Nested Reactivity
It is often the case in application development that state is stored in a top-level object which is then represented and mutated throughout the component tree. This guide will outline 3 ways of handling this type of pattern, using a To-Do List example as a base.
Each method in this guide is more complex and has more overhead than those before it. Generally, you should use the least complex method that still addresses the needs of your application.
Core Example: To-Do List
Each example in this guide will build on the following client-side To-Do list application.
<let/todos=[
{ id: 0, text: "Learn Marko" },
{ id: 1, text: "Make a Website" },
]/>
<ul>
<for|todo, i| of=todos by="id">
<li>
<id/checkboxId/>
<input type="checkbox" id=checkboxId>
<label for=checkboxId>
${todo.text}
</label>
<button
title="delete"
onClick() {
todos = todos.toSpliced(i, 1);
}
>
×
</button>
</li>
</for>
</ul>
<let/nextId=2/>
<form onSubmit(e) {
e.preventDefault();
todos = todos.concat({
id: nextId++,
text: e.target.text.value,
});
}>
<input name="text" placeholder="Another Item">
<button type="submit">
Add
</button>
</form>
let/todos=[
{ id: 0, text: "Learn Marko" },
{ id: 1, text: "Make a Website" },
]
ul
for|todo, i| of=todos by="id"
li
id/checkboxId
input type="checkbox" id=checkboxId
label for=checkboxId -- ${todo.text}
button [
title="delete"
onClick() {
todos = todos.toSpliced(i, 1);
}
]
-- ×
let/nextId=2
form onSubmit(e) {
e.preventDefault();
todos = todos.concat({
id: nextId++,
text: e.target.text.value,
});
}
input name="text" placeholder="Another Item"
button type="submit" -- Add
Case 1: Local State
The first rule of nested reactivity is that you should try to avoid nested reactivity. Generally, state should be managed as close to its uses as possible. Before jumping right into hoisting state into a global object, please consider whether it actually makes sense to do so.
In most cases, it makes more sense to create local state than to add a value to some global store. For example, maybe we want to disable deleting items that haven't yet been completed. For this, we need to hoist state out of the input.
<let/todos=[
{ id: 0, text: "Learn Marko" },
{ id: 1, text: "Make a Website" },
]/>
<ul>
<for|todo, i| of=todos by="id">
<li>
<id/checkboxId/>
<let/done=false/>
<input type="checkbox" checked:=done id=checkboxId>
<label for=checkboxId>
${todo.text}
</label>
<button
title="delete"
disabled=!done
onClick() {
todos = todos.toSpliced(i, 1);
}
>
×
</button>
</li>
</for>
</ul>
let/todos=[
{ id: 0, text: "Learn Marko" },
{ id: 1, text: "Make a Website" },
]
ul
for|todo, i| of=todos by="id"
li
id/checkboxId
let/done=false
input type="checkbox" checked:=done id=checkboxId
label for=checkboxId -- ${todo.text}
button [
title="delete"
disabled=!done
onClick() {
todos = todos.toSpliced(i, 1);
}
]
-- ×
Notice that for this feature, there is no need to modify the todos
object at the top level. State can stay local, so nested reactivity on that object is unnecessary.
Case 2: Simple Hoisted State
Sometimes, it does make sense to hoist state up to a global object. Suppose we want to add a feature where clicking a button shows the first item in the list that isn't done yet. For that information to be known, we need to include the state in the todos
object:
<let/todos=[
{ id: 0, text: "Learn Marko", done: false },
{ id: 1, text: "Make a Website", done: false },
]/>
<ul>
<for|todo, i| of=todos by="id">
<li>
<id/checkboxId/>
<let/done=todo.done valueChange(done) {
todos = todos.toSpliced(i, 1, { ...todo, done });
}/>
<input type="checkbox" checked:=done id=checkboxId>
<label for=checkboxId>
${todo.text}
</label>
<button
title="delete"
disabled=!done
onClick() {
todos = todos.toSpliced(i, 1);
}
>
×
</button>
</li>
</for>
</ul>
let/todos=[
{ id: 0, text: "Learn Marko", done: false },
{ id: 1, text: "Make a Website", done: false },
]
ul
for|todo, i| of=todos by="id"
li
id/checkboxId
let/done=todo.done valueChange(done) {
todos = todos.toSpliced(i, 1, { ...todo, done });
}
input type="checkbox" checked:=done id=checkboxId
label for=checkboxId -- ${todo.text}
button [
title="delete"
disabled=!done
onClick() {
todos = todos.toSpliced(i, 1);
}
]
-- ×
Modifying state trees directly like this is often tedious and hard to follow, so we could also use a library like immer to handle state updates:
import { produce } from "immer";
<let/done=todo.done valueChange(done) {
todos = produce(todos, (draft) => {
draft[i].done = done;
});
}/>
import { produce } from "immer";
let/done=todo.done valueChange(done) {
todos = produce(todos, (draft) => {
draft[i].done = done;
});
}
Case 3: Complex Hoisted State
Contributors
Helpful? You can thank these awesome people! You can also edit this doc if you see any issues or want to improve it.