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.