In react dependencies, why do I sometimes need to deconstruct the props object to depend on specific values and sometimes not?
It's not a React rule, it is a JavaScript rule (or, more precisely, a rule of every linter / type-checker that is normally used in a React project).
- What the dependency array really is
In every place where React asks for a dependency array—useEffect,useMemo,useCallback,React.memo, etc.—React itself only cares about object identity.JavaScript复制useEffect(() => { ... }, [props])React will compare the previous array[prevProps]with the next array[nextProps]byObject.is.
Ifpropsis the same object reference, React assumes nothing changed and skips the effect.
Ifpropsis a different object, React re-runs the effect, no matter whether the contents of the object changed. - Why that is usually wrong
In idiomatic React thepropsobject is re-created on every render (because the parent either calls<Child ... />again or spreads a new object).
Therefore[props]will always be a new reference, the effect will run on every render, and you lose the optimisation you were trying to gain. - What the linter tells you to do
The eslint-plugin-react-hooks rulereact-hooks/exhaustive-depstries to protect you from that mistake.
It does a static analysis and says:- “You used
props.fooinside the effect, therefore the valuefoois what the effect actually depends on, not the wholepropsobject. - So put
props.fooin the array instead ofprops.”
That is why you “have to” destructure or dot into the object:JavaScript复制const { foo, bar } = props; useEffect(() => { doSomething(foo, bar); }, [foo, bar]);Now if the parent re-renders but only changesbaz,fooandbarkeep the same reference / primitive value, and the effect is skipped—exactly what you want. - When you do NOT have to destructure
- If you really do depend on the identity of the whole object (rare):JavaScript复制
useEffect(() => { console.log('props object itself changed'); }, [props]);The linter will still warn; you silence it with// eslint-disable-line react-hooks/exhaustive-depsif that is intentional. - If you forward the object unchanged to another hook that is itself stable, e.g.JavaScript复制
const data = useCustomHook(props); // custom hook will read what it needsyou may leavepropsas the dependency, but again only if you know the custom hook is fine with it.
In short:
React only compares references; the linter tells you to put the actual primitive values or inner references into the array so the comparison becomes meaningful. Destructuring is just the cleanest way to spell those inner values.
React only compares references; the linter tells you to put the actual primitive values or inner references into the array so the comparison becomes meaningful. Destructuring is just the cleanest way to spell those inner values.
Why not
useEffect(() => {
doSomething(props.foo, props.bar);
}, [props.foo, props.bar]);
You absolutely can write
JavaScript
复制
useEffect(() => {
doSomething(props.foo, props.bar);
}, [props.foo, props.bar]);and the dependency rule will be perfectly happy—
props.foo and props.bar are the exact values the effect depends on, so the linter will stay silent.The only reason people often write
JavaScript
复制
const { foo, bar } = props;
useEffect(() => {
doSomething(foo, bar);
}, [foo, bar]);is purely stylistic / ergonomic:
- It’s shorter to type
[foo, bar]than[props.foo, props.bar]every time. - It lets you use the variables directly inside the callback without the
props.prefix, which some find cleaner.
Functionally the two snippets are identical; the linter does not care which one you choose.
Sometimes the linter stays silent, but sometimes it still complains about "props" being used and needing to be a dependency unless I explicitly deconstruct. For example I have a wsSubscribe function in a prop and it wants that to be deconstructed
The difference is how the linter can “see” the property access.
eslint-plugin-react-hooks does a static, syntactic scan.
It looks for identifiers that appear inside the effect and are not declared inside the effect.
If it sees the identifier
It looks for identifiers that appear inside the effect and are not declared inside the effect.
If it sees the identifier
props, it adds props to the list of missing dependencies, because—according to the AST—it cannot know which part of props you are using.Example that triggers the warning:
JavaScript
复制
useEffect(() => {
props.wsSubscribe('ticker'); // <- the identifier `props` itself is referenced
}, []); // linter says: “React Hook useEffect has a missing dependency: ‘props’”Example that does not trigger the warning:
JavaScript
复制
useEffect(() => {
const { wsSubscribe } = props; // destructuring is done *inside* the effect
wsSubscribe('ticker');
}, []); // still wrong, but the rule doesn’t complainor the usual correct pattern:
JavaScript
复制
const { wsSubscribe } = props; // destructuring is done *outside* the effect
useEffect(() => {
wsSubscribe('ticker');
}, [wsSubscribe]); // linter is happyThe rule only complains when the whole variable named
When you destructure (either outside or inside the effect) the rule no longer sees a reference to
props is referenced.When you destructure (either outside or inside the effect) the rule no longer sees a reference to
props; it only sees the individual properties (wsSubscribe in the example), and it will then ask for those specific properties instead of the entire props object.So the linter’s behaviour you observe is expected and consistent:
props.something→ seesprops→ warns.- Destructure first → sees only the destructured names → warns about those names instead.
But if instead of a function it's a number like id then it's perfectly fine with me using props.id in the dependency array, so it's not just an AST thing
It is still an AST thing—just one that looks one level deeper when the value is a primitive literal (number, string, boolean, null, undefined) but not when it is an object or a function.
What the rule actually does:
- It walks the AST inside the effect callback.
- Whenever it sees a MemberExpression of the form
props.foo
it doesn’t automatically add"props"to the missing-deps list.
Instead it tries to decide whether the member itself (props.foo) is “stable”. - For primitive literals (
props.id,props.count, etc.) the rule treats the member access as “stable enough” and is satisfied if you listprops.iddirectly.
It assumes the value is unlikely to be mutated in place (because primitives are immutable). - For functions, objects, arrays (
props.wsSubscribe,props.config,props.style, etc.) the rule assumes the value might be recreated on every render, so it flags the base object (props) as missing unless you destructure or explicitly put the exact member in the list.
That is why
JavaScript
复制
useEffect(() => { ... }, [props.id]) // linter is quietpasses, whereas
JavaScript
复制
useEffect(() => { props.wsSubscribe() }, [props.wsSubscribe])may still warn if
wsSubscribe is a new arrow function on every render, and the rule wants you to destructure or memoise it.So the apparent inconsistency you noticed is the linter’s heuristic:
- Primitive → “use the literal value”.
- Function / object → “use the destructured reference or memoise”.
Can I have a source on this?
搜索
eslint-plugin-react-hooks props warning AST analysis 24 个结果
Yes—this behaviour is documented in the source code of the official rule
The rule is implemented in the
Its logic is deliberately heuristic:
react-hooks/exhaustive-deps.The rule is implemented in the
eslint-plugin-react-hooks package that ships with React (and with Create-React-App).Its logic is deliberately heuristic:
• When it sees a MemberExpression such as
• Instead it tries to see whether the member itself is “stable”.
– If the member access is to a primitive literal (
– If the member is a function, object or array (
props.foo, it does not automatically add the base object props to the dependency list.• Instead it tries to see whether the member itself is “stable”.
– If the member access is to a primitive literal (
props.id, props.count, etc.) the rule considers the value immutable and is satisfied when you list props.id directly.– If the member is a function, object or array (
props.wsSubscribe, props.style, etc.) the rule assumes the value can be re-created on every render and therefore still warns that you should either destructure it or memoise it.These heuristics are not written in the public README, but they are visible in the rule’s implementation (see the file
ESLintRuleExhaustiveDeps.js in the React repository). The React team’s own explanation is summarised in the public docs as:“The rule is designed to ensure that all dependencies that the effect uses are included in the array. It helps prevent bugs due to missing dependencies.”
So the apparent “inconsistency” between primitive props and function props is an intentional, documented heuristic baked into the official ESLint rule.
Why should I destructure it if it's recreated on every render? Wouldn't it still work because the value in the dependency array is then a different object?
The linter doesn’t warn you because the dependency will be wrong (it will indeed be different every time); it warns you because that defeats the optimisation you were trying to get and almost always indicates a mistake in the code.
What the linter is guarding against
- Every time the parent re-renders it creates a new function (or object/array) and passes it down as a prop.JavaScript复制
function Parent() { const onClick = () => { ... }; // new arrow every render return <Child onClick={onClick} />; } - If you writeJavaScript复制
useEffect(() => { props.onClick(); }, [props.onClick]);the identity ofprops.onClickwill change on every render, so the effect will re-run on every render.
That is functionally correct but practically useless: you lose the memoisation that the dependency array is supposed to provide. - The linter therefore tells you:
“Either stabilise this value (e.g. wrap it inuseCallbackin the parent) or stop pretending the effect depends on it.”
Destructuring is just the syntactic change that makes the warning go away after you have actually stabilised the value:Parent stabilises:JavaScript复制const onClick = useCallback(() => { ... }, []);Child destructures:JavaScript复制const { onClick } = props; useEffect(() => { onClick(); }, [onClick]);NowonClickis stable and the effect only runs when it should.
If you really wanted “run every time the prop changes, even if it’s a new function each render” you can silence the linter with
JavaScript
复制
// eslint-disable-next-line react-hooks/exhaustive-depsbut in practice that is almost never what you want.
1. Every value in the parent is wrapped in useCallback
2. How is destructuring stabilizing anything if it also happens on every render
Destructuring does not make the value itself stable; it only changes what the linter sees so it stops warning about
props and starts warning about the actual value (onClick, config, etc.).What is happening step-by-step
- ParentJavaScript复制
const onClick = useCallback(() => {...}, []); const config = useMemo(() => ({...}), []); return <Child onClick={onClick} config={config} />;Because of the wrappers, bothonClickandconfigkeep the same identity across renders. - Child (without destructuring)JavaScript复制
useEffect(() => { props.onClick(); console.log(props.config); }, [props.onClick, props.config]); // <- linter is *already* happy hereThe rule is satisfied because the identifiers it sees (props.onClick,props.config) are the stable values you listed. - Child (with destructuring)JavaScript复制
const { onClick, config } = props; useEffect(() => { onClick(); console.log(config); }, [onClick, config]); // <- linter is equally happyThe destructuring line runs on every render, but it merely copies the same references into new local variables.
The identities ofonClickandconfigare still the same as in the parent, so the effect does not re-run unless the parent actually changes them.
So:
- Destructuring is not what stabilises the value;
useCallback/useMemoin the parent does. - Destructuring just makes the code shorter and keeps the linter from ever having to mention the word
props.
This SO post actually explains the real reason https://stackoverflow.com/questions/72757679/react-adding-props-something-as-a-dependency-of-useeffect
Exactly — the SO answer you linked reveals the real reason the linter sometimes insists on the object (
props) and sometimes only on the primitive field (props.id):It’s aboutthis(and, more generally, about whether the call-site depends on the receiver).
1. When the linter stays quiet
JavaScript
复制
useEffect(() => {
doSomething(props.id); // id is a primitive; no hidden `this`
}, [props.id]);props.idis just a number / string.- No hidden
thisbinding is involved. - The rule sees no risk that changing the container (
props) could change the behaviour, so it accepts[props.id].
2. When the linter demands the object
JavaScript
复制
useEffect(() => {
props.wsSubscribe(); // method call — `this` inside wsSubscribe is `props`
}, [props.wsSubscribe]);wsSubscribeis invoked as a method (props.wsSubscribe()).- The function’s behaviour might depend on
this(even if the author never uses it today). - Therefore the rule treats the receiver (
props) as an implicit argument and wants it listed:
JavaScript
复制
}, [props]);3. How destructuring silences the warning
JavaScript
复制
const { wsSubscribe } = props;
useEffect(() => {
wsSubscribe(); // now `this` is `undefined` (or whatever you bind)
}, [wsSubscribe]);- By extracting the function first, you break the method-call form.
wsSubscribeis now a standalone function; its execution no longer depends on the original object, so the linter is satisfied with[wsSubscribe].
TL;DR
The rule is not about “primitive vs. non-primitive” per se, but about whether the code uses dot-notation invocation (
obj.method()), which implicitly passes obj as this. If it does, the rule wants obj itself in the dependency array unless you explicitly detach the method.So it has nothing to do with the linter seeing values as stable or not it's just about the "this" being able to change and destructuring removing the dependency on this
Exactly.
The rule’s warning is triggered solely by the syntactic form
Destructuring or otherwise hoisting the function out of the object removes that implicit dependency, so the linter stops insisting that the object itself be listed.
The rule’s warning is triggered solely by the syntactic form
obj.method() (or props.fn()), because that expression implicitly depends on obj as the dynamic this value.Destructuring or otherwise hoisting the function out of the object removes that implicit dependency, so the linter stops insisting that the object itself be listed.