To stop an effect re-running every render, a React codebase writes `useEffect(() => { load(filters); }, [JSON.stringify(filters)]);`, where `filters` is an object rebuilt in the component body each render. When does that work, when does it fail, and what would you do instead?
answer
- a string standing in for identity
- serialization is not equality
- key order is never normalized
- undefined and functions disappear silently
basics
~20 sIt works only for small plain objects whose keys are always written in the same order. Reordered keys re-run the effect needlessly, and values JSON drops can make genuinely different objects serialize identically. Stabilize the object where it is created instead.
solid answer
~50 sIt substitutes a string for identity, and strings compare by value, so the dependency does settle — that part works. The trouble is that `JSON.stringify` is a poor equality proxy. Property order follows insertion order rather than being normalized, so `{ a, b }` and `{ b, a }` produce different strings and re-run the effect for no reason. It also drops properties whose value is `undefined`, plus functions and symbols, so two meaningfully different objects can serialize to the same string and a real change is missed. `Date` becomes a string through `toJSON`, `Map` and `Set` become `{}`, and a circular reference throws. It also runs on every render, at a cost proportional to the object's size. I would fix identity at the source instead: destructure the object into primitive dependencies, or `useMemo` it where it is created — and if a key really is needed, build a small deliberate one from known fields rather than serializing whatever the object happens to hold.
code
javascript · 15 linesimport { useEffect } from 'react';
const load = (q) => console.log('loading', q);
export function Results({ status, sort, tags }) {
// A deliberate primitive key from fields we control, in a fixed order,
// hoisted to a variable so the dependency array stays statically checkable
const tagKey = tags.join(',');
useEffect(() => {
load({ status, sort, tags: tagKey ? tagKey.split(',') : [] });
}, [status, sort, tagKey]);
return <output>{tagKey}</output>;
}go deeper
Know that primitives compare by value while objects compare by reference, and that turning an object into a string is one way people try to exploit that. Recognize it as a workaround, not a standard pattern.
Explain both directions of failure: unsorted keys make identical data look different, and dropped undefined, function, Map and Set values make different data look identical. Offer depending on primitives as the ordinary fix.
Weigh the failure modes and say plainly that silent under-firing beats over-firing as a hazard. Name the per-render cost, the circular-reference throw, and the loss of exhaustive-deps coverage, then fix identity at the creation site.
Own the standard: decide whether object-shaped props and hook returns are allowed at all in your codebase, what a sanctioned cache key looks like, and how reviewers distinguish a deliberate key from an accidental equality check.
## What the trick is actually doing React compares dependency entries with `Object.is`. Objects fail that comparison whenever they are rebuilt, so the workaround replaces the object with a *string derived from* the object: strings compare by value, so an unchanged object yields an unchanged dependency and the effect settles. As a mental model it is "structural equality, cheaply". The question is whether `JSON.stringify` is a sound structural-equality function. It is not — it is a serializer with a defined set of lossy behaviours. ## Where it silently over-fires `JSON.stringify` does not sort keys; it walks own enumerable string-keyed properties in the object's own order. Two objects a user would call identical serialize differently when the code paths that built them inserted keys in a different order: ```js JSON.stringify({ status: 'open', sort: 'asc' }); // {"status":"open","sort":"asc"} JSON.stringify({ sort: 'asc', status: 'open' }); // {"sort":"asc","status":"open"} ``` The strings differ, so the effect re-runs — the exact churn the trick was meant to stop, now hidden behind code that looks like it solved the problem. ## Where it silently under-fires — the dangerous half Serialization is lossy, and the losses collapse distinct objects into one string: - Properties whose value is `undefined` are omitted, as are function-valued and symbol-valued properties. `{ q: 'a' }` and `{ q: 'a', cursor: undefined }` both serialize to `{"q":"a"}`. - `Map` and `Set` have no JSON representation and serialize as `{}`, so two different Sets look identical. - `Date` is serialized through `toJSON` to an ISO string, which is usually fine but loses the object identity distinction. - `NaN` and `Infinity` serialize as `null`, flattening distinct numeric states. - Circular references throw a `TypeError`, turning a render into a crash — and it will be the production data shape that finds it, not your fixture. An over-firing effect is a performance bug you can see. An under-firing effect is a correctness bug that shows as stale data, and it is far more expensive to diagnose. ## The costs you also inherit The call runs on every render, walking the whole object graph, so cost scales with payload size on the hot path. And because the dependency array now contains an expression rather than a value, `react-hooks/exhaustive-deps` cannot statically relate it to what the effect reads — it reports the complex expression and asks you to extract it to a variable, which means you have also given up the rule's protection against a genuinely missing dependency. ## What to do instead, in order of preference **1. Depend on primitives.** If the object is built in the body from `status`, `sort` and `page`, list those directly and construct the object inside the callback. Primitives compare by value, the dependency list documents the real inputs, and the lint rule keeps working. ```jsx useEffect(() => { load({ status, sort, page }); }, [status, sort, page]); ``` **2. Stabilize at the creation site.** If the object arrives as a prop or from a hook, fix it where it is made — hoist it if invariant, `useMemo` it against primitive dependencies if derived, or flatten it into primitive props so no identity contract exists. **3. Build a deliberate key, not a blanket serialization.** When the input really is variable-shaped, derive a small key from fields you control, in a fixed order, and hoist it to a named variable so the dependency array stays statically analysable: ```jsx const tagKey = tags.join(','); useEffect(() => { load({ status, tags: tagKey.split(',') }); }, [status, tagKey]); ``` This is honest about being a key: you chose the fields, the order and the cost, and a reader can see the assumption. **4. A deep-compare guard, sparingly.** Keeping the previous value in a `useRef` and replacing it only when a deep comparison says it changed gives correct structural semantics, but it adds a comparison on every render and a helper to maintain. It is a last resort for genuinely dynamic shapes, not a default. ## How to answer the reviewer The short version: `JSON.stringify` in a dependency array is not wrong because it is ugly, it is wrong because it silently answers a different question than the one asked. Fix identity where identity is created; use a serialized key only when you have deliberately chosen the fields and can state what it ignores.
- Which is the worse failure mode of a stringified dependency — over-firing or under-firing — and why?Under-firing. An effect that runs too often is a visible performance problem you can profile and fix. An effect that fails to run because two different objects serialized to the same string presents as stale data with no error, often only for the input shape that triggers the loss — an `undefined` field, a Set, a `NaN`. Correctness bugs cost far more to find than wasted renders.
- Is there any situation where you would accept a serialized dependency key in review?Yes, when the shape is genuinely dynamic, the object is small, the fields are ones you control, and the serialization is hoisted to a named variable with a comment stating what it ignores. That makes it a deliberate cache key rather than an accidental equality check. What I would reject is `JSON.stringify` applied inline to an arbitrary object that crossed a module boundary.
- Why does putting the expression directly in the dependency array weaken the lint rule?`react-hooks/exhaustive-deps` matches dependency entries against the identifiers the effect reads. An expression like `JSON.stringify(filters)` is not one of those identifiers, so the rule reports a complex expression it cannot statically check and stops vouching for completeness. Extracting the value to a named variable restores static analysis and keeps the array a list of plain values.
saying these in an interview costs you the question
- Assuming JSON.stringify sorts object keys
- Treating serialization as reliable deep equality
- Ignoring undefined and function values being dropped
- Adding it inline and suppressing the lint warning
- Forgetting the cost runs on every single render