facebook / react

The library for web and native user interfaces.
https://react.dev
MIT License
228.58k stars 46.79k forks source link

Bug: [Strict Mode] Inconsistent behavior updating reducer state in mount Effect vs. update Effect #30712

Open bthall16 opened 2 months ago

bthall16 commented 2 months ago

When rendering an app using <StrictMode />, calling a reducer's dispatch function in an Effect appears to have different observable behaviors depending on whether the Effect is mounting or updating.

React version: 18.3.1

Steps to reproduce:

  1. Dispatch a reducer action in an Effect where the reducer contains internal invariants depending on the current state (see reproduction below).
  2. Render the component in <StrictMode />.
  3. When the Effect dispatches on mount, the reducer's invariants can be violated causing an error to be thrown. The same does not occur when the Effect dispatches on update.

Minimal reproduction:

In this reproduction, both components call useReducer with a reducer function containing invariants, e.g. to prevent invalid states. For this reproduction, the reducer toggles a boolean value to true once and throws an error for any future dispatches.

Each component then calls useReducer's dispatch function in an Effect. The reducer's invariant is only violated in <MountEffectDispatch />, not <UpdateEffectDispatch /> even though both are being double-invoked as part of <StrictMode />.

This specific way of surfacing the different behaviors between mount and update Effects is simplified from an app I'm developing so it may obfuscate the underlying issue (if there is one).

This behavior isn't seen outside of <StrictMode />.

function MountEffectDispatch() {
  const [value, dispatch] = useReducer((prevValue) => {
    if (prevValue) {
      throw new Error("Already true");
    }

    return !prevValue;
  }, false);

  useEffect(() => {
    dispatch();
  }, []);

  return <p>{String(value)}</p>;
}

function UpdateEffectDispatch() {
  const [value, dispatch] = useReducer((prevValue) => {
    if (prevValue) {
      throw new Error("Already true");
    }

    return !prevValue;
  }, false);

  // Will be used to defer dispatching to a later render
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  useEffect(() => {
    if (!mounted) {
      return;
    }

    dispatch();
  }, [mounted]);

  return <p>{String(value)}</p>;
}

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <UpdateEffectDispatch /> // or <MountEffectDispatch />
  </React.StrictMode>
);

The current behavior

Reducer state updates in mount Effects behave differently from reducer state updates in update Effects when using <StrictMode />.

The expected behavior

Reducer state updates should behave consistently in any Effect when using <StrictMode />.

zenx5 commented 2 months ago

maybe this can help you. Not only is there double rendering at the component level, but it also occurs at the reducer level. image

It's not a solution to the problem, but maybe with the context you already have you can see something that I don't see, good luck. I'll review it in more detail tomorrow.

and it is the snapshots of the log in each render

MountEffectDispatch with StrictMode

image

UpdateEffectDispatch with StrictMode

image

MountEffectDispatch without StrictMode

image

UpdateEffectDispatch without StrictMode

image

bthall16 commented 2 months ago

maybe this can help you. Not only is there double rendering at the component level, but it also occurs at the reducer level.

With <StrictMode /> one of the reducer's results should be thrown away which is what appears to be happening with the <UpdateEffectDispatch /> but not <MountEffectDispatch />.

If, for example, I had a reducer that just increments by 1:

function MountCounter() {
  const [value, dispatch] = useReducer((x) => {
    console.log("[Reducer value]:", x);
    return x + 1;
  }, 0);

  useEffect(() => {
    dispatch();
  }, []);

  console.log("[Render value]:", value);

  return null;
}

function UpdateCounter() {
  const [value, dispatch] = useReducer((x) => {
    console.log("[Reducer value]:", x);
    return x + 1;
  }, 0);

  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  useEffect(() => {
    if (!mounted) {
      return;
    }

    dispatch();
  }, [mounted]);

  console.log("[Render value]:", value);

  return null;
}

<MountCounter /> logs (blank lines added for clarity):

[Render value]: – 0
[Render value]: – 0

[Reducer value]: – 0
[Reducer value]: – 1
[Render value]: – 2

[Reducer value]: – 0
[Reducer value]: – 1
[Render value]: – 2

<UpdateCounter /> logs (blank lines added for clarity):

[Render value]: – 0
[Render value]: – 0

[Render value]: – 0
[Render value]: – 0

[Reducer value]: – 0
[Render value]: – 1

[Reducer value]: – 0
[Render value]: – 1

I'm not sure which sequence of logs is "correct" here but the final result of <UpdateCounter /> is what I'd expect to see: the value 1 is logged, not 2 (which is what we see from <MountCounter />).

What's interesting is that <MountCounter /> appears to show the component rendering twice and the reducer running twice per individual render, whereas <UpdateCounter /> appears to show the component rendering twice but the reducer running once per render. Further, <MountCounter /> appears to be using the result from the first reducer call to pass to the second reducer call within an individual render.

nkalpakis21 commented 1 month ago

built an app to get paid for this PR https://www.n0va-io.com/discover/facebook/react