[]previous and next page

6. Persistence and effects

Load todos from localStorage after mount, save them with a deferred effect, sync other tabs and clean up listeners.

For agentsUpdated

Goal: todos survive a reload, the document title shows the open count, and another tab's changes appear without a refresh. Effects, ownership and cleanup are covered by the guide Effects and lifecycle.

When code runs#

Three primitives schedule work after the DOM exists. All of them are skipped on the server, which is what makes this app safe to render there in step 8.

Primitive Runs Tracks
onMount(fn) Once, in the first flush after creation. Nothing: fn runs untracked.
useEffect(fn) In the first flush, then whenever a state value or store property it read changes. Everything fn reads.
useEffect(deps, fn, { defer: true }) Whenever deps() yields a new value. With defer the first run only collects dependencies. Only deps; fn runs untracked.

Writes are applied in a microtask, or at the end of an event handler, batch or flushSync. Two writes in one tick therefore run an effect once with the final state. A function returned from an effect or onMount is its cleanup: it runs before the next run and when the owner is disposed.

Storage#

The storage code needs only getItem and setItem. Data read from storage is untrusted: entries that are not well-formed todos are dropped.

src/types.tsts
export type Filter = "all" | "active" | "done";

export interface Todo {
  id: number;
  title: string;
  done: boolean;
}
src/storage.tsts
import type { Todo } from "./types";

export interface TodoStorage {
  getItem(key: string): string | null;
  setItem(key: string, value: string): void;
}

export const STORAGE_KEY = "shadowjs-tutorial:todos";

export function parseTodos(raw: string | null): Todo[] {
  if (raw === null) return [];
  let value: unknown;
  try {
    value = JSON.parse(raw);
  } catch {
    // Corrupt JSON is treated like no data at all.
    return [];
  }
  if (!Array.isArray(value)) return [];
  const todos: Todo[] = [];
  for (const entry of value) {
    if (typeof entry !== "object" || entry === null) continue;
    const { id, title, done } = entry as Record<string, unknown>;
    if (typeof id === "number" && typeof title === "string" && typeof done === "boolean") {
      todos.push({ id, title, done });
    }
  }
  return todos;
}

export function loadTodos(storage: TodoStorage): Todo[] {
  return parseTodos(storage.getItem(STORAGE_KEY));
}

export function saveTodos(storage: TodoStorage, todos: readonly Todo[]): void {
  storage.setItem(STORAGE_KEY, JSON.stringify(todos));
}

Load, save and sync in the hook#

useTodos now starts empty and fills itself in onMount. Loading in onMount rather than while the hook runs keeps the first render identical on the server and the client, and keeps window out of code that runs during rendering.

src/todos.tsts
import { onMount, reconcile, useEffect, useMemo, useStore } from "@shadow-js/core";
import { loadTodos, saveTodos, STORAGE_KEY, type TodoStorage } from "./storage";
import type { Filter, Todo } from "./types";

function matches(filter: Filter, todo: Todo): boolean {
  if (filter === "active") return !todo.done;
  if (filter === "done") return todo.done;
  return true;
}

export function useTodos(storage?: TodoStorage) {
  const [state, setState] = useStore({ todos: [] as Todo[], filter: "all" as Filter });
  const visible = useMemo(() => state.todos.filter((todo) => matches(state.filter, todo)));
  const target = (): TodoStorage => storage ?? window.localStorage;

  onMount(() => {
    setState("todos", loadTodos(target()));

    const onStorage = (event: StorageEvent): void => {
      if (event.key !== STORAGE_KEY) return;
      setState("todos", reconcile(loadTodos(target()), { key: "id" }));
    };
    window.addEventListener("storage", onStorage);
    return () => window.removeEventListener("storage", onStorage);
  });

  useEffect(
    () => state.todos.map((todo) => ({ id: todo.id, title: todo.title, done: todo.done })),
    (todos) => saveTodos(target(), todos),
    { defer: true },
  );

  return {
    state,
    visible,
    add(title: string): boolean {
      const text = title.trim();
      if (text === "") return false;
      setState((draft) => {
        const id = draft.todos.reduce((max, todo) => Math.max(max, todo.id), 0) + 1;
        draft.todos.push({ id, title: text, done: false });
      });
      return true;
    },
    toggle(id: number) {
      setState("todos", (todo) => todo.id === id, "done", (done) => !done);
    },
    remove(id: number) {
      setState((draft) => {
        const index = draft.todos.findIndex((todo) => todo.id === id);
        if (index !== -1) draft.todos.splice(index, 1);
      });
    },
    setFilter(filter: Filter) {
      setState("filter", filter);
    },
    clearCompleted() {
      setState((draft) => {
        for (let index = draft.todos.length - 1; index >= 0; index -= 1) {
          if (draft.todos[index]?.done === true) draft.todos.splice(index, 1);
        }
      });
    },
  };
}

What each part does:

  • The onMount callback reads storage once and replaces the (empty) list. Its returned function removes the storage listener when the owner is disposed, which is when render's dispose function runs or the component leaves the page.
  • The storage event fires in the other tabs when one tab writes. reconcile with key: "id" diffs the loaded data into the store: items whose id survives keep their identity, so their rows are not rebuilt.
  • The save effect tracks a plain copy of the todos: reading every id, title and done is what subscribes it to all of them. { defer: true } skips the first run, so mounting never writes. Because onMount was created first, the load has already happened when the effect collects its dependencies, and the load itself does not trigger a save either.
  • Applying another tab's data changes the store, so the save effect writes it back once. The string is the same, and browsers fire storage only when a value actually changes, so two tabs settle instead of ping-ponging.
  • window.localStorage is read inside the callbacks only. Nothing here runs while rendering on the server.

The page#

The three presentational components are unchanged from step 5.

src/AddTodo.tsxtsx
import { onMount, useStore } from "@shadow-js/core";

export interface AddTodoProps {
  onAdd: (title: string) => void;
}

export function AddTodo(props: AddTodoProps) {
  const [title, setTitle] = useStore("");
  let input: HTMLInputElement | undefined;

  onMount(() => {
    input?.focus();
  });

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        props.onAdd(title());
        setTitle("");
      }}
    >
      <input
        ref={(element) => {
          input = element;
        }}
        type="text"
        aria-label="New todo"
        placeholder="What needs to be done?"
        value={title()}
        onInput={(event) => setTitle(event.currentTarget.value)}
      />
      <button type="submit" disabled={title().trim() === ""}>
        Add
      </button>
    </form>
  );
}
src/TodoItem.tsxtsx
import type { Todo } from "./types";

export interface TodoItemProps {
  todo: Todo;
  onToggle: () => void;
  onRemove: () => void;
}

export function TodoItem(props: TodoItemProps) {
  return (
    <li classList={{ done: props.todo.done }}>
      <label>
        <input type="checkbox" checked={props.todo.done} onChange={() => props.onToggle()} />
        {props.todo.title}
      </label>
      <button
        type="button"
        aria-label={`Delete ${props.todo.title}`}
        onClick={() => props.onRemove()}
      >
        Delete
      </button>
    </li>
  );
}
src/Filters.tsxtsx
import { For } from "@shadow-js/web";
import type { Filter } from "./types";

const OPTIONS: readonly { value: Filter; label: string }[] = [
  { value: "all", label: "All" },
  { value: "active", label: "Active" },
  { value: "done", label: "Done" },
];

export interface FiltersProps {
  value: Filter;
  onChange: (filter: Filter) => void;
}

export function Filters(props: FiltersProps) {
  return (
    <nav aria-label="Filter">
      <For each={OPTIONS}>
        {(option) => (
          <button
            type="button"
            aria-pressed={props.value === option.value}
            onClick={() => props.onChange(option.value)}
          >
            {option.label}
          </button>
        )}
      </For>
    </nav>
  );
}

App takes the storage as an optional prop (the default is window.localStorage) and adds an effect for the document title. The effect reads remaining, a derived constant, inside its closure; that read is tracked.

src/App.tsxtsx
import { useEffect } from "@shadow-js/core";
import { For, Show } from "@shadow-js/web";
import { AddTodo } from "./AddTodo";
import { Filters } from "./Filters";
import type { TodoStorage } from "./storage";
import { TodoItem } from "./TodoItem";
import { useTodos } from "./todos";

export interface AppProps {
  storage?: TodoStorage;
}

export function App(props: AppProps) {
  const todos = useTodos(props.storage);
  const remaining = todos.state.todos.filter((todo) => !todo.done).length;
  const completed = todos.state.todos.length - remaining;
  const empty =
    todos.state.filter === "all" ? "Nothing to do." : `No ${todos.state.filter} todos.`;

  useEffect(() => {
    document.title = remaining === 0 ? "Todos" : `(${remaining}) Todos`;
  });

  return (
    <main>
      <h1>Todos</h1>
      <AddTodo onAdd={todos.add} />
      <Filters value={todos.state.filter} onChange={todos.setFilter} />
      <Show when={todos.visible().length > 0} fallback={<p>{empty}</p>}>
        <ul>
          <For each={todos.visible()}>
            {(todo) => (
              <TodoItem
                todo={todo}
                onToggle={() => todos.toggle(todo.id)}
                onRemove={() => todos.remove(todo.id)}
              />
            )}
          </For>
        </ul>
      </Show>
      <p>{remaining} left</p>
      <Show when={completed > 0}>
        <button type="button" onClick={() => todos.clearCompleted()}>
          Clear completed ({completed})
        </button>
      </Show>
    </main>
  );
}

What you should see#

Add a few todos, reload the page and they are still there. The tab title reads (2) Todos while two are open. Stored todos appear after mount. Mounting alone does not overwrite them; the next change saves the new list. Open a second tab and edit the list there: its storage event updates the first tab too.

Next: 8. Server rendering and build.