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.
// Returns a Proxy over initialState. Mutating it re-renders the component.
function useReactive<S extends object>(initialState: S): S;
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
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.Object.is decides. Writing the value that is already there changes nothing, so it re-renders nothing.Map, Set, Date, and class instances. Plain objects and arrays only.