Some form of analysis might be able to help with this. Hard to say precisely without fully understanding the problem.
To aid understanding: is this a react-specific idiosyncrasy or is it common to all UI frameworks?
Well weâre getting into abstract territory here â but what does âonly once at the beginningâ mean, when you are using a framework (React) that tries to remove temporal concerns from your UI logic? In other words, in React, your UI is a function of state, and it is conceptually completely recreated every time your state changes. (In reality, it diffs between whatâs already on screen and only updates what actually needs to be updated, but the point is that you shouldnât ever have to think about âwhat was on screen beforeâ).
In concurrent mode this becomes even more important because components can be âmountedâ but then discarded, so whether or not you fetch data can no longer purely rely on the lifecycle of a component. It should instead rely on what you need to do with that data â for example, if you need to put that data into some state, then itâs right to rely on a setState
function to decide whether to fetch the data.
This is all very abstract but itâs important to understand it when you get into some of the more complex questions when youâre writing React code (and you will always get into them eventually). What youâre already doing is correct, but you may need to just slightly shift your thinking about effects. Using an empty dependency array (or useEffect0
) to signify âon mountâ is cognitively pretty weird and obscure â and thatâs for a good reason: it doesnât really mean that at all.
I think itâs React-specific (and maybe preact-specific too). For instance, SolidJS has similar hooks API, but as a compiled reactive framework, it uses an entirely different mechanism to track dependencies.
The problem that the react-hooks/exhaustive-deps
rule is trying to solve is that the component functions are run over and over, and some of the values inside the function scopes change. But for some hooks that take callbacks as parameters, those callbacksâ closures can get out of sync with the values from the latest run of the component function (the stale closure problem). This is by design: you do not want to re-run, say, the useMemo
callback on every rerender of the component. But neither do you want to not rerun whenever some value captured in the callback actually changes, so, for the lack of a better tracking mechanism, you have to be careful to explicitly list all those values in the hookâs dependency array.
Donât need to care about it, it is just a code checking rule of eslint.
Maybe useMemo is a good example to look at in isolation.
Iâve done a fair amount of memoization, in the rare case where it is needed, over many years.
But canât remember a single time where something like this would be the central focus of something to worry about when programming.
So now Iâm puzzled as to what is different here.
First of all, useMemo
is normally used to memoize values, not functions. The function passed to useMemo
is nullary and is supposed to simply produce values. For memoizing actual functions (event handlers and whatnot), thereâs useCallback
. But I think this is beside the point.
The differences to normal memoizing functions are:
- Free variables
- Where the cache resides
- The stability of the returned function
1. Free and Bound Variables
Normally, the memoization helpers only care about bound variables. The arguments passed to the memoized version of the function are used as cache keys, and the original function is only called if thereâs a cache miss. If the value in the cache was calculated using some free variables, and values of those has changed, the value wonât be re-evaluated.
So itâs a good practice to only memoize functions that donât use any free values (or at least use some that donât change during the app lifetime). But that advice is not very useful for functions created inside React components, because those functions often use values from the component functionâs scope. And with that usage pattern, itâs easy to get stale values, so you have to take care of the free variables, and so (the React solution) you have to list them as explicit dependencies.
I think this is the difference most relevant to exhaustive dependencies, but let me go on anyway, in case you could benefit from more context
2. The Cache
With traditional memoization, to my knowledge, the cache is created either
- inside the scope created by the
memoize
call, - inside the ES module from which the
memoize
function is imported, or - the cache instance is provided by the consumer.
Variant 1 could be OK with class components: you could memoize in the constructor once and be done with it. But with function components, where the functions are re-run repeatedly, that would just create a new empty cache every time, and thereâd be no memoization, effectively. Well, there would if youâd use your value way more often that the component re-renders (i.e., the component function re-runs), but thatâs not an awfully frequent case.
Variant 2 would mean all the instances of all the component share their cache, and variant 3 means some manual labour, and youâd have to do some non-trivial work to separate cache for instances of the same component.
So the React solution is that useMemo
is stateful, in the same way as useState
/useReducer
: it tracks some internal state for every hook usage in every component instance. So thereâs a separate cache for every (first) call of a useMemo
hook inside every createElement
call, and the consumers donât have to do anything extra, other than abide the rules of hooks.
3. Stability
One concern that traditional memoization, to my knowledge, doesnât have at all, but that is very important for modern React, is the stability of the references. And since itâs directly related to the hooksâ dependencies, I think itâs worth going into some detail.
Why stability matters
First of all, in React a stable value is a value that doesnât change on re-renders. And when it comes to non-scalar values, it means that the reference doesnât change. So, for clarity, creating a new value of [a, b]
on every render is still unstable, even if both a
and b
havenât changed.
Why does it matter? React uses diffing all the time, for the hooksâ dependencies and for the componentsâ props. Diffing is done shallowly, i.e., every property of a props object and every element of a dependency array is compared using Object.is
. So if the value is an object (arrays and functions are objects in JS too), itâs compared by reference.
Comparison by reference blends very well with immutable patterns. There, new value === new reference. So React can compare values cheaply and then bail out of re-rendering (if itâs props) or out of running computations/effects (if itâs hook dependencies).
Also, React actually stabilises functions returned by useState
or useReducer
, so you can pass them as props or use them inside effects, and you never have to worry about handling the changes of their values, because those functions literally never change. And thatâs why the eslint plugin ignores those functions: it knows you donât have to list them as dependencies.
Where it breaks
The problems begins when you create values on the fly. Every function you create inside the component function, every array or object where you combine some values, and for that matter, every value created using a variant constructor with payload means a different value on every re-render.
Sure enough, if the value changes over time, you do want the reference to change. But only when the value actually changes. IOW, you want to enforce the new value === new reference
invariant. Otherwise, if you get new refs without new values, you create false positives when diffing and your components can go into infinite loops. It happens all the time, and I donât know any middle or senior React dev who doesnât occasionally get bitten by that.
So this is something React docs tell about useCallback
but donât tell about useMemo
: as often as not, itâs used not for âexpensive computationsâ, but simply to stabilise the references between the actual changes in the values.
But, using useMemo
, itâs easy to break the invariant the other way: if you forget to list a dependency, the hook fails to produce a new reference pointing to a new value. Now your reference is too stable.
And this is what the exhaustive deps part of the plugin tries to solve: it prevents you from overstabilising your values (or skipping your effects when you shouldnât).
Thanks for the extensive explanation, this is really useful.
Canât avoid wondering how it is that we ended up there.
The corresponding âmanualâ for memoization outside of React is: if you call this function again with the same value, the original result will be returned. Not much else to say.
I think discussing memoization is a red herring. Weâre complicating the issue, and actually a large proportion of dependency arrays in React code have nothing to do with memoization, but in fact have to do with when you run an effect. In my own experience, most usages of useMemo
are in order to stabilize a dependency of a subsequent useEffect
.
useEffect
hooks need dependency arrays because of all the reasons explained in this thread â in brief, because you probably only want them to run when certain other things happen. These âother thingsâ are usually represented as free values in the hook, as @hoichi explains. Itâs very hard for humans to make sure that they put all of the free values used in the hook, into the dependency array. So, we use the eslint rule to check that for us.
To maybe move the conversation forward slightly: Iâve just installed the latest version of the VS Code extension (amazing work by the way ) and it has struck me how similar reanalyze
is to eslint
, from the DX perspective. I would love to see if we could make reanalyze do this kind of custom code checking for us. Iâm imagining a plugin system for reanalyzeâŚ
I agree about the typical usage of useMemo
, and maybe part of the confusion is the naming. Maybe the hook should have been called useStableValue
or something like that.
Totally agree, eslint is the tool I miss the most, especially the no-restricted-syntax - ESLint - Pluggable JavaScript Linter