Skip to content

Reconsider providing a cleaner hook-based solution to derived state #33041

@aweebit

Description

@aweebit

Search terms

getDerivedStateFromProps, useState, useEffect, derived state

Previous issues

Essential reading

Abstract / TL;DR

Adjusting state when some other state 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 other state changes, as suggested in [1]:

const [count, setCount] = useState(initialCount);
const [prevInitialCount, setPrevInitialCount] = useState(initialCount);

if (initialCount !== prevInitialCount) {
  setPrevInitialCount(initialCount);
  setCount(initialCount);
}

The state dependency (initialCount in this case) doesn't have to be defined in the same component – it can just as well be some reactive prop passed to it, or a context value it uses.

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. There will be more realistic examples below.

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.
  • The responsibility of forcing the state reset is transferred to the component's consumers instead of being its implementation detail.

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 for useMemo, 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 prevState to 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. (⚠ Update: the library's implementation of the functionality 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 usee my @aweebit/react-essentials library instead, and check this comment for details.)

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:

  1. 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 />);
  2. 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 prevState pattern or don't understand it, those who find it too confusing or fail to see its advantages over the usEffect approach, as well as those who don't understand how effects work well enough, end up resorting to useEffect and 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 enhanced useState hook would help reduce the frequency of such misuses of useEffect.
  • The developers who do understand the prevState pattern and when to use it end up having to suffer from its deliberate clumsiness or rely on user-land solutions like the aforementioned use-state-with-deps library (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!

Metadata

Metadata

Assignees

No one assigned

    Labels

    Status: UnconfirmedA potential issue that we haven't yet confirmed as a bug

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions