New Chat
Ctrl
K
  • Websites
  • Docs
  • Slides
  • Sheets
  • Deep Research
Kimi Code Kimi Claw
Chat History
  • Mobile App
  • About Us
    • Visit Moonshot AI
    • Open Platform
    • Features
    • Terms of Service
    • Privacy Policy
  • Language
  • User Feedback

React Dependency Array: When to Deconstruct Props

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).
  1. 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
    Copy
    useEffect(() => { ... }, [props])
    React will compare the previous array [prevProps] with the next array [nextProps] by Object.is.
    If props is the same object reference, React assumes nothing changed and skips the effect.
    If props is a different object, React re-runs the effect, no matter whether the contents of the object changed.
  2. Why that is usually wrong
    In idiomatic React the props object 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.
  3. What the linter tells you to do
    The eslint-plugin-react-hooks rule react-hooks/exhaustive-deps tries to protect you from that mistake.
    It does a static analysis and says:
    • “You used props.foo inside the effect, therefore the value foo is what the effect actually depends on, not the whole props object.
    • So put props.foo in the array instead of props.”
    That is why you “have to” destructure or dot into the object:
    JavaScript
    Copy
    const { foo, bar } = props;
    useEffect(() => {
      doSomething(foo, bar);
    }, [foo, bar]);
    Now if the parent re-renders but only changes baz, foo and bar keep the same reference / primitive value, and the effect is skipped—exactly what you want.
  4. When you do NOT have to destructure
    • If you really do depend on the identity of the whole object (rare):
      JavaScript
      Copy
      useEffect(() => {
        console.log('props object itself changed');
      }, [props]);
      The linter will still warn; you silence it with // eslint-disable-line react-hooks/exhaustive-deps if that is intentional.
    • If you forward the object unchanged to another hook that is itself stable, e.g.
      JavaScript
      Copy
      const data = useCustomHook(props);   // custom hook will read what it needs
      you may leave props as 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.
Why not useEffect(() => { doSomething(props.foo, props.bar); }, [props.foo, props.bar]);
You absolutely can write
JavaScript
Copy
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
Copy
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 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
Copy
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
Copy
useEffect(() => {
  const { wsSubscribe } = props; // destructuring is done *inside* the effect
  wsSubscribe('ticker');
}, []);                          // still wrong, but the rule doesn’t complain
or the usual correct pattern:
JavaScript
Copy
const { wsSubscribe } = props;   // destructuring is done *outside* the effect
useEffect(() => {
  wsSubscribe('ticker');
}, [wsSubscribe]);               // linter is happy
The rule only complains when the whole variable named 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 → sees props → 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:
  1. It walks the AST inside the effect callback.
  2. 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”.
  3. For primitive literals (props.id, props.count, etc.) the rule treats the member access as “stable enough” and is satisfied if you list props.id directly.
    It assumes the value is unlikely to be mutated in place (because primitives are immutable).
  4. 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
Copy
useEffect(() => { ... }, [props.id])       // linter is quiet
passes, whereas
JavaScript
Copy
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?
Search
eslint-plugin-react-hooks props warning AST analysis
24 results
Yes—this behaviour is documented in the source code of the official rule 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 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
  1. Every time the parent re-renders it creates a new function (or object/array) and passes it down as a prop.
    JavaScript
    Copy
    function Parent() {
      const onClick = () => { ... };   // new arrow every render
      return <Child onClick={onClick} />;
    }
  2. If you write
    JavaScript
    Copy
    useEffect(() => {
      props.onClick();
    }, [props.onClick]);
    the identity of props.onClick will 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.
  3. The linter therefore tells you:
    “Either stabilise this value (e.g. wrap it in useCallback in 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
    Copy
    const onClick = useCallback(() => { ... }, []);
    Child destructures:
    JavaScript
    Copy
    const { onClick } = props;
    useEffect(() => { onClick(); }, [onClick]);
    Now onClick is 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
Copy
// eslint-disable-next-line react-hooks/exhaustive-deps
but 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
  1. Parent
    JavaScript
    Copy
    const onClick = useCallback(() => {...}, []);
    const config  = useMemo(() => ({...}), []);
    return <Child onClick={onClick} config={config} />;
    Because of the wrappers, both onClick and config keep the same identity across renders.
  2. Child (without destructuring)
    JavaScript
    Copy
    useEffect(() => {
      props.onClick();
      console.log(props.config);
    }, [props.onClick, props.config]);   // <- linter is *already* happy here
    The rule is satisfied because the identifiers it sees (props.onClick, props.config) are the stable values you listed.
  3. Child (with destructuring)
    JavaScript
    Copy
    const { onClick, config } = props;
    useEffect(() => {
      onClick();
      console.log(config);
    }, [onClick, config]);               // <- linter is equally happy
    The destructuring line runs on every render, but it merely copies the same references into new local variables.
    The identities of onClick and config are 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/useMemo in 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 about this (and, more generally, about whether the call-site depends on the receiver).

1. When the linter stays quiet

JavaScript
Copy
useEffect(() => {
  doSomething(props.id);   // id is a primitive; no hidden `this`
}, [props.id]);
  • props.id is just a number / string.
  • No hidden this binding 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
Copy
useEffect(() => {
  props.wsSubscribe();     // method call — `this` inside wsSubscribe is `props`
}, [props.wsSubscribe]);
  • wsSubscribe is 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
Copy
}, [props]);

3. How destructuring silences the warning

JavaScript
Copy
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.
  • wsSubscribe is 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 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.