useStateWithDeps
A useState-like hooks for plain objects with dependency tracking and minimum re-renders. useStateWithDeps returns a snapshot that tracks which properties you have read, and only re-renders when those properties change.
Usage
import { useStateWithDeps } from 'foxact/use-state-with-deps';
const Demo = () => {
const [snapshot, setState] = useStateWithDeps({
tracked: 0,
untracked: 0
});
// Only `tracked` is read during render, so it becomes the only rendering
// dependency of this component. Changing `untracked` alone will NOT
// re-render.
return (
<div>
<p>tracked: {snapshot.tracked}</p>
<button onClick={() => setState({ tracked: 1 })}>re-renders</button>
<button onClick={() => setState({ untracked: 1 })}>doesn't re-render</button>
</div>
);
};setState accepts a partial payload and merges it into the current state (unlike useState, which replaces the entire state):
const [snap, setState] = useStateWithDeps({ a: 1, b: 42 });
// `b` is left untouched
setState({
a: 10,
});
// add `c` to the state object, `a` and `b` are left untouched
setState({
c: 114514
});
// snap is now { a: 10, b: 42, c: 114514 }A property only becomes a rendering dependency once it is read during render. In the first example above, calling setState({ untracked: 1 }) updates the state (subsequent reads will see 1) but doesn’t trigger a re-render, because untracked hasn’t been read yet.
Values are compared with Object.is, the same comparison useState uses to bail out: setting a property to its current value will not trigger re-render.
setState also accepts an updater function that receives the current state and returns a partial payload:
setState(prevState => ({ count: prevState.count + 1 }));Always prefer the updater function when deriving the next state value from the current one, as accessing the current state value through the updater function will bypass the dependency tracking and may avoid unnecessary re-renders:
const handleClick = useCallback(() => {
// ❌ reads `count` through the tracked snapshot: `count` becomes a
// permanent rendering dependency
setState({ count: snap.count + 1 });
// ✅ reads `count` through the untracked `prevState`: `count` will not
// become a rendering dependency
setState(prevState => ({ count: prevState.count + 1 }));
}, [setState, snap]);Both the snapshot and setState are referentially stable across re-renders, so it is safe to include/omit them from other hooks’ dependency arrays.
Caveats
useStateWithDeps is designed for plain objects with the following caveats:
useStateWithDepsis designed for plain objects with a (mostly) fixed shape.- Rendering dependencies are permanent: once a property has been read through the snapshot, it stays a rendering dependency for the lifetime of the component (until unmount), even if a conditional branch stops reading it.
- Never spread the snapshot. Dependency tracking works by intercepting property reads with getters, and a spread (
{ ...snapshot }) reads every property at once — every property becomes a rendering dependency, and the component re-renders on every change, which defeats the entire optimization.- Destructuring specific properties (
const { a, b } = snapshot) is perfectly fine, it only reads the properties you explicitly name, which are the ones you intend to depend on anyway.
- Destructuring specific properties (
Extending the snapshot
When building a custom hook on top of useStateWithDeps, you may want to return the state alongside extra members that are not part of the state (a mutate function, for example). Spreading the snapshot would break the optimization as described above. Instead, wrap the snapshot with getters that delegate property access lazily:
function useRequest() {
const [snapshot, setState] = useStateWithDeps({
data: undefined,
error: undefined,
isLoading: false
});
const mutate = useCallback(/** ... */, [setState]);
return {
// ❌ DON'T DO THIS. Spread would read (and track) every property right here
// ...snapshot
//
// ✅ a getter delegates the read: `data` is only read through the
// snapshot (and only becomes a rendering dependency) when the consumer
// of `useRequest` actually accesses it
get data() { return snapshot.data; },
get error() { return snapshot.error; },
get isLoading() { return snapshot.isLoading; },
mutate
};
}