Reconsider providing a cleaner hook-based solution to derived state
Search terms
getDerivedStateFromProps, useState, useEffect, derived state
Previous issues
- #14288
- #14738
- #14830
- #15523
- #16461
- #17712
Essential reading
- [1] You Might Not Need an Effect > Adjusting some state when a prop changes
- [2] You Probably Don't Need Derived State (from the legacy docs)
Abstract / TL;DR
Adjusting state when a prop or context changes makes sense more often than the official docs would have you believe. The pattern suggested in [1] is extremely underrated, but also unnecessarily confusing. React should offer and promote a less obscure hook-based solution to the problem so as to avoid the suggested solution's complexity and prevent developers from misemploying useEffect.
Motivation
Here is the right way to reset state when some input changes, as suggested in [1]:
const [count, setCount] = useState(initialCount);
const [prevInitialCount, setPrevInitialCount] = useState(initialCount);
if (initialCount !== prevInitialCount) {
setPrevInitialCount(initialCount);
setCount(initialCount);
}
The input in question (initialCount in this case) could be a prop, a context value, an input argument to a custom hook, or some value derived from a combination of those.
From my experience, however, most people are either unaware of this pattern or find it too confusing to wrap their heads around, and therefore use an effect-based approach instead:
const [count, setCount] = useState(initialCount);
useEffect(() => {
setCount(initialCount);
}, [initialCount]);
The prevalence of this approach is hard to overstate. Using an effect is suggested in top answers to pretty much all questions about syncing state to props on StackOverflow (example, example, example). All AI assistants I've asked also suggested this as their recommended solution. But most importantly, this is the approach I see used in the wild all the time, even by devs much more experienced than I am.
The problem with the approach is that effects only run after rendering is completed and, in most cases, after its results are committed to the DOM. This results in unnecessary rerenders and inconsistent intermediate states being displayed in the UI.
Unnecessary rerenders are also a drawback of the approach suggested in [1]: as explained there, if a function resetting some state is called while rendering, the render will finish, then its results are simply thrown away and a new render with the updated state begins. This is just compute time wasted for no reason! Can we really not do better than that?
Let's explore the drawbacks of both approaches with a more advanced example I've come up with.
This section is pretty long. You don't have to read all of it! Expand at your own risk 😛
import {
createContext,
useContext,
useEffect,
useMemo,
useReducer,
useState,
} from "react";
import { createRoot } from "react-dom/client";
const ClientLanguageContext = createContext("en"); // English is the default
function useClientLanguage() {
return useContext(ClientLanguageContext);
}
function LanguageCheckboxes({
additionalLanguages,
}: {
additionalLanguages: string[];
}) {
const clientLanguage = useClientLanguage();
const languages = useMemo(
() => new Set([clientLanguage, ...additionalLanguages]),
[clientLanguage, additionalLanguages]
);
const [checkedLanguages, setCheckedLanguages] = useState(new Set<string>());
useEffect(() => {
setCheckedLanguages((prev) => {
// If the `languages` set has changed in a way that it no longer includes
// some of the languages it included before, those removed languages also
// have to be removed from `checkedLanguages`!
// Compute the intersection of `languages` and previous `checkedLanguages`:
const next = new Set(
Array.from(prev).filter((language) => languages.has(language))
);
// Only actually adjust the value if languages have been removed:
return next.size < prev.size ? next : prev;
});
}, [languages]);
return (
<>
{Array.from(languages).map((language) => (
<div key={language}>
<label>
<input
value={language}
type="checkbox"
checked={checkedLanguages.has(language)}
onChange={(e) => {
const nextCheckedLanguages = new Set(checkedLanguages);
if (e.target.checked) nextCheckedLanguages.add(language);
else nextCheckedLanguages.delete(language);
setCheckedLanguages(nextCheckedLanguages);
}}
/>
{language}
</label>
</div>
))}
<p>Checked languages: {Array.from(checkedLanguages).join(", ")}</p>
</>
);
}
const ADDITIONAL_LANGUAGES = ["de", "fr"];
function App() {
const [clientLanguage, setClientLanguage] = useState("en");
const [additionalLanguages, toggleAdditionalLanguages] = useReducer(
(prev) => (prev.length ? [] : ADDITIONAL_LANGUAGES),
ADDITIONAL_LANGUAGES
);
return (
<ClientLanguageContext value={clientLanguage}>
<p>
Client language:
<br />
<input
value={clientLanguage}
onChange={(e) => setClientLanguage(e.target.value)}
/>
</p>
<p>
<button onClick={toggleAdditionalLanguages}>
Toggle additional languages
</button>
</p>
<LanguageCheckboxes additionalLanguages={additionalLanguages} />
</ClientLanguageContext>
);
}
createRoot(document.getElementById("root")!).render(<App />);
This example app does not offer any useful functionality, but it is good at demonstrating all sorts of issues related to derived state.
As you can see, I've chosen the wrong useEffect approach for this initial implementation. If you check all available languages and then click the toggle button, you will notice that “de” and “fr” disappear faster from the checkboxes than from the “Checked languages” list on the bottom, where they stay a few milliseconds longer. The reason is that the effect adjusting checkedLanguages is only executed after the render caused by the additionalLanguages update is completed and its result is reflected in the UI. This is simply how effects work in React, and the fact that we get to see an invalid state in the UI because of that is what makes the useEffect approach to derived state an absolute no-go.
Let's now try the approach suggested in [1]:
function LanguageCheckboxes({
additionalLanguages,
}: {
additionalLanguages: string[];
}) {
const clientLanguage = useClientLanguage();
const languages = useMemo(
() => new Set([clientLanguage, ...additionalLanguages]),
[clientLanguage, additionalLanguages]
);
const [checkedLanguages, setCheckedLanguages] = useState(new Set<string>());
const [prevLanguages, setPrevLanguages] = useState(languages);
if (languages !== prevLanguages) {
setPrevLanguages(languages);
// If the `languages` set has changed in a way that it no longer includes
// some of the languages it included before, those removed languages also
// have to be removed from `checkedLanguages`!
// Compute the intersection of `languages` and `checkedLanguages`:
const nextCheckedLanguages = new Set(
Array.from(checkedLanguages).filter((language) => languages.has(language))
);
// Only adjust `checkedLanguages` if languages have been removed:
if (nextCheckedLanguages.size < checkedLanguages.size) {
setCheckedLanguages(nextCheckedLanguages);
}
}
return /* ... */;
}
Well, this does seem to work, but unfortunately, it is not a good solution either. The reason is that the value of languages is computed with the useMemo hook, which, according to the docs, should only be used as a performance optimization. The code we write should work perfectly fine without it, but that is not the case here: since without useMemo, the identity of languages would change on every render, setPrevLanguages(languages) would end up being called in an infinite loop!
To overcome this complication, we have to store previous values of both inputs the languages variable is derived from (i.e. clientLanguage and additionalLanguages) instead of its own previous values:
function LanguageCheckboxes({
additionalLanguages,
}: {
additionalLanguages: string[];
}) {
const clientLanguage = useClientLanguage();
const languages = useMemo(
() => new Set([clientLanguage, ...additionalLanguages]),
[clientLanguage, additionalLanguages]
);
const [checkedLanguages, setCheckedLanguages] = useState(new Set<string>());
const [prevClientLanguage, setPrevClientLanguage] = useState(clientLanguage);
const [prevAdditionalLanguages, setPrevAdditionalLanguages] =
useState(additionalLanguages);
if (
clientLanguage !== prevClientLanguage ||
additionalLanguages !== prevAdditionalLanguages
) {
setPrevClientLanguage(clientLanguage);
setPrevAdditionalLanguages(additionalLanguages);
// If the `languages` set has changed in a way that it no longer includes
// some of the languages it included before, those removed languages also
// have to be removed from `checkedLanguages`!
// Compute the intersection of `languages` and `checkedLanguages`:
const nextCheckedLanguages = new Set(
Array.from(checkedLanguages).filter((language) => languages.has(language))
);
// Only adjust `checkedLanguages` if languages have been removed:
if (nextCheckedLanguages.size < checkedLanguages.size) {
setCheckedLanguages(nextCheckedLanguages);
}
}
return /* ... */;
}
This is the correct code that I think adheres to all React's official recommendations, but oh boy, is it cumbersome, fragile and confusing! Imagine deciding to extend languages by elements from yet another source beside clientLanguage and additionalLanguages. How easy is it to forget to add a prevX state variable for this new source? The linter is not there to remind us!
Furthermore, we haven't got rid of the unnecessary rerendering. The inconsistent result of the first render doesn't end up in the UI anymore and instead gets immediately thrown away as I've explained earlier, but still, that unnecessary first render does take place! Can we really not do better than that?
One idea that often comes to mind in the context of state synchronization is to use a different value for the key attribute whenever some input that the component's state is derived from changes. This is the recommended approach in [2], but unfortunately, it barely solves anything. There is a bunch of problems with the approach:
- It is useless in the context of custom hooks since there are no components involved in their definitions, and so we cannot really supply the key anywhere.
- Changing the key causes all of the component's internal state to be reset, which is rarely what we want.
- Changing the key causes a new DOM subtree to be created for the component. Besides being quite inefficient, this can also cause unwanted side effects such as the focused input within the component not being focused anymore after the remount.
- If the component's state is derived from more than one input values, the developer has to come up with a clever way to combine those values into a key.
I hope this is enough to show how bad this key solution is in most cases.
Let's now have a look at the alternative I suggest.
Proposal: Extend useState by a dependency array
The alternative isn't new, it has been suggested before a couple of times. I particularly like this definition from #14738:
It would be useful if we could declare dependencies for
useState, in the same way that we can foruseMemo, and have the state reset back to the initial state if they change:const [choice, setChoice] = useState(options[0], [options]);In order to allow preserving the current value if it's valid, React could supply
prevStateto the initial state factory function, if any exists, e.g.const [choice, setChoice] = useState(prevState => { if (prevState && options.includes(prevState) { return prevState; else { return options[0]; } }, [options]);
This is a very natural solution requiring pretty much no mind shift at all since it simply brings useState in line with the other hooks accepting a dependency array – a concept well-known to all React developers.
The example with LanguageCheckboxes from the collapsed section above has demonstrated that in certain scenarios, adjusting state based on both the new input and the previous state value is needed. That is why the prevState part of the proposal is especially important.
This is how LanguageCheckboxes could be simplified with it:
Click to show code
function LanguageCheckboxes({
additionalLanguages,
}: {
additionalLanguages: string[];
}) {
const clientLanguage = useClientLanguage();
const languages = useMemo(
() => new Set([clientLanguage, ...additionalLanguages]),
[clientLanguage, additionalLanguages]
);
const [checkedLanguages, setCheckedLanguages] = useState<Set<string>>(
(prev) => {
if (prev === undefined) return new Set();
// If the `languages` set has changed in a way that it no longer includes
// some of the languages it included before, those removed languages also
// have to be removed from `checkedLanguages`!
// Compute the intersection of `languages` and previous `checkedLanguages`:
const next = new Set(
Array.from(prev).filter((language) => languages.has(language))
);
// Only actually adjust the value if languages have been removed:
return next.size < prev.size ? next : prev;
},
[languages]
);
return /* ... */;
}
This is very similar to the original useEffect solution people love for its readability, but without any of its drawbacks! Isn't that beautiful?
By the way, a user-land implementation of this proposal exists, see use-state-with-deps.
More motivating examples
The proposal has been rejected before with the following argumentation:
The idiomatic way to reset state based on props is here:
https://reactjs.org/docs/hooks-faq.html#how-do-i-implement-getderivedstatefromprops
In other words:
const [selectedChoice, setSelectedChoice] = useState(options[0]); const [prevOptions, setPrevOptions] = useState(options); if (options !== prevOptions) { setPrevOptions(options); setSelectedChoice(options[0]); }I don't think we want to encourage this pattern commonly so we're avoiding adding a shorter way (although we considered your suggestion).
In general, I feel like the React team is trying to make me believe that adjusting state when some input value changes is not something I want to do. I cannot agree with that. Here are 2 examples of real-world scenarios where derived state makes perfect sense that I have encountered just recently:
-
useRelativeTime: a hook that returns the relative time string like “in 5 seconds” or “2 minutes ago” that it keeps up-to-date, for a given timestamp, in a given language. The current output value is kept in a state variable that may need to be adjusted when the input timestamp or language changes.Click to show code
import { useEffect, useMemo, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; const units: { name: Intl.RelativeTimeFormatUnit; milliseconds: number }[] = [ { name: "week", milliseconds: 1000 * 60 * 60 * 24 * 7 }, { name: "day", milliseconds: 1000 * 60 * 60 * 24 }, { name: "hour", milliseconds: 1000 * 60 * 60 }, { name: "minute", milliseconds: 1000 * 60 }, { name: "second", milliseconds: 1000 }, ]; function relativeTimeHelper( timeInMs: number, rtf: Intl.RelativeTimeFormat ): readonly [output: string, nextBumpTime: number] { const now = Date.now(); const diff = timeInMs - now; const absDiff = Math.abs(diff); const settleForUnit = (unit: (typeof units)[0]) => { const value = Math.trunc(diff / unit.milliseconds); const output = rtf.format(value, unit.name); const nextBumpTime = diff > 0 // time in the future ? now + (diff % unit.milliseconds) + 1 : timeInMs + (-value + 1) * unit.milliseconds; return [output, nextBumpTime] as const; }; for (const unit of units) { if (absDiff > unit.milliseconds) { return settleForUnit(unit); } } return settleForUnit(units[units.length - 1]); } function useRelativeTime(language: string, timeInMs?: number) { const rtf = useMemo(() => new Intl.RelativeTimeFormat(language), [language]); const [initialOutput, initialNextBumpTime] = useMemo(() => { return timeInMs !== undefined ? relativeTimeHelper(timeInMs, rtf) : []; }, [timeInMs, rtf]); // Does this part have to be so confusing? This would be so much cleaner: // const [output, setOutput] = useState(initialOutput, [initialOutput]); const [output, setOutput] = useState(initialOutput); const [prevInitialOutput, setPrevInitialOutput] = useState(initialOutput); if (initialOutput !== prevInitialOutput) { setPrevInitialOutput(initialOutput); setOutput(initialOutput); } useEffect(() => { if (timeInMs !== undefined) { const bump = () => { const [nextOutput, nextBumpTime] = relativeTimeHelper(timeInMs, rtf); setOutput(nextOutput); timeout = setTimeout(bump, nextBumpTime - Date.now()); }; let timeout = setTimeout(bump, initialNextBumpTime! - Date.now()); return () => clearTimeout(timeout); } }, [timeInMs, rtf, initialNextBumpTime]); return output; } function App() { const time = useRef(Date.now() + 5000); const output = useRelativeTime("en", time.current); return output; } createRoot(document.getElementById("root")!).render(<App />); -
A component displaying hierarchical data from a tree structure. The components's toolbar includes an input that can be used to control the depth up to which the nodes' children are to be expanded. The data changes dynamically, so it can happen that a previously valid input value becomes invalid because it starts exceeding the overall (maximum) depth of the tree that has decreased. In that case, the input value has to be adjusted to match the new maximum depth.
Sure, adjusting state based on input changes is not something you do every day, but nonetheless, scenarios where this is necessary are manifold. I feel like by “avoiding adding a shorter way” so as not “to encourage this pattern”, React does more harm than good:
- The developers who don't know about the
prevStatepattern or don't understand it, those who find it too confusing or fail to see its advantages over theusEffectapproach, as well as those who don't understand how effects work well enough, end up resorting touseEffectand introducing inconsistencies that I've illustrated above. This happens so often that I wouldn't be surprised if even Meta's own codebases included examples of this. I am sure that providing a clear API like the suggested enhanceduseStatehook would help reduce the frequency of such misuses ofuseEffect. - The developers who do understand the
prevStatepattern and when to use it end up having to suffer from its deliberate clumsiness or rely on user-land solutions like the aforementioneduse-state-with-depslibrary (which, by the way, was not exactly easy to find).
This is why I think the proposal should be reconsidered. If I haven't missed anything, it's been more than 5 years since the proposal was last brought up in this repository. The React landscape was very different back then, as the transition from class components to hooks was still ongoing. Maybe it wasn't entirely clear 5 years ago how often the useEffect hook would be misused. Also I suppose that developers would resort to class components whenever they wanted to avoid confusing patterns of the new and daunting hook world. This doesn't happen anymore. Taking all of that into consideration, I think the time has come to give the proposal a second look!
I think I will make a habit of posting here every time I find myself missing support for useState dependency arrays once again.
At this point I am just using a custom useStateWithDeps hook which is a slightly modified version of the hook provided by the use-state-with-deps library. Here is a single-file version if anyone needs the hook but prefers not to install third-party libraries for such small things:
Click to expand code
🚨 Update: This implementation breaks the rules of React by accessing and writing to refs' current values during renders which makes it incompatible with React 18's concurrent features. Please use the implementation provided below this one instead!
/**
* @file Based on {@link https://github.com/peterjuras/use-state-with-deps}
*
* @license MIT
*
* Copyright (c) 2020 Peter Juras
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import {
useCallback,
useReducer,
useRef,
type DependencyList,
type Dispatch,
type SetStateAction,
} from 'react';
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
function isFunction(input: unknown): input is Function {
return typeof input === 'function';
}
function depsAreEqual(
prevDeps: DependencyList,
deps: DependencyList,
): boolean {
return (
prevDeps.length === deps.length &&
deps.every((dep, index) => Object.is(dep, prevDeps[index]))
);
}
function useForceUpdate(): () => void {
const [, forceUpdate] = useReducer(() => ({}), {});
return forceUpdate;
}
/**
* `useState` hook with an additional dependency array that resets the state
* to the `initialState` param when the dependencies passed in the `deps` array
* change
*
* @param initialState The state that will be set when the component mounts or
* the dependencies change
*
* It can also be a function which returns a state value. If the state is reset
* due to a change of dependencies, this function will be passed the previous
* state as its argument (will be `undefined` in the first call upon mount).
*
* @param deps Dependencies that reset the state to `initialState`
*/
export default function useStateWithDeps<S>(
initialState: S | ((previousState?: S) => S),
deps: DependencyList,
): [S, Dispatch<SetStateAction<S>>] {
// It would be possible to use useState instead of
// useRef to store the state, however this would
// trigger re-renders whenever the state is reset due
// to a change in dependencies. In order to avoid these
// re-renders, the state is stored in a ref and an
// update is triggered via forceUpdate below when necessary
const state = useRef(undefined as S);
const prevDeps = useRef(deps);
const isMounted = useRef(false);
// If first render, or if dependencies have changed since last time
if (!isMounted.current || !depsAreEqual(prevDeps.current, deps)) {
// Update state and deps
let nextState: S;
if (isFunction(initialState)) {
nextState = initialState(state.current);
} else {
nextState = initialState;
}
state.current = nextState;
prevDeps.current = deps;
isMounted.current = true;
}
const forceUpdate = useForceUpdate();
const updateState = useCallback(function updateState(
newState: S | ((previousState: S) => S),
): void {
let nextState: S;
if (isFunction(newState)) {
nextState = newState(state.current);
} else {
nextState = newState;
}
if (!Object.is(state.current, nextState)) {
state.current = nextState;
forceUpdate();
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return [state.current, updateState];
}
🚨 Update: Both this implementation and the one from use-state-with-deps break the rules of React by accessing and writing to refs' current values during renders which makes the implementations incompatible with React 18's concurrent features. Please use the following implementation instead:
Click to expand code
import {
useState,
type DependencyList,
type Dispatch,
type SetStateAction,
} from 'react';
import { depsAreEqual } from '../utils.js';
/**
* `useState` hook with an additional dependency array `deps` that resets the
* state to `initialState` when dependencies change
*
* Due to React's limitations, a change in dependencies always causes two
* renders when using this hook. The result of the first render is thrown away
* as described in
* [useState > Storing information from previous renders](https://react.dev/reference/react/useState#storing-information-from-previous-renders).
*
* For motivation and more examples, see
* https://github.com/facebook/react/issues/33041.
*
* @param initialState The value to which the state is set when the component is
* mounted or dependencies change
*
* It can also be a function that returns a state value. If the state is reset
* due to a change of dependencies, this function will be passed the previous
* state as its argument (will be `undefined` in the first call upon mount).
*
* @param deps Dependencies that reset the state to `initialState`
*/
export function useStateWithDeps<S>(
initialState: S | ((previousState?: S) => S),
deps: DependencyList,
): [S, Dispatch<SetStateAction<S>>] {
const [state, setState] = useState(initialState);
const [prevDeps, setPrevDeps] = useState(deps);
if (!depsAreEqual(deps, prevDeps)) {
setPrevDeps(deps);
setState(initialState);
}
return [state, setState];
}
This hook is also available as part of my @aweebit/react-essentials library. For more details, please read this comment.
I also add this rule to my ESLint config so as to get warnings about missing dependencies:
'react-hooks/exhaustive-deps': [
'warn',
{ additionalHooks: '^useStateWithDeps$' },
],
Recently I've been working on a simple language learning website with flashcards, and here are 3 places where I ended up using useStateWithDeps:
-
type View = 'flashcard' | 'courseList' | 'wordList'; const [currentView, setCurrentView] = useStateWithDeps<View>( (prev) => (activeCourseId === null ? 'courseList' : (prev ?? 'flashcard')), [activeCourseId], );If the server responds with
nullwhenactiveCourseIdis requested, that means the user doesn't have any courses at all, and so the “course list” view where a new course can be created should be shown. -
const COLLAPSE_START_COUNT = 6; export type WordTableProps = { deck: DeckWithFlashcardsType; active: boolean; }; export default function WordTable({ deck: { id: deckId, flashcards }, active, }: WordTableProps) { const collapsible = !active && flashcards.length >= COLLAPSE_START_COUNT; // eslint-disable-next-line react-hooks/exhaustive-deps const [collapsed, setCollapsed] = useStateWithDeps(true, [collapsible]); /* ... */In the “word list” view there is a word table for each deck of flashcards in the active course. Except for the table showing the currently active deck, all tables exceeding a certain number of rows should be collapsed by default so as to improve performance and navigation.
Notice how I had to disable the ESLint rule here. The reason is twofold:
- The rule expects a function in the first parameter
- Even if I were to write
() => true, there would still be an error becausecollapsibleis listed as a dependency but not present in the function's body. According to my knowledge, extra dependencies like this are currently only allowed foruseEffect
-
type WordTableRowProps = { deckId: number; flashcard: FlashcardType }; export default function WordTableRow({ deckId, flashcard, }: WordTableRowProps) { const { word, translations } = flashcard; const pending = usePendingWordsSet().has(word); const [editing, setEditing] = useStateWithDeps( (prev) => Boolean(prev && !pending), [pending], ); /* ... */Words can be edited directly in the table. The same word can be present in two decks, and so also in two tables at the same time. If the user starts editing it in one of the tables, then in the other one, and then submits his edit in one of the tables, the other one should react by quitting the editing mode, too. Right after submission, the word becomes part of a
PendingWordsSetContext. To check if a word is in the set,usePendingWordsSet()is used, and if it is, theeditingstate variable is reset tofalse.
I think I will make a habit of posting here every time I find myself missing support for
useStatedependency arrays once again.
Likewise. Today's example: my component wraps a <Modal> which takes an open prop. When the modal closes, I'd like to reset much of the component's state (e.g. filled-in inputs) ready for the next time it's used. However, I can't just pass key={open} from the parent component, because the modal plays an animation on open/close, and doing that causes the animation to be cut short.
What I didn't think about first when writing this issue is that resetting state created with useReducer could also be useful sometimes. I literally had a situation like that today at work, and ended up writing this other custom hook building upon useStateWithDeps from my previous comment:
import { type DependencyList, useRef } from 'react';
import { useStateWithDeps } from './useStateWithDeps.js';
// We cannot simply import the following types from @types/react since they are
// only available starting from React 19, but we also want to support React 18
// whose type declarations for useReducer are very different.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AnyActionArg = [] | [any];
export type ActionDispatch<ActionArg extends AnyActionArg> = (
...args: ActionArg
) => void;
/**
* `useReducer` hook with an additional dependency array `deps` that resets the
* state to `initialState` when dependencies change
*
* This hook is the reducer pattern counterpart of {@linkcode useStateWithDeps}.
*
* Due to React's limitations, a change in dependencies always causes two
* renders when using this hook. The result of the first render is thrown away
* as described in
* [useState > Storing information from previous renders](https://react.dev/reference/react/useState#storing-information-from-previous-renders).
*
* For motivation and examples, see
* https://github.com/facebook/react/issues/33041.
*
* ### On linter support
*
* The `react-hooks/exhaustive-deps` ESLint rule doesn't support hooks where
* the dependency array parameter is at any other position than the second.
* However, as we would like to keep the hook as compatible with `useReducer` as
* possible, we don't want to artificially change the parameter's position.
* Therefore, there will be no warnings about missing dependencies.
* Because of that, additional caution is advised!
* Be sure to check that no dependencies are missing from the `deps` array.
*
* Related issue: {@link https://github.com/facebook/react/issues/25443}.
*
* Unlike `eslint-plugin-react-hooks` maintained by React's team, the unofficial
* `useExhaustiveDependencies` rule provided for Biome by Biome's team
* does actually have support for dependency arrays at other positions, see
* {@link https://biomejs.dev/linter/rules/use-exhaustive-dependencies/#validating-dependencies useExhaustiveDependencies > Options > Validating dependencies}.
*
* @param reducer The reducer function that specifies how the state gets updated
*
* @param initialState The value to which the state is set when the component is
* mounted or dependencies change
*
* It can also be a function that returns a state value. If the state is reset
* due to a change of dependencies, this function will be passed the previous
* state as its argument (will be `undefined` in the first call upon mount).
*
* @param deps Dependencies that reset the state to `initialState`
*/
export function useReducerWithDeps<S, A extends AnyActionArg>(
reducer: (prevState: S, ...args: A) => S,
initialState: S | ((previousState?: S) => S),
deps: DependencyList,
): [S, ActionDispatch<A>] {
// eslint-disable-next-line react-hooks/exhaustive-deps
const [state, setState] = useStateWithDeps(initialState, deps);
// Only the initially provided reducer is used
const reducerRef = useRef(reducer);
function dispatch(...args: A): void {
setState((previousState) => reducerRef.current(previousState, ...args));
}
return [state, useRef(dispatch).current];
}
It would of course be great if also this functionality was provided by React's useReducer hook itself.
Since I will be using the hooks in all my projects, I decided I would create a package with them and upload it to NPM. Now here it is for those interested: https://www.npmjs.com/package/@aweebit/react-essentials.
But of course you may also simply copy the code to your project if you don't want new dependencies.
This issue has been automatically marked as stale. If this issue is still affecting you, please leave any comment (for example, "bump"), and we'll keep it open. We are sorry that we haven't been able to prioritize it yet. If you have any new additional information, please include it with your comment!
Bump
Also relevant here are two new React Compiler ESLint rules:
Enabling and following the problems reported by these two lint rules makes it much more challenging to achieve deriving state, as synchronous setState() usage in useEffect() or render will now be reported as a problem.
@karlhorky I actually found out about the plan to introduce those new rules just a couple days ago, and I am already very excited. The set-state-in-effect one will make sooo many people who don't realize they're making this useEffect mistake over and over again finally see it, and hopefully as a result also come to the conclusion useState dependency arrays are something React should really support out of the box.
Enabling and following the problems reported by these two lint rules makes it much more challenging to achieve deriving state, as synchronous
setState()usage inuseEffect()or render will now be reported as a problem.
As it should.
By the way, I've also recently realized that the code for useStateWithDeps that I posted above as well as the original implementation from the use-state-with-deps library have both been wrong this entire time: they broke the rules of React by accessing and writing to refs' current values which can lead to terribly confusing bugs when using React 18's concurrent features (useTransition, useDeferredState, etc.). This is the kind of problem another new ESLint rule (refs) is there to prevent.
I therefore recommend everyone to not use use-state-with-deps, and if you were using my @aweebit/react-essentials library, please update to the newest version where the issue was fixed (and a couple new really handy small functions were added, as well as decent documentation).
Unfortunately, the fix involved giving up on preventing some unnecessary renders, so now your components that use useStateWithDeps will always have an additional second render phase whose results are simply thrown away whenever the state's dependencies change (even if the dependent state value remains the same after all).
The fact there really seems to be no good way to prevent those completely useless, unnecessary renders in user land is now my ultimate argument why this functionality should be provided by React itself.
Another recent related issue from the react.dev website repository:
- https://github.com/reactjs/react.dev/issues/7012