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);
        }
      >
        &times;
      </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);
        }
      ]
        -- &times;

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);
        }
      >
        &times;
      </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);
        }
      ]
        -- &times;

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);
        }
      >
        &times;
      </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);
        }
      ]
        -- &times;

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.