How useSelector
can trigger an update only when we want it to
A deep dive into the inner workings of a seemingly-impossible hook
When a react context updates, all components that use that context also update. This would cause huge performance issues if all components with react-redux’s useSelector
re-rendered each time any part of the redux store changed. So how does useSelector
work?
I am a big fan of React’s context api, but performance issues can crop up when two pieces of state live in a context and one of them updates often while the other does not. If I only need the less-frequently-changing piece of state in my component, it will still re-render every time the more-frequently-changing piece of state updates.
The useSelector
hook from react-redux doesn’t have this issue — components only re-render when their selected piece of state changes, even when other slices of the store are updated. So how does it work? Does it somehow bypass the rules of context?
Of course, the answer is “no”, but useSelector
employs some creative tactics to get there. Before we begin, let’s take a closer look at the problem. A contrived example of a context with two pieces of state, clicks
and time
, might look like this:
One piece of state (time
) tracks the number of seconds elapsed since opening the app, another (clicks
) tracks how many times the user clicks a button. Here’s how we might use them in our app:
The timer increments every second, and the click counter increments whenever our button is clicked. However, since the click component reads from the same context as the timer, it also re-renders every time the timer increments, even when the click state hasn’t changed. Not good!
The accepted solution seems to be “split your state into separate contexts”, but this doesn’t feel great to me as it creates artificial boundaries. If I’m creating an audio player, I don’t want to have to split up my isPlaying
state from currentTime
just because currentTime
changes often and isPlaying
does not.
Now that we understand the problem, let’s look at useSelector
. If I were to build react-redux from scratch, it might look something like this.
While my useSelector
correctly returns the state of the store, it suffers from the same problem as above and re-renders <Clicker />
every time the timer updates. So how does the useSelector
from react-redux get around this?
I’m not an author or expert on react/redux, but after digging around the source code, I think I have the answer: the react-redux context value never actually changes.
Let me clarify that. Redux has an ever-changing store, but the way we access the state of the store is through its getState
method. The reference to the getState
method does not change. The store state returned from getState()
may change, but the getter method itself remains referentially equal throughout the app’s lifecycle.
This would normally be a no-no in react-land — we usually want our layouts to declaratively re-render when the state they rely on changes. However, as we just saw, this can also cause unwanted renders.
So how does a component “know" when to re-render if it can’t rely on changes to its props/context? A different pattern is used: a subscription.
useSelector
registers a subscriber that gets called whenever the redux store gets updated, and then if that update results in a change to the selected state, it triggers a re-render and returns the new value. That subscription happens here:
subscription.onStateChange = checkForUpdates
The subscription calls checkForUpdates
, which checks whether the update to the store resulted in a change to the selected state. If state has changed, a re-render is triggered by calling forceRender({})
:
The only job that forceRender
has is, unsurprisingly, to force a re-render. It does this by incrementing a piece of local state in a sorta-hacky-but-simple way — every time forceRender
is called, it increments an internal counter, which isn’t actually used for anything, but has the desired effect:
const [, forceRender] = useReducer(s => s + 1, 0)
This re-render in turn causes useSelector
to select the appropriate piece of state from the store, which is then returned to our component:
selectedState = selector(store.getState())...return selectedState
To summarize, a subscription is set up that fires whenever any redux state changes. When the subscription fires, it calls a function within each instance of useSelector
that checks whether its internal reference to selected (old) store state is equal to the selection using the current (new) state of the store. If they differ, a new render is forced, returning the updated state. This doesn’t feel very “reacty” to me (whatever that means), but it does the trick!*
You may see a gotcha here. If my selector returns multiple combined pieces of state, the newly-created selected object will never be referentially equal to the last time the subscription fired, which will put us back in the same boat where re-renders occur too often.
useSelector(state => {
// Each time this runs it returns a brand new object
return {
thingOne: state.thingOne
thingTwo: state.thingTwo
}
})
This can be mitigated by using an equality function like shallowEqual
as the second argument to useSelector
. See https://react-redux.js.org/next/api/hooks#equality-comparisons-and-updates
Just for good measure, here’s a working-but-minimal useSelector
hook created from scratch that only triggers an update when its selected state changes:
https://codesandbox.io/s/peaceful-river-dj5ps
NOTE: While rolling your ownuseSelector
is an interesting exercise, I am NOT advocating that you do this in a production app!
Please let me know if this was helpful to you or if I missed anything!
*The reason the subscriber pattern doesn’t feel “reacty” to me is that the component is no longer just the result of “props and state”. Instead, the parent context has to hold on to function references from within each child instance of useSelector
that are explicitly called at the right time.
**I’ve been using Kent C. Dodds’ context pattern and am liking it a lot — it doesn’t help with the issue discussed above but makes working with context a lot friendlier: https://kentcdodds.com/blog/how-to-use-react-context-effectively