Home > front end >  Why is useMemo not rerendering even though the dependancy being passed into it is changing?
Why is useMemo not rerendering even though the dependancy being passed into it is changing?

Time:01-18

So I have a component TaskList:

const [incompleteTasks, setIncompleteTasks] = useState<any[]>([]);
  useMemo(() => {
    const filteredTasks = TaskStore.tasks.filter(
      (task: any) => !task.completed
    );
    setIncompleteTasks(filteredTasks);
  }, [TaskStore.tasks]);

and the observable state is passed as a dependancy from TaskStore:

public tasks: Task[] = [];
  constructor() {
    makeAutoObservable(this);
  }
  @action setCompleted = (task: Task, completed: number) => {
    const index = this.tasks.indexOf(task);
    if (index !== -1) {
      this.tasks[index].completed = !!completed;
    }
  };

I thought the way useMemo() works is that it caches the calculation in the first parameter(so the filtered array), and then the second parameter is the dependancy which triggers a useMemo() to return another calculation of the first parameter if it changes. Is my understanding wrong? Or am I not doing it correctly?

CodePudding user response:

You've said you believe TaskStore.tasks to be observable because you're using makeAutoObservable, so in theory that shouldn't be the problem. My Mobx is very rusty (I haven't used it in years, though I like the idea of it), but since you're using Mobx, I suspect you want a computed, not useMemo, for what you're doing. See the end of the answer for an example using Mobx where incompleteTasks is a computed.

Using useMemo

But if you want to use useMemo:

Using useMemo's callback to call a state setter is incorrect. Instead, incompleteTasks should be the result of useMemo, not a state member, because it's derived state, not primary state:

const incompleteTasks = useMemo(
    () => TaskStore.tasks.filter((task: any) => !task.completed),
    [TaskStore.tasks]
);

useMemo will only call your callback if TaskStore.tasks changes;¹ otherwise, when called, it will return the previously-returned value instead. So your code only filters when necessary.

Here's a really simple non-Mobx example of derived state:

const { useState, useCallback, useMemo, useEffect } = React;

const initialTasks = [
    { id: 1, text: "first task", completed: false },
    { id: 2, text: "second task", completed: false },
    { id: 3, text: "third task", completed: false },
    { id: 4, text: "fourth task", completed: false },
    { id: 5, text: "fifth task", completed: false },
    { id: 6, text: "sixth task", completed: false },
];

const Task = ({ task, onChange }) => {
    const handleChange = ({ currentTarget: { checked } }) => onChange(task, checked);
    return (
        <label>
            <input type="checkbox" checked={task.completed} onChange={handleChange} /> {task.text}
        </label>
    );
};

const Example = () => {
    const [counter, setCounter] = useState(0);
    const [tasks, setTasks] = useState(initialTasks);
    const incompleteTasks = useMemo(() => {
        console.log(`Updating incompleteTasks.`);
        return tasks.filter((task) => !task.completed);
    }, [tasks]);

    const updateTask = useCallback((task, completed) => {
        setTasks((tasks) => tasks.map((t) => (t === task ? { ...t, completed } : t)));
    }, []);

    // A pointless counter just to show that `incompleteTasks` isn't recreated
    // on every render
    useEffect(() => {
        const timer = setInterval(() => {
            setCounter((c) => c   1);
        }, 1000);
        return () => {
            clearInterval(timer);
        };
    }, []);

    // ...code to add/remove tasks not shown for brevity...

    return (
        <div>
            <div>
                Counter: {counter}
            </div>
            <h3>All tasks:</h3>
            <ul>
                {tasks.map((task) => (
                    <li key={task.id}>
                        <Task task={task} onChange={updateTask} />
                    </li>
                ))}
            </ul>
            <h3>Incomplete tasks:</h3>
            <ul>
                {incompleteTasks.map((task) => (
                    <li key={task.id}>
                        <Task task={task} onChange={updateTask} />
                    </li>
                ))}
            </ul>
        </div>
    );
};

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<Example />);
<div id="root"></div>
<div style="height: 50rem"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.1.0/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.1.0/umd/react-dom.development.js"></script>

Notice how even though the component is re-rendered every time counter changes, incompleteTasks is only re-calculated when tasks changes (for instance, because one of the tasks is marked completed/uncompleted).

Using Mobx's computed

But I suspect you don't want to use useMemo at all. Instead, you probably want to make incompleteTasks a Mobx computed. I haven't used Mobx in several years, but here's an example I put together using their documentation, particular this, this, and this:

const { useState, useCallback, useEffect } = React;

const { makeAutoObservable, action } = mobx;
const { observer, computed } = mobxReactLite;

class TaskStore {
    tasks = [];

    constructor() {
        makeAutoObservable(this);
    }

    get incompleteTasks() {
        console.log("Computing incompleteTasks...");
        return this.tasks.filter((task) => !task.completed);
    }
}

const taskStore = new TaskStore();
taskStore.tasks.push(
    { id: 1, text: "first task", completed: false },
    { id: 2, text: "second task", completed: false },
    { id: 3, text: "third task", completed: false },
    { id: 4, text: "fourth task", completed: false },
    { id: 5, text: "fifth task", completed: false },
    { id: 6, text: "sixth task", completed: false }
);

const Task = ({ task, onChange }) => {
    const handleChange = ({ currentTarget: { checked } }) => onChange(task, checked);
    return (
        <label>
            <input type="checkbox" checked={task.completed} onChange={handleChange} /> {task.text}
        </label>
    );
};

const Example = observer(({ taskStore }) => {
    const [counter, setCounter] = useState(0);

    const updateTask = useCallback(action((task, completed) => {
        task.completed = !task.completed;
    }), []);

    // A pointless counter just to show that `incompleteTasks` isn't recreated
    // on every render
    useEffect(() => {
        const timer = setInterval(() => {
            setCounter((c) => c   1);
        }, 1000);
        return () => {
            clearInterval(timer);
        };
    }, []);

    // ...code to add/remove tasks not shown for brevity...

    return (
        <div>
            <div>Counter: {counter}</div>
            <h3>All tasks:</h3>
            <ul>
                {taskStore.tasks.map((task) => (
                    <li key={task.id}>
                        <Task task={task} onChange={updateTask} />
                    </li>
                ))}
            </ul>
            <h3>Incomplete tasks:</h3>
            <ul>
                {taskStore.incompleteTasks.map((task) => (
                    <li key={task.id}>
                        <Task task={task} onChange={updateTask} />
                    </li>
                ))}
            </ul>
        </div>
    );
});

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<Example taskStore={taskStore} />);
<div id="root"></div>
<div style="height: 50rem"></div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.1.0/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.1.0/umd/react-dom.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mobx/6.7.0/mobx.umd.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mobx-react-lite/3.4.0/mobxreactlite.umd.development.js"></script>


¹ In theory. The useMemo documentation says it's only a performance optimiation, not a semantic guarantee, but that's all you need for this code.

CodePudding user response:

Your understanding of useMemo is correct. It does cache the result of a calculation between re-renders. Your code should be more like

return useMemo(() => {
    const filteredTasks = TaskStore.tasks.filter(
      (task: any) => !task.completed
    );
    setIncompleteTasks(filteredTasks);
  }, [TaskStore.tasks]);
  • Related