Jotai Vanilla via CDN: Multi-Atom, Derived State, Actions, and Persistence

Below is a multi-atom Jotai Vanilla example (via CDN) that demonstrates: separate atoms for multiple concerns, derived atoms (computed values), write-only atoms (actions), and simple persistence with localStorage. It remains framework-agnostic and runs in a single HTML file.

Jotai Vanilla via CDN: Multi-Atom, Derived State, Actions, and Persistence
Photo by Aedrian Salazar / Unsplash

Example: Profile + Counter + Todos (no framework, single file)

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8" />
  <title>Jotai Vanilla – Multi-Atom Example</title>
  <style>
    body { font-family: system-ui, Arial, sans-serif; line-height: 1.4; margin: 24px; }
    .card { border: 1px solid #ddd; border-radius: 12px; padding: 16px; margin-bottom: 16px; }
    .row { display: flex; gap: 8px; align-items: center; margin: 8px 0; }
    input[type="text"], input[type="number"] { padding: 6px 8px; }
    button { padding: 6px 10px; cursor: pointer; }
    ul { padding-left: 20px; }
    li { display: flex; justify-content: space-between; align-items: center; margin: 4px 0; }
    small { color: #666; }
  </style>
</head>
<body>
  <h1>Jotai Vanilla – Multi-Atom Demo</h1>

  <div class="card">
    <h2>Profile</h2>
    <div class="row">
      <label for="name">Name:</label>
      <input id="name" type="text" placeholder="Your name" />
      <span id="greeting"></span>
    </div>
  </div>

  <div class="card">
    <h2>Counter</h2>
    <div class="row">
      <label for="step">Step:</label>
      <input id="step" type="number" min="1" step="1" value="1" />
      <small>(how much to add/subtract)</small>
    </div>

    <div class="row" style="font-size:1.5em;">
      <strong>Count:</strong> <span id="count">0</span>
      <span> | Doubled: <span id="doubled">0</span></span>
    </div>

    <div class="row">
      <button id="decrement">–</button>
      <button id="increment">+</button>
      <button id="reset">Reset All</button>
    </div>
  </div>

  <div class="card">
    <h2>Todos</h2>
    <div class="row">
      <input id="todoInput" type="text" placeholder="Add a todo..." />
      <button id="addTodo">Add</button>
    </div>
    <ul id="todoList"></ul>
  </div>

  <script type="module">
    import { atom, createStore } from "https://esm.sh/[email protected]/vanilla";

    // ---------- Store ----------
    const store = createStore();

    // ---------- Atoms (state) ----------
    const nameAtom = atom("Guest");
    const stepAtom = atom(1);
    const countAtom = atom(0);
    const todosAtom = atom([]); // array of { id, title }

    // ---------- Derived atoms (read-only) ----------
    const doubledAtom = atom((get) => get(countAtom) * 2);
    const greetingAtom = atom((get) => `Hello, ${get(nameAtom)}!`);

    // ---------- Action atoms (write-only) ----------
    const incrementAtom = atom(null, (get, set) => {
      set(countAtom, get(countAtom) + get(stepAtom));
    });

    const decrementAtom = atom(null, (get, set) => {
      set(countAtom, get(countAtom) - get(stepAtom));
    });

    const addTodoAtom = atom(null, (get, set, title) => {
      const trimmed = String(title || "").trim();
      if (!trimmed) return;
      const next = [...get(todosAtom), { id: Date.now(), title: trimmed }];
      set(todosAtom, next);
    });

    const removeTodoAtom = atom(null, (get, set, id) => {
      const next = get(todosAtom).filter((t) => t.id !== id);
      set(todosAtom, next);
    });

    const resetAllAtom = atom(null, (_get, set) => {
      set(nameAtom, "Guest");
      set(stepAtom, 1);
      set(countAtom, 0);
      set(todosAtom, []);
    });

    // ---------- Persistence (simple localStorage) ----------
    const PERSIST_KEYS = {
      name: "demo:name",
      step: "demo:step",
      count: "demo:count",
      todos: "demo:todos",
    };

    // hydrate on load
    try {
      const savedName = localStorage.getItem(PERSIST_KEYS.name);
      if (savedName !== null) store.set(nameAtom, savedName);

      const savedStep = localStorage.getItem(PERSIST_KEYS.step);
      if (savedStep !== null) store.set(stepAtom, parseInt(savedStep, 10) || 1);

      const savedCount = localStorage.getItem(PERSIST_KEYS.count);
      if (savedCount !== null) store.set(countAtom, parseInt(savedCount, 10) || 0);

      const savedTodos = localStorage.getItem(PERSIST_KEYS.todos);
      if (savedTodos !== null) store.set(todosAtom, JSON.parse(savedTodos) || []);
    } catch { /* ignore parse errors */ }

    // subscribe to persist on change
    store.sub(nameAtom, () => localStorage.setItem(PERSIST_KEYS.name, store.get(nameAtom)));
    store.sub(stepAtom, () => localStorage.setItem(PERSIST_KEYS.step, String(store.get(stepAtom))));
    store.sub(countAtom, () => localStorage.setItem(PERSIST_KEYS.count, String(store.get(countAtom))));
    store.sub(todosAtom, () => localStorage.setItem(PERSIST_KEYS.todos, JSON.stringify(store.get(todosAtom))));

    // ---------- DOM wiring ----------
    const $ = (id) => document.getElementById(id);

    const nameInput = $("name");
    const greetingEl = $("greeting");
    const stepInput = $("step");
    const countEl = $("count");
    const doubledEl = $("doubled");
    const incBtn = $("increment");
    const decBtn = $("decrement");
    const resetBtn = $("reset");
    const todoInput = $("todoInput");
    const addTodoBtn = $("addTodo");
    const todoList = $("todoList");

    // UI update functions
    const renderGreeting = () => { greetingEl.textContent = store.get(greetingAtom); };
    const renderStep = () => { stepInput.value = String(store.get(stepAtom)); };
    const renderCount = () => { countEl.textContent = String(store.get(countAtom)); };
    const renderDoubled = () => { doubledEl.textContent = String(store.get(doubledAtom)); };
    const renderNameField = () => { nameInput.value = store.get(nameAtom); };
    const renderTodos = () => {
      const todos = store.get(todosAtom);
      todoList.innerHTML = "";
      for (const t of todos) {
        const li = document.createElement("li");
        const span = document.createElement("span");
        span.textContent = t.title;
        const del = document.createElement("button");
        del.textContent = "Delete";
        del.addEventListener("click", () => store.set(removeTodoAtom, t.id));
        li.appendChild(span);
        li.appendChild(del);
        todoList.appendChild(li);
      }
    };

    // subscriptions
    store.sub(nameAtom, () => { renderGreeting(); renderNameField(); });
    store.sub(stepAtom, renderStep);
    store.sub(countAtom, renderCount);
    store.sub(doubledAtom, renderDoubled);
    store.sub(todosAtom, renderTodos);

    // events
    nameInput.addEventListener("input", (e) => store.set(nameAtom, e.target.value));
    stepInput.addEventListener("change", (e) => {
      const v = parseInt(e.target.value, 10);
      store.set(stepAtom, Number.isFinite(v) && v > 0 ? v : 1);
    });
    incBtn.addEventListener("click", () => store.set(incrementAtom));
    decBtn.addEventListener("click", () => store.set(decrementAtom));
    resetBtn.addEventListener("click", () => store.set(resetAllAtom));
    addTodoBtn.addEventListener("click", () => {
      store.set(addTodoAtom, todoInput.value);
      todoInput.value = "";
      todoInput.focus();
    });
    todoInput.addEventListener("keydown", (e) => {
      if (e.key === "Enter") {
        store.set(addTodoAtom, todoInput.value);
        todoInput.value = "";
      }
    });

    // initial render
    renderGreeting();
    renderNameField();
    renderStep();
    renderCount();
    renderDoubled();
    renderTodos();
  </script>
</body>
</html>
💻
Demo here.

What This Demonstrates

  • Multiple independent atoms for name, step, count, and todos keep concerns clean and composable.
  • Derived atoms (doubledAtom, greetingAtom) compute values from other atoms without duplicating logic.
  • Write-only action atoms (incrementAtom, decrementAtom, addTodoAtom, removeTodoAtom, resetAllAtom) encapsulate mutations in one place—keeping UI handlers thin.
  • Simple persistence ensures user state survives reloads without extra libraries.
  • Fine-grained subscriptions (store.sub) update exactly the UI parts that depend on each atom.

Additional Points and Considerations

  • Lock versions in CDN imports (important) to avoid unexpected breaks:
    https://esm.sh/[email protected]/vanilla
  • Prefer more atoms rather than one big object. It keeps dependencies explicit and re-renders minimal.
  • For larger apps, consider modularizing state by feature (profile atoms, counter atoms, todos atoms) and grouping action atoms per feature.
  • If you later adopt React (or Preact), the same mental model applies—your vanilla store and atoms can be reused.

Support Us