The data fetching library swr uses a clever technique to drastically reduce the number of React re-renders in components that share state. I call it hook getters, and just like state selectors, it stems from this idea: if you never use a certain value, why re-render when that value changes?
For example:
const Component = () => {
const { data } = useSWR()
// useSWR also returns `error`, but it's unused here
// No need to re-render when `error` changes
}
Instead of selector callbacks, SWR uses JavaScript getters
to detect when values are being read, and will only re-render for relevant changes. This trick only applies if your state is shared and object-like – which happens to be a majority of state and context in React!
Implementation
I wrote a collection of concepts that implement this pattern with a Proxy. Here’s the idea:
- Keep state outside of render lifecycle (could be ref, or global)
- State returned from the hook is actually a proxy
- When proxy
get
is invoked: track which object key is used, return the value - When proxy
set
is invoked: update state, broadcast to all listeners - In each hook, listen for broadcast but only re-render if the changed key is tracked
Let’s model our state solution after Zustand, where you create state “stores” outside of render:
const useStore = createStore({
name: 'John',
username: 'johndoe',
})
function NameDisplay() {
const state = useStore()
// Should only re-render when state.name changes, as username is unused
return <input value={state.name} onChange={(e) => (state.name = e.target.value)} />
}
When creating a store, we’ll track the state and any listeners. When each hook subscribes to the shared state, we just add it to a set of listeners.
function createStore(initial = {}) {
const state = initial
const listeners = new Set()
function subscribe(listener) {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
}
Setting state checks for equality and broadcasts to any listeners if the state changed:
function setState(key, value) {
if (!Object.is(state[key], value)) {
state[key] = value
listeners.forEach((listener) => listener(state, key))
}
}
Finally, creating the store returns a hook to actually use the store. It creates a proxy that updates a tracked
ref on any get
access, and calls setState
for any set
access.
return function useStore() {
const rerender = useState()[1]
const tracked = useRef({})
const stateRef = useRef(state)
const proxy = useMemo(() => {
stateRef.current = state
return new Proxy(
{},
{
get(_, property) {
tracked.current[property] = true
return stateRef.current[property]
},
set(_, property, value) {
setState(property, value)
return true
},
},
)
}, [])
}
When the hook mounts, it subscribes to the store and listens for any state broadcasts. It only forces a re-render when a state broadcast modified a key the hook is already tracking.
useEffect(() => {
const unsub = subscribe((_, key) => {
if (tracked.current[key]) {
rerender({})
}
})
return unsub
}, [])
Finally, return the proxy.
return proxy
That’s about it, a simple shared state system in about 50 lines. Full code is here.