Tutorial , 4. State with Mimir
Time for state. We will move notes into a Mimir atom (so the list updates live when we add one), add a
selector for the count, and make the theme toggle real, persisted so it survives a reload.
The notes atom
0000000000// atoms/notes.ts
import { atom, selector } from "ekko:rune/mimir";
import type { Note } from "../lib/notes";
import { seedNotes } from "../lib/notes";
export const notesAtom = atom<Note[]>({ key: "notes:list", default: seedNotes });
export const noteCount = selector({ key: "notes:count", get: ({ get }) => get(notesAtom).length });
Read the atom in the list
0000000000// pages/index.tsx
import { Link } from "ekko:rune/router";
import { useAtomValue } from "ekko:rune/mimir";
import { notesAtom, noteCount } from "../atoms/notes";
export function ssr() { return { title: "Notes" }; }
export default function Home() {
const notes = useAtomValue(notesAtom); const count = useAtomValue(noteCount); <h1>Notes <small>({count})</small></h1> <ul className="note-list"> {notes.map(n => <li key={n.id}><Link href={`/notes/${n.id}`}>{n.title}</Link></li>)} }
Add pages/notes/new.tsx. Note this is a static route (/notes/new) and beats the dynamic /notes/:id
because static has higher priority (see File-based routing).
0000000000// pages/notes/new.tsx
import { useState } from "@ekko/react";
import { useSetAtom } from "ekko:rune/mimir";
import { useRouter } from "ekko:rune/router";
import { notesAtom } from "../../atoms/notes";
export function ssr() { return { title: "New note" }; }
export default function NewNote() {
const setNotes = useSetAtom(notesAtom); const { navigate } = useRouter(); const [title, setTitle] = useState(""); const [body, setBody] = useState(""); const id = title.toLowerCase().replace(/\s+/g, "-") || String(Date.now()); setNotes(notes => [...notes, { id, title: title || "Untitled", body }]); // new array → notifies navigate(`/notes/${id}`); <input placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} /> <textarea placeholder="Body" value={body} onChange={e => setBody(e.target.value)} /> <button onClick={save}>Save</button> }
Register it:
0000000000import * as NewNote from "./pages/notes/new";
const modules: any = { "/": Home, "/notes/new": NewNote, "/notes/:id": NotePage };
Now: create a note → the list shows it and the count selector updates, because both read notesAtom,
and the new array notifies subscribers. We never lifted state or passed props; the form and the list just
share the atom.
We used useSetAtom in the form (it only writes) and useAtomValue in the list (it only reads). Reach for
useAtom when a component does both. See Reading & writing.
Make the theme toggle real
0000000000// atoms/theme.ts
import { atom } from "ekko:rune/mimir";
export const themeAtom = atom({ key: "site-theme", default: "dark" });
0000000000// components/ThemeToggle.tsx
import { useAtom } from "ekko:rune/mimir";
import { themeAtom } from "../atoms/theme";
export default function ThemeToggle() {
const [theme, setTheme] = useAtom(themeAtom); <button className="theme-btn" onClick={() => setTheme(t => t === "dark" ? "light" : "dark")}> {theme === "dark" ? "🌙" : "☀️"} }
We also need to put the .dark class on <html> so the SCSS variables flip. Add a tiny bridge the layout
renders:
0000000000// components/ThemeBridge.tsx
import { useEffect } from "@ekko/react";
import { mimir } from "ekko:rune/mimir";
import { themeAtom } from "../atoms/theme";
export default function ThemeBridge() {
mimir.session("domain"); // persist the theme across reloads const apply = (v: string) => document.documentElement.classList.toggle("dark", v !== "light"); apply(mimir.get(themeAtom)); return mimir.subscribe(themeAtom, apply); }
Render <ThemeBridge /> once in the layout. Now toggling flips the theme, and mimir.session("domain")
persists it: reload the page and your choice sticks.
What just happened
- State lives in atoms, outside the component tree, so the list, the form, and the count stay in sync and
survive navigation.
- A selector (
noteCount) derives from notesAtom and recomputes only when the notes change. - A session persists the theme to IndexedDB, surviving an F5.
There is still a flash of the wrong theme on first paint; we fix that with ssr() seeding + a no-FOUC script
next.
Next: server rendering and seeding state, 5. SSR and data.