Skip to content

Confusing behavior when reading state in concurrent React #6

@voluntadpear

Description

@voluntadpear

This is more of a topic for https://github.com/reactwg/async-react but since it's a closed group I figured I should try to ask here.

I have a local fork of this demo in which I force to always use the HistoryRouter: voluntadpear/async-react@main...version-using-prev

Here is a video showing how setting a search value and changing tabs work correctly. The final state is reflected correctly, with the search input containing my search value and the tab being the one I clicked:

with-prev-value.mp4

However, if I modify this state update:

setRouterState((prev) => {
const newParams = { ...prev.search };
if (value !== "") {
newParams[key] = value;
} else {
delete newParams[key];
}
return {
url: prev.url,
search: newParams,
pendingNav() {
const newUrlParams = new URLSearchParams(newParams).toString();
window.history.pushState(
{},
"",
prev.url + (newUrlParams ? `?${newUrlParams}` : ""),
);
},
};
});

and instead of using an updater function in the set function I grab the current state directly and just invoke the set function with the final transformation, as done in this other branch: voluntadpear/async-react@main...version-not-using-updater, this is the behavior observed:

without-updater.mp4

The value I entered into the search input is lost, as routerState doesn't reflect the state update done inside the transition.

This is a confusing behavior since per the docs of useState:

Is using an updater always preferred?

You might hear a recommendation to always write code like setAge(a => a + 1) if the state you’re setting is calculated from the previous > state. There is no harm in it, but it is also not always necessary.

In most cases, there is no difference between these two approaches. React always makes sure that for intentional user actions, like clicks, the age state variable would be updated before the next click. This means there is no risk of a click handler seeing a “stale” age at the beginning of the event handler.

However, if you do multiple updates within the same event, updaters can be helpful. They’re also helpful if accessing the state variable itself is inconvenient (you might run into this when optimizing re-renders).

If you prefer consistency over slightly more verbose syntax, it’s reasonable to always write an updater if the state you’re setting is calculated from the previous state. If it’s calculated from the previous state of some other state variable, you might want to combine them into one object and use a reducer.

I couldn't find any clear indication of what the behavior during a transition should be, and it looks to me the docs indicate that most of the time reading the current state directly should be fine. In my case. I believe the change I did doesn't fall into those mentioned in the docs as potentially problematic.

Could you perhaps shed some light about what's the underlying reason the updater is needed here and the state read is stale during the transition?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions