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.
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.
Comments ()