Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bug: SetState with same value, rerenders one more time #28779

Open
leadq opened this issue Apr 8, 2024 · 7 comments
Open

Bug: SetState with same value, rerenders one more time #28779

leadq opened this issue Apr 8, 2024 · 7 comments
Labels
Status: Unconfirmed A potential issue that we haven't yet confirmed as a bug

Comments

@leadq
Copy link

leadq commented Apr 8, 2024

As far as I know, react's update machanism somehow checks the new value and current value of states to optimize rerenders. And I can see this behaviour after second time.

I tried 2 scenario. I put "console.log" just above return().

The first one is, I defined a state with initial value String "1". Then I tried to set the same value on button click. It never rerendered as expected.

The second scenerio is, I defined a state with initial value String "1". Then I tried to set another value String "2" on every button click. My expectation is it will rerender to set the value "2". But after setting the value "2" once, it should never rerender for value "2" if current value still "2". However it does one more time. After then, it wont rerender. Is it expected behaviour ?

React version: 18.2.15

Steps To Reproduce

  1. By using my code sandbox below:
    1.1. Enter the url
    1.2 run the code
    1.3 click the button twice or more
    1.4 you will see the "count 1" on the logs twice
Screenshot 2024-04-08 at 14 44 07
  1. By using your code:
    2.1. Create a simple react project starter.
    2.2 define a state of immutable value like number or string
    2.3 define a trigger for updating state with new value by using button click event etc. for example if initial value is string "initial" then set this state to "second" on each click.
    2.4 add "console.log" above your return.
    2.5 click the button on the browser few times

https://codesandbox.io/p/sandbox/nervous-albattani-vl3mnm?file=%2Fsrc%2FApp.js%3A12%2C31

The current behavior

it rerenders one more time with same old value. After one extra rerender, It won't rerender with same value as expected.

The expected behavior

it should never rerender with same prev value

@leadq leadq added the Status: Unconfirmed A potential issue that we haven't yet confirmed as a bug label Apr 8, 2024
@AbdulHadi806
Copy link

AbdulHadi806 commented Apr 8, 2024

In React, when you update the state using useState, React doesn't immediately update the state and re-render the component. Instead, it schedules the state update and re-rendering to occur asynchronously. This means that when you call setCount(1) in your first scenario or setCount("2") in your second scenario, React doesn't update the state and re-render the component immediately.

React batches state updates for performance reasons. When multiple setState calls are made within the same synchronous event, React will batch them together and perform a single re-render at the end of the event. This is why you're seeing unexpected behavior in your console logs.

In your first scenario, when you click the button, React schedules the state update to 1, but before it re-renders the component, it logs the current count value, which is still 0. Then it re-renders the component with the updated count value of 1.

In your second scenario, similarly, React schedules the state update to "2" and logs the current count value, which is still "1" before re-rendering the component. Then it re-renders the component with the updated count value of "2".

This behavior is expected in React due to its asynchronous nature of state updates and re-renders. If you want to perform any action after the state has been updated, you should use useEffect hook with appropriate dependencies.

But you can still try memoizing the state. However as far as I know this is expected behavior :)

const memoizedCount = useMemo(() => count, [count]);

@leadq
Copy link
Author

leadq commented Apr 9, 2024

In React, when you update the state using useState, React doesn't immediately update the state and re-render the component. Instead, it schedules the state update and re-rendering to occur asynchronously. This means that when you call setCount(1) in your first scenario or setCount("2") in your second scenario, React doesn't update the state and re-render the component immediately.

React batches state updates for performance reasons. When multiple setState calls are made within the same synchronous event, React will batch them together and perform a single re-render at the end of the event. This is why you're seeing unexpected behavior in your console logs.

In your first scenario, when you click the button, React schedules the state update to 1, but before it re-renders the component, it logs the current count value, which is still 0. Then it re-renders the component with the updated count value of 1.

In your second scenario, similarly, React schedules the state update to "2" and logs the current count value, which is still "1" before re-rendering the component. Then it re-renders the component with the updated count value of "2".

This behavior is expected in React due to its asynchronous nature of state updates and re-renders. If you want to perform any action after the state has been updated, you should use useEffect hook with appropriate dependencies.

But you can still try memoizing the state. However as far as I know this is expected behavior :)

const memoizedCount = useMemo(() => count, [count]);

But I dont understand the async behaviour you mentioned. I know applying next rerender with new changes somehow asynchronus because of optimization. But I think it is not related with this. Because if you click once then wait for a minute, rerender will already be done and whereever the state value inside react closure should already be updated with next value until next click. Lets put some time between two clicks. React still rerender with same immutable value. Are you sure about your explanation? Other hand, I couldnt find any deep dive explanation about state closure implementation inside react. If you know implementation detail, please let me know.

@Mayvis
Copy link

Mayvis commented Apr 29, 2024

@leadq maybe you can see this thread that sophiebits answer your question.

#14810

@leadq
Copy link
Author

leadq commented Apr 29, 2024

@leadq maybe you can see this thread that sophiebits answer your question.

#14810

I've checked the thread. But, no one explained the extra rerender at the end of that thread

@Mayvis
Copy link

Mayvis commented Apr 29, 2024

@leadq As far as I know, React can’t guess the output of render() won’t change, even if you update state has the same value, it has to render() again and compare the results with the previous render(). This is the conclusion. React optimize this strategy called "eagerState" to make sure it will not re-render.

So how is the "eagerState" work?

In React, state is stored in the fiber tree, and react use double cache mechanism, there are at least two fiber trees in existence. When we mark a component A as needing an update, the "update exists" information is stored in two fiber nodes corresponding to component A in its respective fiber trees. When the first update occurs and is completed after a click, the "update exists" information is erased from one of the fibers, but it remains in the other related fiber. So, the next time component A is updated, it will still render because the "update exists" information remains in one of the fibers. However, during subsequent updates, both fibers related to component A do not have updates, allowing component A to hit eagerState and avoid rendering.

If you don't want this behavior. Just simply prevent by yourself. 😅

const handleClick = () => {
  if (count === prevCount) return
  
  setCount(1)
}

If you want more detail, you need to study the react source code by yourself.😅

Hope this can help. ;)

@leadq
Copy link
Author

leadq commented Apr 30, 2024

@leadq As far as I know, React can’t guess the output of render() won’t change, even if you update state has the same value, it has to render() again and compare the results with the previous render(). This is the conclusion. React optimize this strategy called "eagerState" to make sure it will not re-render.

So how is the "eagerState" work?

In React, state is stored in the fiber tree, and react use double cache mechanism, there are at least two fiber trees in existence. When we mark a component A as needing an update, the "update exists" information is stored in two fiber nodes corresponding to component A in its respective fiber trees. When the first update occurs and is completed after a click, the "update exists" information is erased from one of the fibers, but it remains in the other related fiber. So, the next time component A is updated, it will still render because the "update exists" information remains in one of the fibers. However, during subsequent updates, both fibers related to component A do not have updates, allowing component A to hit eagerState and avoid rendering.

If you don't want this behavior. Just simply prevent by yourself. 😅

const handleClick = () => {
  if (count === prevCount) return
  
  setCount(1)
}

If you want more detail, you need to study the react source code by yourself.😅

Hope this can help. ;)

From my perspective, this library (which, by the way, is a great asset to have in our lives) offers some APIs for us to use. As an end user, I view this library as a black box—I expect it to function reliably and consistently as described, without needing to understand its internal workings, much like any API consumer would. According to React's documentation, setState performs certain optimizations, and if the next value remains the same after being checked by a method like Object.is, it does not re-render the component. However, the official docs also mention that there might be cases where it could still cause a re-render. This seems to be a buggy behavior, but the docs mention this only briefly, allowing us to categorize this issue as "some cases". However, there's no clarification on what these "some cases" are. If it requires looking into the source code to understand, this is indeed a significant challenge for us.Screenshot_20240430_091524_Chrome.jpg

Certainly, having an in-depth knowledge of the source code and understanding how it works would be ideal. I do find myself diving into the library out of curiosity from time to time. However, my point is that using the phrase "some cases" in the official documentation feels rather precarious. It's very vague, and when I encounter a bug, I can't possibly know whether it falls into this "some cases" category. Therefore, there should be examples and clear limitations of these cases in the official documentation. I've opened this issue because maybe something is missed, and it would be beneficial if the maintainers could shed some light on this.

@kei444666
Copy link

@leadq
I've reviewed the source code regarding this issue and discovered the following:

  • The actual implementation of useState's setState is dispatchSetState.
    const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
    ): any);
    queue.dispatch = dispatch;
    return [hook.memoizedState, dispatch];
  • It's found that if the lanes bound to the fiber in dispatchSetState are not NoLanes (0), re-rendering occurs even if the eagerState remains the same.
  • EagerState is checked, and if it's identical to the current value, it returns without re-rendering.
    if (is(eagerState, currentState)) {
    // Fast path. We can bail out without scheduling React to re-render.
    // It's still possible that we'll need to rebase this update later,
    // if the component re-renders for a different reason and by that
    // time the reducer has changed.
    // TODO: Do we still need to entangle transitions in this case?
    enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
    return;
  • If the lanes of the fiber bound to dispatchSetState are not NoLane, eagerState is not checked, leading to re-rendering even when the same value is set.
    if (
    fiber.lanes === NoLanes &&
    (alternate === null || alternate.lanes === NoLanes)
    ) {
    // The queue is currently empty, which means we can eagerly compute the
    // next state before entering the render phase. If the new state is the
    // same as the current state, we may be able to bail out entirely.
    const lastRenderedReducer = queue.lastRenderedReducer;
    if (lastRenderedReducer !== null) {
    let prevDispatcher = null;
    if (__DEV__) {
    prevDispatcher = ReactSharedInternals.H;

Why is it re-rendering even when the same value is set?

Regarding this issue, I've observed two crucial behaviors in React:

  • When setState (dispatchSetState) is called, the bound fiber might refer to the current one (yet to be reflected on the screen) or the previous fiber (currently reflected on the screen). They alternate with each rendering.
  • During re-rendering, the current fiber may have lanes at 0, but the previous fiber (contained in the current fiber's alternate) may have lanes at 2.

Reasons for these behaviors:

The fiber referred to by dispatchSetState at the time of the call might be the current fiber or the previous one

React places the previous fiber in the alternate of the current fiber with each rendering. Thus, the fiber bound to dispatchSetState alternates between the current and the previous one with each rendering. (This is my observation based on changes during rendering.)

During re-rendering, the current fiber may have lanes at 0, but the previous fiber may have lanes at 2

The lanes of a fiber become NoLanes when bailoutHooks()'s removeLanes() is executed, which occurs when the current value matches the previous value.

Thus, when there's an update, markWorkInProgressReceivedUpdate is executed,

if (!is(newState, hook.memoizedState)) {
markWorkInProgressReceivedUpdate();
// Check if this update is part of a pending async action. If so, we'll

leading to didReceiveUpdate becoming true

export function markWorkInProgressReceivedUpdate() {
didReceiveUpdate = true;
}

When didReceiveUpdate is false, bailoutHooks()'s removeLanes() is executed

if (current !== null && !didReceiveUpdate) {
bailoutHooks(current, workInProgress, renderLanes);
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}

Conclusion

When the fiber bound to dispatchSetState refers to the previous fiber, and the lanes of the previous fiber are at 2 (If there was an update to the value in the previous rendering), re-rendering occurs even when the same value is set for setState.

This speculation is not verified against all operations, so there might be inaccuracies.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Status: Unconfirmed A potential issue that we haven't yet confirmed as a bug
Projects
None yet
Development

No branches or pull requests

4 participants