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