Skip to content

Commit 32d81d5

Browse files
committed
Avoid unnecessary selector evaluations
1 parent 6304f01 commit 32d81d5

File tree

2 files changed

+73
-27
lines changed

2 files changed

+73
-27
lines changed

src/hooks/useSelector.js

Lines changed: 20 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useReducer, useRef, useEffect, useMemo, useLayoutEffect } from 'react'
1+
import { useState, useRef, useEffect, useMemo, useLayoutEffect } from 'react'
22
import invariant from 'invariant'
33
import { useReduxContext } from './useReduxContext'
44
import shallowEqual from '../utils/shallowEqual'
@@ -42,61 +42,46 @@ export function useSelector(selector) {
4242
invariant(selector, `You must pass a selector to useSelectors`)
4343

4444
const { store, subscription: contextSub } = useReduxContext()
45-
const [, forceRender] = useReducer(s => s + 1, 0)
4645

4746
const subscription = useMemo(() => new Subscription(store, contextSub), [
4847
store,
4948
contextSub
5049
])
5150

52-
const latestSubscriptionCallbackError = useRef()
53-
const latestSelector = useRef(selector)
54-
55-
let selectedState = undefined
56-
51+
let state, setState
5752
try {
58-
selectedState = selector(store.getState())
53+
;[state, setState] = useState(selector(store.getState()))
5954
} catch (err) {
60-
let errorMessage = `An error occured while selecting the store state: ${
55+
const errorMessage = `An error occured while selecting the store state: ${
6156
err.message
6257
}.`
63-
64-
if (latestSubscriptionCallbackError.current) {
65-
errorMessage += `\nThe error may be correlated with this previous error:\n${
66-
latestSubscriptionCallbackError.current.stack
67-
}\n\nOriginal stack trace:`
68-
}
69-
7058
throw new Error(errorMessage)
7159
}
60+
const [error, setError] = useState(null)
7261

73-
const latestSelectedState = useRef(selectedState)
62+
const latestSelector = useRef(selector)
7463

7564
useIsomorphicLayoutEffect(() => {
7665
latestSelector.current = selector
77-
latestSelectedState.current = selectedState
78-
latestSubscriptionCallbackError.current = undefined
79-
})
66+
setState(selector(store.getState()))
67+
}, [selector])
8068

8169
useIsomorphicLayoutEffect(() => {
8270
function checkForUpdates() {
8371
try {
8472
const newSelectedState = latestSelector.current(store.getState())
8573

86-
if (shallowEqual(newSelectedState, latestSelectedState.current)) {
74+
if (shallowEqual(newSelectedState, state)) {
8775
return
8876
}
89-
90-
latestSelectedState.current = newSelectedState
77+
setState(newSelectedState)
9178
} catch (err) {
9279
// we ignore all errors here, since when the component
9380
// is re-rendered, the selectors are called again, and
9481
// will throw again, if neither props nor store state
9582
// changed
96-
latestSubscriptionCallbackError.current = err
83+
setError(err)
9784
}
98-
99-
forceRender({})
10085
}
10186

10287
subscription.onStateChange = checkForUpdates
@@ -107,5 +92,13 @@ export function useSelector(selector) {
10792
return () => subscription.tryUnsubscribe()
10893
}, [store, subscription])
10994

110-
return selectedState
95+
if (error) {
96+
const errorMessage = `An error occured while selecting the store state: ${
97+
error.message
98+
}.\n\nOriginal stack trace:\n${error.stack}`
99+
100+
throw new Error(errorMessage)
101+
}
102+
103+
return state
111104
}

test/hooks/useSelector.spec.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,31 @@ describe('React', () => {
4747
})
4848

4949
describe('lifeycle interactions', () => {
50+
it.only('always uses the latest state', () => {
51+
store = createStore(c => c + 1, -1)
52+
53+
const Comp = () => {
54+
const selector = useCallback(c => c + 1, [])
55+
const value = useSelector(selector)
56+
renderedItems.push(value)
57+
return <div />
58+
}
59+
60+
rtl.render(
61+
<ProviderMock store={store}>
62+
<Comp />
63+
</ProviderMock>
64+
)
65+
66+
expect(renderedItems).toEqual([1])
67+
68+
act(() => {
69+
store.dispatch({ type: '' })
70+
})
71+
72+
expect(renderedItems).toEqual([1, 2])
73+
})
74+
5075
it('subscribes to the store synchronously', () => {
5176
let rootSubscription
5277

@@ -183,6 +208,34 @@ describe('React', () => {
183208

184209
expect(renderedItems).toEqual([0, 0])
185210
})
211+
212+
it('uses the latest selector', () => {
213+
let selectorId = 0
214+
let forceRender
215+
216+
const Comp = () => {
217+
const [, f] = useReducer(c => c + 1, 0)
218+
forceRender = f
219+
const renderedSelectorId = selectorId++
220+
const value = useSelector(() => renderedSelectorId)
221+
renderedItems.push(value)
222+
return <div />
223+
}
224+
225+
rtl.render(
226+
<ProviderMock store={store}>
227+
<Comp />
228+
</ProviderMock>
229+
)
230+
231+
rtl.act(forceRender)
232+
233+
// this line verifies the susbcription callback uses the same memoized selector and therefore
234+
// does not cause a re-render
235+
store.dispatch({ type: '' })
236+
237+
expect(renderedItems).toEqual([0, 1])
238+
})
186239
})
187240

188241
describe('edge cases', () => {

0 commit comments

Comments
 (0)