React toggle button only works once?



I am learning React Reducer now. I want to build a toggle button that changes a boolean completed value to its opposite each time I click the button.

What I have is an array of states, each state is an object with an id and a completed value set to be true or false. Then I loop through states, setting each state as an Item component and display it on screen.

// App.js file

import React, { useReducer } from "react";
import { AppReducer } from "./AppReducer";
import Item from "./Item";

function App() {
    const initialStates = [
        {
            id: 1,
            completed: false,
        },
        {
            id: 2,
            completed: false,
        },
    ];

    const [states, dispatch] = useReducer(AppReducer, initialStates);

    return (
        <div>
            {states.map((state) => (
                <Item item={state} key={state.id} dispatch={dispatch} />
            ))}
        </div>
    );
}

export default App;

In the Item component, I display whether this item is completed or not (true or false). I set up a toggle function on the button to change the completed state of the Item.

// Item.js

import React from "react";

const Item = ({ item, dispatch }) => {
    function setButtonText(isCompleted) {
        return isCompleted ? "True" : "False";
    }

    let text = setButtonText(item.completed);

    function toggle(id){
        dispatch({
            type: 'toggle', 
            payload: id
        })

        text = setButtonText(item.completed);
    }

    return (
        <div>
            <button type="button" onClick={() => toggle(item.id)}>Toggle</button>
            <span>{text}</span>
        </div>
    );
};

export default Item;


Here is my reducer function. Basically what I am doing is just loop through the states array and locate the state by id, then set the completed value to its opposite one.

// AppReducer.js

export const AppReducer = (states, action) => {
  switch (action.type) {
      case "toggle": {

          const newStates = states;
          for (const state of newStates) {
              if (state.id === action.payload) {
                  const next = !state.completed;
                  state.completed = next;
                  break;
              }
          }
          return [...newStates];
      }
      default:
          return states;
  }
};

So my problem is that the toggle button only works once. I checked my AppReducer function, it did change completed to its opposite value, however, every time we return [...newStates], it turned back to its previous value. I am not sure why is that. I appreciate it if you can give it a look and help me.

The code is available here.

Answer

Here is the working version forked from your codesandbox https://codesandbox.io/s/toggle-button-forked-jy6jd?file=/src/Item.js

The store value updated successfully. The problem is the way of listening the new item change. dispatch is a async event, there is no guarantee the updated item will be available right after dispatch()

So the 1st thing to do is to monitor item.completed change:

useEffect(() => {
    setText(setButtonText(item.completed));
}, [item.completed]);

The 2nd thing is text = setButtonText(item.completed);, it will not trigger re-render. Therefore, convert the text to state and set it when item.completed to allow latest value to be displayed on screen

const [text, setText] = useState(setButtonText(item.completed));


Source: stackoverflow