reactjsreact-hooksreact-contextmemoreact-memo

React memo previous props and new props are same in child of context consumer even when the context value has changed


I am trying to test optimization of renders with a dummy todo app. I created a context so as to pass the items, as well as various setter functions to the child components. As far as I understand, the direct consumer of context will always re-render if the context has changed but we can still optimize the re-renders for the children of the context consumer components, and that is what I am trying to do here.

The problem is that every time I mark a todo item as completed, the state in the context provider changes correctly but the final todo item gets the same value as the previous and the new props. Basically, the updated state not only reflects in the new props, but the prev props also, which is absurd to me.

Adding my dummy code here:

TodoContext.tsx


import React, { PropsWithChildren, createContext, useState } from "react";

const todoContext = createContext({});

interface TodoItem {
  id: string,
  description: string,
  state: TodoStateFilter
}

type TodoStateFilter = 'all' | 'active' | 'completed'

const TodoContextProvider: React.FC<PropsWithChildren> = ({ children }) => {
  const [items, setItems] = useState<TodoItem[]>([
    {
      id: "123",
      description: "first dummy task",
      state: 'active'
    }
  ]);
  React.useEffect(() => {
    console.log('items changed', items)
  },[items])

  const deleteItem = function (itemId: string) {
    console.log("removing todo item ", itemId);
    setItems((currentItems) => {
      const remainingItems = currentItems.filter((item) => {
        return item.id !== itemId;
      });
      return [...remainingItems];
    });
  }

  const toggleItemState = function(itemId: string, state: TodoStateFilter) {
    console.log("toggle state changing state of todo item ", itemId, ' to ',state);
    setItems((currentItems) => {
      console.log("FOO toggle state updating currentItems", currentItems)
      const updatedItems = currentItems.map((item) => {
        if (item.id === itemId) {
          item.state = state
        }
        return item;
      });
      console.log('toggle state updated ', updatedItems)
      console.log('toggle state updated array reference has changed? ', updatedItems!==currentItems)
      return updatedItems;
    });
  }

  return (
    <todoContext.Provider
      value={{
        items,
        // addItem,
        deleteItem,
        // updateItem,
        toggleItemState,
        // applyTodoFilter,
        // filterApplied
      }}
    >
      {children}
    </todoContext.Provider>
  );
};

export { todoContext, TodoContextProvider };

ShowTodoList.tsx

// @ts-nocheck
import React from "react";
import { todoContext } from "./context/TodoContext";
import TodoListItem from "./TodoListItem";
import "./ShowTodoList.css";

const ShowTodoList = () => {
  const { 
    items, 
    toggleItemState, 
    deleteItem, 
  } = React.useContext(todoContext);

  const handleToggleState = React.useCallback((itemId,newState) => {
    console.log('toggle state reached inside ShowTodoList ', itemId, newState)
    toggleItemState(itemId, newState);
  }, []);

  const handleDelete = React.useCallback((itemId) => {
    deleteItem(itemId)
  }, [])

  return (
    <div className="todo--list">
      {items
      .map((item) => {
        return (
        <TodoListItem 
          key={item.id}
          item={item} 
          handleState={handleToggleState} 
          handleDelete={handleDelete}
        />
        );
      })}
    </div>
  );
};

export default ShowTodoList;

TodoListItem.tsx

import React from 'react';
import './TodoListItem.css'

interface ItemProps {
  item: any,
  handleState: (id: string, newState: 'active' | 'completed') => void,
  handleDelete: (id: string) => void,
}

const TodoListItem: React.FunctionComponent<ItemProps> = ({item
  , handleState
  , handleDelete
}: ItemProps): JSX.Element => {
  const [editMode, setEditMode] = React.useState<boolean>(false);

  return (
    !editMode 
    ? (<div className={`todo__item`}>
      <div className='todo__item--state'>
      <div 
      className={`todo__item--state-button ${item.state}`} 
      id={item.id}
      onClick={() => {const newState = item.state === 'active' ? 'completed' : 'active'; console.log('toggle state triggered inside item ', item); handleState(item.id, newState); return;}}
      ></div>
      </div>

      <p 
        className={`item__description item__description--${item.state}`}
        // @ts-ignore
        onDoubleClick={() => handleEditMode()}
      >{item.description}</p>

      <div className={`item--delete`} onClick={() => handleDelete(item.id)}>X</div>
    </div>)
    : (<div className={`todo__item`}>
        {/* <input value={editedInput} onChange={handleInputChange} onKeyDown={handleInputChange}
          id="todo__input"
          className="todo__input"
          /> */}
    </div>)
  )
}

function propsAreEqual(prevProps: ItemProps, newProps: ItemProps): boolean{
  console.log('comparing prevProps and newProps', prevProps, newProps);
  if(prevProps.item !== newProps.item){
    return false;
  }
  const v = (
    (prevProps.handleState === newProps.handleState) 
    && (prevProps.handleDelete === newProps.handleDelete) 
    && (
      (prevProps.item.id === newProps.item.id) 
      && (prevProps.item.description === newProps.item.description) 
      && (prevProps.item.state === newProps.item.state)
      ))
  console.log('props equal ? ', v);
  return v;
}

export default React.memo(TodoListItem, propsAreEqual)

and this is my oversimplified App.tsx

import { TodoContextProvider } from "./context/TodoContext";
import ShowTodoList from './ShowTodoList'
import "./styles.css";

export default function App() {
  return (
    <div className="App">
      <TodoContextProvider>
        <ShowTodoList />
      </TodoContextProvider>
    </div>
  );
}

When I click the button toggle the item state, I am not seeing the item re-render and the log gives shows me the same value of old props and new props


Solution

  • You are mutating the item when you toggle it. Instead create a new item object with the updated state:

    const toggleItemState = function(itemId: string, state: TodoStateFilter) {
      setItems(currentItems => 
        currentItems.map(item => 
          item.id === itemId
          ? { ...item, state } // crate a new item
          : item
        )
      );
    }