6. Persistence and effects
Load todos from localStorage after mount, save them with a deferred effect, sync other tabs and clean up listeners.
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.
export type Filter = "all" | "active" | "done";
export interface Todo {
id: number;
title: string;
done: boolean;
}
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.
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
onMountcallback reads storage once and replaces the (empty) list. Its returned function removes thestoragelistener when the owner is disposed, which is whenrender's dispose function runs or the component leaves the page. - The
storageevent fires in the other tabs when one tab writes.reconcilewithkey: "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,titleanddoneis what subscribes it to all of them.{ defer: true }skips the first run, so mounting never writes. BecauseonMountwas 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
storageonly when a value actually changes, so two tabs settle instead of ping-ponging. window.localStorageis read inside the callbacks only. Nothing here runs while rendering on the server.
The page#
The three presentational components are unchanged from step 5.
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>
);
}
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>
);
}
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.
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.