Description
Since React doesn't provide a native way to define a dependency array for resetting state, I have to resort to a user-land implementation of this functionality. Here is the signature of the hook I use:
function useDerivedState<S>(
initialState: S | ((previousState?: S) => S),
deps: DependencyList,
): [S, Dispatch<SetStateAction<S>>]
Please check #33041 (comment) for details and full code.
In order to get warnings about missing dependencies, I add this rule to my ESLint config:
'react-hooks/exhaustive-deps': [
'warn',
{ additionalHooks: 'useDerivedState' },
]
Unfortunately, that leads to warnings in cases where there shouldn't be any. Here is an example:
const [collapsed, setCollapsed] = useDerivedState(() => true, [collapsible]);
The reason is that collapsible
is listed as a dependency, but not present in the initializer function's body, so the linter plugin thinks it's unnecessary. However, this is intended: it is desired that collapsed
is reset to true
whenever collapsible
changes.
Such seemingly redundant dependencies are currently only accepted for the effects family of hooks. They got this special treatment shortly after the exhaustive-deps
rule was introduced, see #14920 (comment).
The test for whether a hook belongs to the effects family is very simple: it's enough that the hook's name includes the substring "Effect"
at the end of it, or followed by a symbol other than a lowercase letter.
Now, I could of course rename my useDerivedState
hook to something like useDerivedStateEffect
, but this would be a despicable misnomer since not only is the hook's functionality nothing like that of useEffect
, but also its whole idea is to fight how effects are used all over the place for deriving state despite it being a terrible anti-pattern (#33041 has a detailed explanation).
A couple more workarounds I can think of:
-
// eslint-disable-next-line react-hooks/exhaustive-deps const [collapsed, setCollapsed] = useDerivedState(true, [collapsible]);
Simply suppressing the warning. Dirty.
-
const [collapsed, setCollapsed] = useDerivedState(() => { collapsible; // eslint-disable-line @typescript-eslint/no-unused-expressions return true; }, [collapsible]);
Forcing
collapsible
to be referenced in the initializer function's body, but now a different warning has to be suppressed.This is more verbose and a little confusing, but especially in more complex cases where we don't only have one single dependency, I would go for this option because I wouldn't want to disable the
exhaustive-deps
rule completely.(By the way, Biome's
useExhaustiveDependencies
rule allows ignoring only a specific dependency. This alone make me kind of want to give Biome a try. Very inspiring!) -
const [collapsed, setCollapsed] = useDerivedState(() => { return collapsible || true; }, [collapsible]);
There shouldn't be any problem with this, right? Well, turns out there is, and it's that with this code, TypeScript for whatever reason decides that the type of
collapsed
istrue
rather thanboolean
, so it has to be specified manually:const [collapsed, setCollapsed] = useDerivedState<boolean>(() => { return collapsible || true; }, [collapsible]);
I'm really clueless about why TypeScript thinks
collapsible || true
is of typetrue
while a simpletrue
expression is of typeboolean
. Probably I should open an issue about this in TypeScript's repo, but if anyone here has any idea, please let me know 🙏Anyway, I shouldn't be writing stupid code like this just to get around the linter plugin's limitations.
I suggest introducing support for the following kind of configuration:
'react-hooks/exhaustive-deps': ['warn', {
additionalHooks: [
{ name: 'useDerivedState', allowExtraDeps: true },
{ name: 'useDefinitelyNotAnEffect', allowExtraDeps: false },
/^(useMyCustomHook|useMyOtherCustomHook)$/,
]
}]
Probably quite self-explanatory, but here are some details:
-
additionalHooks
can now accept objects of this shape:{ name: string | RegExp, allowExtraDeps?: boolean }
If
allowExtraDeps
is undefined, the decision on whether to report seemingly redundant dependencies should be made just the same way it is made now. Otherwise, it should be based on that property's value. -
I think it's better not to convert the
name
properties from string to regexes, but treat strings as values that should be matched exactly. Before, regexes couldn't be used directly because the config file had to be in JSON format, and that is why strings were converted to regexes. With the new flat config format, that is no longer necessary to have regex support. -
In addition to strings, regexes and object of the proposed shape,
additionalHooks
should also accept arrays of those values so that each one of them can be configured individually.
The proposed object shape could also be extended to allow even more flexibility, like for example what the aforementioned Biome rule offers with its closureIndex
and dependenciesIndex
options (this would solve #25443), or with its stableResult
option (this would solve #16873)