You are publishing a shared custom hook, useFilters(), that returns the current filter state plus functions to change it. Consumers memoize child components and list your functions in dependency arrays. What identity guarantees should the hook's return value make, and how do you implement them?
answer
- identity is public API here
- who consumes what you return
- keep state out of the dependency list
- updater form, or a latest-value ref
- setState and dispatch are already stable
basics
~20 sPromise that the functions a hook returns keep the same identity for the life of the component, and that data changes identity only when it really changed. Implement stable functions with useCallback plus updater-form updates or a latest-value ref, so no state sits in their dependency list.
solid answer
~50 sAny function a shared hook hands back becomes someone else's dependency, so I treat identity as part of the contract: the updater functions are stable for the component's lifetime, and the data fields change identity only when the data changed. Implementing that means keeping current state out of the callbacks' dependency lists. I write updates in the updater form — `setFilters(prev => ({ ...prev, [key]: value }))` — so the callback never needs to read `filters`, and it can be wrapped in `useCallback` with an empty dependency array. When a callback genuinely has to read the latest value for something other than an update, I keep that value in a ref updated in an effect and read `ref.current` inside a stable callback. I also lean on what React already guarantees: the setter from `useState` and the `dispatch` from `useReducer` are stable, so re-exporting one of those directly is the cheapest stable API of all. Then I document which fields are stable, because callers cannot see it.
code
javascript · 21 linesimport { useCallback, useEffect, useRef, useState } from 'react';
export function useFilters(initial) {
const [filters, setFilters] = useState(initial);
// A mailbox holding the latest value, updated after every commit.
const filtersRef = useRef(filters);
useEffect(() => {
filtersRef.current = filters;
});
// Stable: the updater form means no state is needed in the dependency list.
const setOne = useCallback((key, value) => {
setFilters((prev) => ({ ...prev, [key]: value }));
}, []);
// Stable: reads the latest value through the ref instead of a dependency.
const describe = useCallback(() => JSON.stringify(filtersRef.current), []);
return { filters, setOne, describe };
}go deeper
Know that a function created in a component body is a brand-new function each render, and that useCallback exists to keep the same one — so a hook handing callbacks to others has to think about that.
Explain why a useCallback that lists current state in its dependencies is barely better than no memoization, and show the updater form as the way to drop that dependency.
Treat identity as a contract you publish: decide which callbacks are stable, implement them with updater form or a latest-value ref, keep data identity honest rather than frozen, and note that useState setters and dispatch are already stable.
Own the guarantee across teams — document per-field stability, test it so a refactor cannot silently break far-away consumers, and weigh how much you promise, since every stability guarantee constrains the implementation permanently.
## Why identity is part of a hook's public contract When a hook is used in one component you control, function identity is a detail. When it is a shared hook, every value it returns can end up in three places you do not control: a dependency array, a `React.memo` child's props, and a `useMemo` input. In all three, identity decides behaviour. So the honest API of `useFilters()` is not just "returns filters and setters" — it is "returns filters, which change identity when the filters change, and `setOne`/`clear`, which never change identity". Consumers cannot discover that by reading types, so it belongs in the hook's documentation and in your tests. ## What React already guarantees Two things are stable without any work on your part: the setter returned by `useState` and the `dispatch` returned by `useReducer`. React documents both, which is why `dispatch` is safe to pass through context and to omit from dependency arrays. If your hook's public surface can be the raw `dispatch`, you are done. Note what is *not* stable: the array `useState` returns is a fresh array each render, refs are stable objects but `ref.current` is not a value React tracks, and anything you build in the hook body is new on every render unless you memoize it. ## Making your own callbacks stable The usual mistake is reaching for `useCallback` and then listing current state in its dependencies: ```javascript // Unstable: identity changes whenever `filters` changes — i.e. constantly. const setOne = useCallback((key, value) => { setFilters({ ...filters, [key]: value }); }, [filters]); ``` This is `useCallback` in name only. The fix is not to shorten the dependency list but to remove the need for the dependency: ```javascript // Stable: the updater form receives the previous value, so no dependency is needed. const setOne = useCallback((key, value) => { setFilters((prev) => ({ ...prev, [key]: value })); }, []); ``` The updater form covers most cases, because most callbacks are computing the next state from the previous one. When a callback needs the latest value for something else — logging it, sending it to an API, deciding whether to act — use a ref as a mailbox for the current value and read it inside a stable callback: ```javascript const filtersRef = useRef(filters); useEffect(() => { filtersRef.current = filters; }); const submit = useCallback(() => { api.search(filtersRef.current); // always the latest, no dependency }, []); ``` The trade-off is deliberate: `submit` is no longer reactive to `filters`, which is exactly what you want for an imperative action and exactly what you do not want for something that should re-run when filters change. ## Honest identity for data Stability cuts both ways. A hook that returns a new object for unchanged data defeats every consumer's memoization; a hook that returns a stale object for changed data causes missed renders and is much worse. So do not "stabilise" data by caching it past a real change. Where a returned value is derived — a filtered list, a computed summary — wrap the derivation in `useMemo` keyed on the real inputs, so its identity changes exactly when the underlying data does. The returned container object itself is a lesser concern. `return { filters, setOne, clear }` creates a fresh object each render, which costs nothing as long as consumers destructure it immediately. It only matters if someone forwards the whole object as a prop to a memoized child. Memoizing the container is cheap insurance for a widely-used hook; telling callers to pass individual fields is the better guidance. ## Proving and documenting it Stability is a promise that silently breaks. Protect it: write a test that renders the hook, triggers an unrelated state change, and asserts the callback identity is unchanged; document per field what is stable; and be conservative about how much you promise, because every guarantee constrains your implementation forever. Callers who follow the React lint rules will list your callbacks in their dependency arrays whether you like it or not, so an unstable callback in a shared hook shows up as an effect that runs every render somewhere far from your file. That distance is why this belongs in the hook's design, not in the consumer's workaround.
- Which values does React itself guarantee to be identity-stable across renders?The setter function returned by `useState` and the `dispatch` returned by `useReducer` — React documents both as stable for the lifetime of the component, which is why they are safe to omit from dependency arrays and to pass through context. The ref *object* from `useRef` is also the same object each render. Everything else your hook builds is fresh per render unless you memoize it.
- What is the downside of making a callback stable by reading state through a ref?It stops being reactive. The callback always sees the latest value, but nothing re-runs when that value changes, so anything that *should* respond to a change — an effect that refetches, a subscription that must be rebuilt — must not use the ref trick. Reserve it for imperative actions the user triggers, and keep genuine reactivity in dependencies.
- Should the object a hook returns be memoized so consumers can compare it?Only when a consumer really forwards the whole object onward. Most callers destructure it on the spot, so a fresh literal per render is free. If the hook is widely used, memoizing the container is cheap insurance — but the better advice to callers is to pass the individual fields to memoized children rather than the whole bag.
- How would you catch a regression that makes one of these callbacks unstable?Test it directly: render the hook, capture the callback, trigger an unrelated state update, re-read it and assert the two references are identical. It is a two-line assertion that pins a promise consumers rely on, and without it a well-meaning refactor that adds one dependency to a `useCallback` will break memoization in components nobody thought to check.
saying these in an interview costs you the question
- Wraps a callback in useCallback while listing the state it mutates
- Thinks useCallback compares function bodies rather than identity
- Claims every value a hook returns should be memoized by default
- Freezes returned data so it stops changing when the data changed
- Assumes consumers can see which returned functions are stable