0

I am trying to make a simple task manager app and I want to implement react memo in TaskRow (task item) but when I click the checkbox to finish the task, the component properties are the same and I cannot compare them and all tasks are re-rendered again, any suggestions? Thanks

enter image description here

Sand Box: https://codesandbox.io/s/interesting-tharp-ziwe3?file=/src/components/Tasks/Tasks.jsx

Tasks Component

import React, { useState, useEffect, useCallback } from 'react'
import TaskRow from "../TaskRow";


function Tasks(props) {

    const [taskItems, setTaskItems] = useState([])

    useEffect(() => {
        setTaskItems(JSON.parse(localStorage.getItem('tasks')) || [])
    }, [])

    useEffect(() => {
        if (!props.newTask) return
        newTask({ id: taskItems.length + 1, ...props.newTask })
    }, [props.newTask])

    
    const newTask = (task) => {
        updateItems([...taskItems, task])
    }

    const toggleDoneTask = useCallback((id) => {
        const taskItemsCopy = [...taskItems]
        taskItemsCopy.map((t)=>{
            if(t.id === id){
                t.done = !t.done
                return t
            }
            return t
        })
        console.log(taskItemsCopy)
        console.log(taskItems)
        updateItems(taskItemsCopy)
    }, [taskItems])

    const updateItems = (tasks) => {
        setTaskItems(tasks)
        localStorage.setItem('tasks', JSON.stringify(tasks))
    }


    return (
        <React.Fragment>
            <h1>learning react </h1>
            <table>
                <thead>
                    <tr>
                        <th>Title</th>
                        <th>Description</th>
                        <th>Done</th>
                    </tr>
                </thead>
                <tbody>
                    {
                        props.show ? taskItems.map((task, i) =>
                            <TaskRow
                                task={task}
                                key={task.id}
                                toggleDoneTask={()=>toggleDoneTask(task.id)}>
                            </TaskRow>)
                            :
                            taskItems.filter((task) => !task.done)
                                .map((task) =>
                                    <TaskRow
                                        show={props.show}
                                        task={task}
                                        key={task.id}
                                        toggleDoneTask={()=>toggleDoneTask(task.id)}></TaskRow>
                                )
                    }
                </tbody>
            </table>
        </React.Fragment>
    )
}


export default Tasks

Item task (TaskRow component)

import React, { memo } from 'react'

function TaskRow(props) {

    return (<React.Fragment>
        {console.log('render', props.task)}
        <Tr show={props.show} taskDone={props.task.done}>
            <td>
                {props.task.title}
            </td>
            <td>
                {props.task.description}
            </td>
            <td>
                <input type="checkbox"
                    checked={props.task.done}
                    onChange={props.toggleDoneTask}
                />

            </td>
        </Tr>
    </React.Fragment>)

}


export default memo(TaskRow, (prev,next)=>{
    console.log('prev props', prev.task)
    console.log('next props', next.task)
})
Franco
  • 207
  • 1
  • 13

1 Answers1

1

I'm afraid there are quite a lot of problems with the code you've shared, only some of them due to React.memo. I'll start there and work through the ones I've spotted.

You've provided an equality testing function to memo, but you are not using it to test anything. The default behaviour, which requires no testing function, will shallowly compare props between the previous and the next render. This means it will pick up on differences between primitive values (e.g. string, number, boolean) and references to objects (e.g. literals, arrays, functions), but it will not automatically deeply compare those objects.

Remember, memo will only allow rerenders when the equality testing function returns false. You've provided no return value to the testing function, meaning it returns undefined, which is falsy. I've provided a simple testing function for an object literal with primitive values which will do the job needed here. If you have more complex objects to pass in the future, I suggest using a comprehensive deep equality checker like the one provided by the lodash library, or, even better, do not pass objects at all if you can help it and instead try to stick to primitive values.

export default memo(TaskRow, (prev, next) => {
  const prevTaskKeys = Object.keys(prev.task);
  const nextTaskKeys = Object.keys(next.task);

  const sameLength = prevTaskKeys.length === nextTaskKeys.length;
  const sameEntries = prevTaskKeys.every(key => {
    return nextTaskKeys.includes(key) && prev.task[key] === next.task[key];
  });

  return sameLength && sameEntries;
});

While this solves the initial memoisation issue, the code is still broken for a couple of reasons. The first is that despite copying your taskItems in toggleTaskDone, for similar reasons to those outlined above, your array of objects is not deeply copied. You are placing the objects in a new array, but the references to those objects are preserved from the previous array. Any changes you make to those objects will be directly mutating the React state, causing the values to become out of sync with the rest of your effects.

You can solve this by mapping the copy and spreading the objects. You would have to do this for every level of object reference in any state object you attempt to change, which is part of the reason that React advises against complex objects in useState (one level of depth is usually fine).

const taskItemsCopy = [...taskItems].map((task) => ({ ...task }));

Side Note: You are not doing anything with the result of taskItemsCopy in your original code. map is not a mutating method - calling it without assigning the result to a variable does nothing.

The next issue is more subtle, and demonstrates one of the pitfalls and potential complications when memoising your components. The toggleTaskDone callback has taskItems in its dependency array. However, you are passing it as a prop in an anonymous function to TaskRow. This prop is not being considered by React.memo - we're specifically ignoring it because we only want to rerender on changes to the task object itself. This means that when a task does change its done status, all the other tasks are becoming out of sync with the new value of taskItems - when they change their done status, they will be using the value of taskItems as it was the last time they were rendered.

Inline anonymous functions are recreated on every render, so they are always unequal by reference. You could actually fix this somewhat by adjusting the way the callback is passed and executed:

// Tasks.jsx
toggleDoneTask={toggleDoneTask}
// TaskRow.jsx
onChange={() => props.toggleDoneTask(props.task.id)}

In this way you would be able to check for reference changes in your memo equality function, but since the callback changes every time taskItems changes, this would make the memoisation completely useless!

So, what to do. This is where the implementation of the rest of the Tasks component starts to limit us a bit. We can't have taskItems in the dependency of toggleTaskDone, and we also can't call updateItems because that has the same (implicit) dependency. I've provided a solution which technically works, although I would consider this a hack and not really recommended for actual usage. It relies on the callback version of setState which will allow us to have access to the current value of taskItems without including it as a dependency.

const toggleDoneTask = useCallback((id) => {
  setTaskItems((prevItems) => {
    const prevCopy = [...prevItems].map((task) => ({ ...task }));
    const newItems = prevCopy.map((t) => {
      if (t.id === id) t.done = !t.done;
      return t;
    });
    localStorage.setItem("tasks", JSON.stringify(newItems));
    return newItems;
  });
}, []);

Now it doesn't matter that we aren't equality checking the handler prop, because the function never alters from the initial render of the component. With these changes implemented my fork of your sandbox seems to be working as expected.

On a broader note, I really think you should consider writing React code using create-react-app when you're learning the framework. I was a little surprised to see you had a custom webpack set up, and you don't seem to have proper linting for React (bundled automatically in CRA) which would highlight a lot of these issues for you as warnings. Specifically, the misuse of the dependency array in a number of places in the Task component which is going to make it unstable and error prone even with the essential fixes I've suggested.

lawrence-witt
  • 8,094
  • 3
  • 13
  • 32
  • Hello, everything you told me helped me a lot, but I still don't understand why you have to use that "hack", I tried to search for information on the internet but I did not find anything that answered my question. I know tasks won't sync without the "hack", but I don't understand why, thanks for your help! – Franco Dec 17 '20 at 05:49
  • 1
    When you introduce `memo` into your components, you are taking *direct* responsibility for which values they get updated with and when. I answered a [similar question](https://stackoverflow.com/q/65150393/12799351) recently which goes over this in a bit more detail. I also briefly explain why doing this to get around declaring your dependencies is bad practice. The way to implement this 'correctly' is to switch to a [reducer](https://reactjs.org/docs/hooks-reference.html#usereducer) state pattern, which does essentially the same thing as my solution but in a more structured and readable way. – lawrence-witt Dec 17 '20 at 11:46
  • Sure, the "hack" is much more readable, structured, and readable, but why don't you recommend it? – Franco Dec 17 '20 at 19:24
  • 1
    I'm saying that the reducer pattern is better structured, and ultimately more readable, because it explicitly decouples the handler callback from the actual setting of state. Since `useState` is actually just a special implementation of `useReducer` under the hood, the footprint of the functions called when you use the `useState` callback is very similar. In which case, it's better to just be 'honest' about how your state actually works and make your own reducer. Maybe 'hack' is too strong a word - the original code I posted is perfectly valid, I just wouldn't consider it ideal. – lawrence-witt Dec 17 '20 at 20:25
  • One last question and I don't bother you anymore, hahaha, when you say "We can't have taskItems in the dependency of toggleTaskDone, and we also can't call updateItems because that has the same (implicit) dependency" why we can't have taskItems in the dependency of toggleTaskDone?, thanks for all your help, and sorry for my bad english xD – Franco Dec 17 '20 at 23:31
  • 1
    No problem! Think through these steps: 1) `taskItems` is in the dependency array, 2) whenever `taskItems` is changed, the callback is recreated with the new values, 3) if you pass the callback to your memoised component, then the component needs to be rerendered every time `taskItems` changes to get the recreated callback, 4) if you do that, there's no point memoising it at all because all `TaskRow` components will be rerendered whenever one of them has a change in value. – lawrence-witt Dec 18 '20 at 01:21