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
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
)
);
}