react icon indicating copy to clipboard operation
react copied to clipboard

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

Open bthall16 opened this issue 1 year ago • 4 comments

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 />.

bthall16 avatar Aug 15 '24 17:08 bthall16

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

zenx5 avatar Aug 16 '24 01:08 zenx5

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.

bthall16 avatar Aug 16 '24 13:08 bthall16

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

nkalpakis21 avatar Sep 05 '24 03:09 nkalpakis21

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!

github-actions[bot] avatar Dec 04 '24 03:12 github-actions[bot]

Closing this issue after a prolonged period of inactivity. If this issue is still present in the latest release, please create a new issue with up-to-date information. Thank you!

github-actions[bot] avatar Dec 11 '24 04:12 github-actions[bot]