Skip to content

Commit 6948056

Browse files
MrWolfZmarkerikson
authored andcommitted
add deps to useSelector to allow controlling stability of selector (#1251)
* fix stale selector issue * add `deps` to `useSelector` to allow controlling stability of selector
1 parent 8b991ed commit 6948056

File tree

2 files changed

+43
-12
lines changed

2 files changed

+43
-12
lines changed

src/hooks/useSelector.js

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,14 @@ const useIsomorphicLayoutEffect =
1818
/**
1919
* A hook to access the redux store's state. This hook takes a selector function
2020
* as an argument. The selector is called with the store state.
21+
*
22+
* This hook takes a dependencies array as an optional second argument,
23+
* which when passed ensures referential stability of the selector (this is primarily
24+
* useful if you provide a selector that memoizes values).
2125
*
2226
* @param {Function} selector the selector function
27+
* @param {any[]} deps (optional) dependencies array to control referential stability
28+
* of the selector
2329
*
2430
* @returns {any} the selected state
2531
*
@@ -35,7 +41,7 @@ export const CounterComponent = () => {
3541
}
3642
```
3743
*/
38-
export function useSelector(selector) {
44+
export function useSelector(selector, deps) {
3945
invariant(selector, `You must pass a selector to useSelectors`)
4046

4147
const { store, subscription: contextSub } = useReduxContext()
@@ -46,13 +52,15 @@ export function useSelector(selector) {
4652
contextSub
4753
])
4854

55+
const memoizedSelector = useMemo(() => selector, deps)
56+
4957
const latestSubscriptionCallbackError = useRef()
50-
const latestSelector = useRef(selector)
58+
const latestSelector = useRef(memoizedSelector)
5159

5260
let selectedState = undefined
5361

5462
try {
55-
selectedState = latestSelector.current(store.getState())
63+
selectedState = memoizedSelector(store.getState())
5664
} catch (err) {
5765
let errorMessage = `An error occured while selecting the store state: ${
5866
err.message
@@ -70,7 +78,7 @@ export function useSelector(selector) {
7078
const latestSelectedState = useRef(selectedState)
7179

7280
useIsomorphicLayoutEffect(() => {
73-
latestSelector.current = selector
81+
latestSelector.current = memoizedSelector
7482
latestSelectedState.current = selectedState
7583
latestSubscriptionCallbackError.current = undefined
7684
})

test/hooks/useSelector.spec.js

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/*eslint-disable react/prop-types*/
22

3-
import React from 'react'
3+
import React, { useReducer } from 'react'
44
import { createStore } from 'redux'
55
import * as rtl from 'react-testing-library'
66
import { Provider as ProviderMock, useSelector } from '../../src/index.js'
@@ -165,15 +165,40 @@ describe('React', () => {
165165

166166
expect(renderedItems.length).toBe(1)
167167
})
168+
169+
it('re-uses the selector if deps do not change', () => {
170+
let selectorId = 0
171+
let forceRender
172+
173+
const Comp = () => {
174+
const [, f] = useReducer(c => c + 1, 0)
175+
forceRender = f
176+
const renderedSelectorId = selectorId++
177+
const value = useSelector(() => renderedSelectorId, [])
178+
renderedItems.push(value)
179+
return <div />
180+
}
181+
182+
rtl.render(
183+
<ProviderMock store={store}>
184+
<Comp />
185+
</ProviderMock>
186+
)
187+
188+
rtl.act(forceRender)
189+
190+
// this line verifies the susbcription callback uses the same memoized selector and therefore
191+
// does not cause a re-render
192+
store.dispatch({ type: '' })
193+
194+
expect(renderedItems).toEqual([0, 0])
195+
})
168196
})
169197

170198
describe('edge cases', () => {
171199
it('ignores transient errors in selector (e.g. due to stale props)', () => {
172-
// TODO Not sure this test is really testing what we want.
173-
// TODO The parent re-renders, which causes the child to re-run the selector anyway and throw the error.
174-
// TODO Had to flip the assertion for now. Probably needs to be rethought.
175-
176200
const spy = jest.spyOn(console, 'error').mockImplementation(() => {})
201+
177202
const Parent = () => {
178203
const count = useSelector(s => s.count)
179204
return <Child parentCount={count} />
@@ -197,9 +222,7 @@ describe('React', () => {
197222
</ProviderMock>
198223
)
199224

200-
expect(() => store.dispatch({ type: '' })).toThrowError(
201-
/while selecting the store state/
202-
)
225+
expect(() => store.dispatch({ type: '' })).not.toThrowError()
203226

204227
spy.mockRestore()
205228
})

0 commit comments

Comments
 (0)