useReactiveLoading saved progress…

useReactive

useReactive(initialState) returns a state object you update by writing to it. state.count++ re-renders the component. So do state.user.name = 'Ada', state.list.push(item), and delete state.draft — no setter, no spread, no rebuilding the object around the one field you changed. It is the ergonomics of Vue's reactive() inside a React component, and ahooks ships it.

The convenience has a price, and the price is the question. React decides what changed by comparing references with Object.is. An object you mutate in place keeps the same reference forever, so React.memo, useMemo and useEffect dependency arrays all compare that object to itself, conclude nothing changed, and skip. Nothing throws. The child just quietly shows the old data.

Build the hook. Then let the tests show you what it cost.

Signature

// Returns a Proxy over initialState. Mutating it re-renders the component.
function useReactive<S extends object>(initialState: S): S;

Examples

The pitch, in full:

function Counter() {
  const state = useReactive({ count: 0, user: { name: 'ada' }, tags: [] });

  return (
    <div>
      <button onClick={() => state.count++}>{state.count}</button>
      <button onClick={() => state.tags.push('react')}>tag</button>
    </div>
  );
}

Every kind of write lands, at any depth:

const state = useReactive({ user: { name: 'ada' }, list: [1, 2] });

state.user.name = 'grace'; // re-renders
state.list.push(3); // re-renders
state.brandNew = true; // re-renders — a new key counts
delete state.user.name; // re-renders
state.list = state.list; // does NOT re-render — nothing changed

Notes

  • The same proxy, every time. Two reads of state.user must give the same object. Build a fresh proxy per read and state.user !== state.user, which breaks more than you would guess — starting with state.list.indexOf(state.list[0]) returning -1.
  • Nested, recursive, and lazy. Objects and arrays at any depth are proxied, but only when something reads them. A sub-tree nobody touches is never wrapped.
  • Stable across renders. The hook returns the same object on render 50 as on render 1. It is the component's state, not a value derived from it.
  • Reads are not tracked. Any write re-renders the component, whatever it happened to read. This is not fine-grained reactivity — see Reactive Proxy Signals for the version that is.
  • Object.is decides. Writing the value that is already there changes nothing, so it re-renders nothing.
  • Out of scopeMap, Set, Date, and class instances. Plain objects and arrays only.

FAQ

Why does React.memo stop working with useReactive?
React.memo compares props by reference. Mutating an object in place changes its contents but never its reference, so memo compares the same object to itself, correctly concludes nothing changed, and skips the child. Nothing throws — the child just shows stale data.
Why must nested proxies be cached in a WeakMap?
Without a cache every read builds a new Proxy over the same target, so state.user !== state.user. That breaks plain JavaScript before it breaks React: items.indexOf(items[0]) returns -1, includes returns false, and filter cannot remove anything by reference.
Is useReactive fine-grained reactivity like Vue or Solid?
No. It does not track reads at all, so any write re-renders the whole component regardless of what it rendered. Fine-grained reactivity needs the get trap to record which effect read which key, which is a different and larger engine.
When is useReactive the right choice?
In a component that owns its state and passes none of it down — a form, a wizard, a local editor. The ergonomics are real and nothing is memoized, so nothing is skipped. It becomes a trap the moment the state crosses a boundary into a memoized child or an object dependency array.
Loading editor…