3

I have two reducer actions that I want to dispatch one after the other. The first one modifies the state, then the second one uses a portion of the modified state to make another modification. The difficulty is that when the second dispatch is called, it still has the old outdated state and thus doesn't update the state properly.

An example is the following (also found here - https://codesandbox.io/s/react-usereducer-hqtc2) where there is a list of conversations along with a note of which one is considered the "active" conversation:

import React, { useReducer } from "react";

const reducer = (state, action) => {
  switch (action.type) {
    case "removeConversation":
      return {
        ...state,
        conversations: state.conversations.filter(
          c => c.title !== action.payload
        )
      };
    case "setActive":
      return {
        ...state,
        activeConversation: action.payload
      };
    default:
      return state;
  }
};

export default function Conversations() {
  const [{ conversations, activeConversation }, dispatch] = useReducer(
    reducer,
    {
      conversations: [
        { title: "James" },
        { title: "John" },
        { title: "Mindy" }
      ],
      activeConversation: { title: "James" }
    }
  );

  function removeConversation() {
    dispatch({ type: "removeConversation", payload: activeConversation.title });
    dispatch({ type: "setActive", payload: conversations[0] });
  }

  return (
    <div>
      Active conversation: {activeConversation.title}
      <button onClick={removeConversation}>Remove</button>
      <ul>
        {conversations.map(conversation => (
          <li key={conversation.title}>{conversation.title}</li>
        ))}
      </ul>
    </div>
  );
}

In here, when I click the "remove conversation" button, I want to remove the active conversation, then set the active conversation to be the one at the top of the list. However, here when the first dispatch removes the conversation from the list, the second dispatch sets active to conversations[0], which still contains the removed value (since the state hasn't updated yet). As a result, it keeps the active conversation as the one it was before, even though it's been removed from the list.

I could probably combine the logic into just one action and do it all there (remove the conversation and set active all in one), but I would ideally like to keep my reducer actions to have one responsibility each if possible.

Is there any way to make the second dispatch call have the most recent version of the state so that this kind of problem doesn't occur?

2 Answers2

2

It may help if you think of useEffect() like setState's second parameter (from class based components).

If you want to do an operation with the most recent state, use useEffect() which will be hit when the state changes:

const {
  useState,
  useEffect
} = React;

function App() {
  const [count, setCount] = useState(0);
  const decrement = () => setCount(count-1);
  const increment = () => setCount(count+1);
  
  useEffect(() => {
    console.log("useEffect", count);
  }, [count]);
  console.log("render", count);
  
  return ( 
    <div className="App">
      <p>{count}</p> 
      <button onClick={decrement}>-</button> 
      <button onClick={increment}>+</button> 
    </div>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render( < App / > , rootElement);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.6/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.6/umd/react-dom.production.min.js"></script>

<div id="root"></div>

Some further info on useEffect()

Miroslav Glamuzina
  • 4,472
  • 2
  • 19
  • 33
0

Answering this for anyone who may come across similar issues in the future. The key to finding the solution to this is understanding that state in React is a snapshot.

You can see that in the dispatched setActive action, the value of conversations[0] of state is being passed:

dispatch({ type: "setActive", payload: conversations[0] });

Thus when the action is called before the next render, it uses the snapshotted state at the time of re-render:

// snapshot of state when action is called
{
  conversations: [
    { title: "James" },
    { title: "John" },
    { title: "Mindy" }
  ],
  activeConversation: { title: "James" }
}

Thus conversations[0] evaluates to {title: "James"}. This is why in the reducer, activeConversation: action.payload returns {title: "James"} and the active conversation doesn't change. In technical terms, "you're calculating the new state from the value in your closure, instead of calculating it from the most recent value."

So how do we fix this? Well useReducer actually in fact always has access to the most recent state value. It is a sister pattern to the state updater function, which also gives you access to the latest state variable even before the next render.

This means that after the first dispatch action:

dispatch({ type: "removeConversation", payload: activeConversation.title }); // first dispatch action
dispatch({ type: "setActive", payload: conversations[0] }); // second dispatch action

the next dispatch action actually has access to the latest state already. You just need to access it:

case "setActive":
  return {
    ...state,
    activeConversation: state.conversations[0]
  };

You can verify this by logging it to the console:

const reducer = (state, action) => {
  console.log(state);
  switch (action.type) {
    case "removeConversation":
      return {
        ...state,
        conversations: state.conversations.filter(
          c => c.title !== action.payload
        )
      };
    case "setActive":
      return {
        ...state,
        activeConversation: state.conversations[0]
      };
    default:
      return state;
  }
};

Also important to note that the 2 dispatch calls are batched as explained in the state updater function link mentioned above. More info on batching here too.

neldeles
  • 588
  • 1
  • 5
  • 12