React Hook Getter Pattern

09/27/21

SWR uses a clever technique to reduce the number of re-renders in components that share state. Just like state selectors, it stems from this idea: if you never use a certain value, why re-render when that unused value changes?

For example:

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 apps.

Implementation

I wrote a collection of concepts that implement this pattern with a Proxy. Here’s the general 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.