Counter store
The three state patterns a Redux app reaches for, written with kerf’s defineStore: a counter with sync actions, a fetch with loading and error states, and persistence across reloads. The Redux migration guide compares each one with its Redux Toolkit equivalent.
What to look at:
- Sync actions.
increment,decrement, andresetare plain functions that callset(), the equivalent of a Redux Toolkit slice’s reducers. There are no action types or dispatch. - An async action with loading and error states.
fetchsetsloading, awaits a mock request, then sets either the data or the error. That is the whole thunk: anasyncfunction insideactions. Fetch (fails) exercises the error branch. - Persistence through
effect(). One effect writes the counter’s state tolocalStorageon every change, and the store’sinitialreads it back. That replaces redux-persist. Reload the page and the count survives. - Two independent mounts. The counter and the fetch panel each have their own
mount(), so a fetch state change never re-renders the counter. - Page-lifetime
delegate()calls. The listeners sit ondocument.bodyfor the life of the page, so the app discards their disposers with thevoidsigil. Compare the htmx cart island, whose transient root captures them.
// site/src/examples/complete/counter-store/main.tsx (excerpt — full source on GitHub)import { attr, defineStore, delegate, effect, mount, type AttrSpec,} from "kerfjs";
const ACTIONS = { inc: attr("data-action", "inc"), reset: attr("data-action", "reset"),} as const satisfies Record<string, AttrSpec<"data-action">>;
const counter = defineStore({ initial: loadCounter, // reads localStorage, falls back to { count: 0, … } actions: (set, get) => ({ increment: () => set({ count: get().count + 1, lastBumpedAt: new Date().toISOString() }), reset: () => set({ count: 0, lastBumpedAt: null }), }),});
// Persist on every change — the redux-persist equivalent.effect(() => { localStorage.setItem(STORAGE_KEY, JSON.stringify(counter.state.value));});
const remote = defineStore({ initial: (): FetchState => ({ data: null, loading: false, error: null }), actions: (set) => ({ fetch: async (succeed: boolean): Promise<void> => { set({ data: null, loading: true, error: null }); try { set({ data: await mockFetch(succeed), loading: false, error: null }); } catch (e: unknown) { set({ data: null, loading: false, error: String(e) }); } }, }),});
mount(counterRoot, () => ( <div data-counter> <div class="count">{counter.state.value.count}</div> <button {...ACTIONS.inc.attrs}>+1</button> </div>));
// Page-lifetime listener: the `void` sigil marks the discarded disposer.void delegate(document.body, "click", ACTIONS.inc.selector, () => counter.actions.increment(),);