Rect Context with Selectors
When context updates, every component that subscribes to it updates. It is by design. Soon after you learn this you start to look for solutions to prevent unnecessary re-renders.
Even though a component might need to react only when a specific part of the context changes, there is no escape hatch to it. The useContext() hook runs and returns the context value on every value change. And so the component that subscribes to it needs to re-render.
☝️ HEADS UP!
You can use memo to stop children from being re-rendered but that won’t prevent the componetn consuming context to re-render.
But is it possible to use React Context without the unnecessary re-renders?
And, moreover, is it possible also to:
- stop the endless nesting of single value Context Providers?
- “subscribe” only to some of the updates of the parent context?
- access the context value?
It is. However…
Most of the solutions opt-out from the default React Context updates altogether and provide their own subscribe/unsubscribe mechanisms to some sort of event based store updates.
👉 TIP: See how e.g. Zustand allows you to use selectors with their store.
…but there is a React way to do that.
Solution: React Context with Selectors
Selectors are functions that take the current state of the context and return a specific piece of data. They are similar to mapStateToProps in Redux, but they are not tied to Redux.
We are basically looking for a solution that checks few conditions:
- Must be a Context so we can access it from descendant components;
- Must let us select what part of it we are interested in;
- Must let us compare when the value changed (e.g. same value but new reference).
Here’s a sketch of possible implementation to give you a rough idea:
// The code below is just for illustration purposes:
import { createContext, useContext, useEffect, useMemo, useRef } from 'react'
type Selector<T, R> = (state: T) => R
type Callback<R> = (result: R) => void
type Subscriptions<T> = Map<Callback<unknown>, Selector<T, unknown>>
function createSelectorContext<T>(context: Context<T>) {
// Step 1: Create a new Context that will be stable
const SubscriptionsContext = createContext<{
subscribe: <R>(selector: Selector<T, R>, callback: Callback<R>) => void;
unsubscribe: (callback: Callback<unknown>) => void;
} | null>(null)
// Step 2: Create a component we will use to pass store value to
const Provider = memo(({ children, value }: { children: React.ReactNode, value: T }) => {
// Step 3: We will manage subscriptions (selectors and callback pairs)
const subscriptions = useRef<Subscriptions<T>>(new Map())
// Step 4: We provide new context value-an API to subscribe/unsubscribe
// to store updates with an selector and callback
const api = useMemo(() => ({
subscribe: <R>(selector: Selector<T, R>, callback: Callback<R>) => {
subscriptions.current.set(callback, selector)
},
unsubscribe: (callback: Callback<unknown>) => {
subscriptions.current.delete(callback)
},
}), [])
// Step 5: We will loop selectors and call callback with the store slice
useEffect(() => {
subscriptions.current.forEach(
(selector, callback) => {
callback(selector(value))
},
)
}, [value])
return (
<SubscriptionsContext.Provider value={api}>
{children}
</SubscriptionsContext.Provider>
)
})
// Step 6: Return wrappers
return [Provider, SubscriptionsContext]
}
Such implementation is still a little cumbersome to use but it should give you the impression of how it could work.
To further simplify usage we can return also Consumer or a hook (do not forget to unsubscribe on unmount after subscribing).
What’s next
- See the live demo at https://attitude.github.io/react-selector-context/
- See the code to see how the demo works
- You can try the RC of the library by adding it to your project right away:
npm add react-selector-contet
☝️ IMPORTANT
Make sure your selectors return stable reference (e.g. numbers, booleans, strings are safe, but objects are recreated on each context update.) Either pass immutable value to the Selector Context Provider that does keeps references of the nested values or add the 2ⁿᵈ argument to the hook to check for equality.
I haven’t saw such implementation anywhere so it might as well be all wrong. It might also be great, it si too new to tell.
I at least hope to inspire you to write your own solutions, better ones even. So please let me know how this works for you!
Please open an issue or reach out to me if you find something is not working as expected.
Cheers! 👋
Martin