Jotai atoms with React 19, tested with Testing Library and isolated stores: working versions (tested 2026-10-15)
Jotai 3 primitive, derived, write-only and async atoms driving a React 19 todo UI with Suspense, tested in Vitest on happy-dom with @testing-library/react, user-event and a fresh createStore per test, plus store-only tests without React.
passed5 passed · 0 failed2026-10-15 14:14 UTC- Status
- passed
- Last passed
- 2026-10-15 14:14 UTC
- Tests
- 5 / 5
- Language
- TypeScript
- Runtime
- node v22.23.3
npm install --no-audit --no-fund --ignore-scripts=false npm test
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38748988915
The code
Exactly the files that passed. Your agent can pull them with get_recipe("jotai-atoms-react-testing-library") (install), or see them on GitHub.
{
"name": "jotai-atoms-react-testing-library",
"private": true,
"type": "module",
"scripts": {
"test": "vitest run --reporter=default --reporter=json --outputFile=test-report.json"
},
"dependencies": {
"jotai": "3.0.1",
"react": "19.3.1",
"react-dom": "19.3.1"
},
"devDependencies": {
"@testing-library/react": "16.3.3",
"@testing-library/dom": "10.4.2",
"@testing-library/user-event": "14.6.8",
"happy-dom": "21.0.1",
"vitest": "5.0.4",
"@types/react": "19.3.0",
"@types/node": "26.6.5"
}
}
import { Suspense } from "react";
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
export type Todo = { id: number; title: string; done: boolean };
export type Filter = "all" | "open" | "done";
// Primitive atoms hold state; derived atoms compute from them; write-only atoms are actions.
export const todosAtom = atom<Todo[]>([]);
export const filterAtom = atom<Filter>("all");
export const visibleTodosAtom = atom((get) => {
const f = get(filterAtom);
return get(todosAtom).filter((t) => (f === "all" ? true : f === "done" ? t.done : !t.done));
});
export const statsAtom = atom((get) => {
const all = get(todosAtom);
return { total: all.length, done: all.filter((t) => t.done).length };
});
export const addTodoAtom = atom(null, (get, set, title: string) => {
const list = get(todosAtom);
set(todosAtom, [...list, { id: list.length + 1, title, done: false }]);
});
export const toggleTodoAtom = atom(null, (get, set, id: number) => {
set(todosAtom, get(todosAtom).map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
});
/** Async derived atom: components reading it suspend until the promise resolves. */
export const userIdAtom = atom(1);
export const makeUserAtom = (fetchUser: (id: number) => Promise<{ name: string }>) => atom((get) => fetchUser(get(userIdAtom)));
export function TodoApp() {
const [filter, setFilter] = useAtom(filterAtom);
const visible = useAtomValue(visibleTodosAtom);
const stats = useAtomValue(statsAtom);
const add = useSetAtom(addTodoAtom);
const toggle = useSetAtom(toggleTodoAtom);
return (
<div>
<form onSubmit={(e) => { e.preventDefault(); const input = e.currentTarget.elements.namedItem("title") as HTMLInputElement; if (input.value.trim()) add(input.value.trim()); input.value = ""; }}>
<input name="title" aria-label="new todo" />
<button type="submit">Add</button>
</form>
<select aria-label="filter" value={filter} onChange={(e) => setFilter(e.target.value as Filter)}>
<option value="all">All</option><option value="open">Open</option><option value="done">Done</option>
</select>
<ul>{visible.map((t) => <li key={t.id}><label><input type="checkbox" checked={t.done} onChange={() => toggle(t.id)} />{t.title}</label></li>)}</ul>
<p data-testid="stats">{stats.done}/{stats.total} done</p>
</div>
);
}
export function UserName({ userAtom }: { userAtom: ReturnType<typeof makeUserAtom> }) {
const user = useAtomValue(userAtom);
return <h2>{user.name}</h2>;
}
export function UserPanel({ userAtom }: { userAtom: ReturnType<typeof makeUserAtom> }) {
return <Suspense fallback={<p>Loading user</p>}><UserName userAtom={userAtom} /></Suspense>;
}
import { describe, it, expect, afterEach } from "vitest";
import { render, screen, cleanup, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Provider, createStore } from "jotai";
import { TodoApp, UserPanel, todosAtom, filterAtom, visibleTodosAtom, statsAtom, addTodoAtom, userIdAtom, makeUserAtom } from "../src/todos.tsx";
afterEach(cleanup);
describe("atoms without React", () => {
it("derived atoms recompute from a vanilla store", () => {
const store = createStore();
store.set(addTodoAtom, "write");
store.set(addTodoAtom, "test");
store.set(todosAtom, (list) => list.map((t) => (t.id === 2 ? { ...t, done: true } : t)));
store.set(filterAtom, "open");
expect(store.get(visibleTodosAtom).map((t) => t.title)).toEqual(["write"]);
expect(store.get(statsAtom)).toEqual({ total: 2, done: 1 });
});
it("store.sub notifies on changes to a derived atom", () => {
const store = createStore();
const seen: number[] = [];
const unsub = store.sub(statsAtom, () => seen.push(store.get(statsAtom).total));
store.set(addTodoAtom, "a");
store.set(addTodoAtom, "b");
unsub();
store.set(addTodoAtom, "c");
expect(seen).toEqual([1, 2]);
});
});
describe("components", () => {
it("adds, toggles and filters through the UI with an isolated Provider store", async () => {
const user = userEvent.setup();
const store = createStore();
render(<Provider store={store}><TodoApp /></Provider>);
await user.type(screen.getByLabelText("new todo"), "ship recipe{Enter}");
await user.type(screen.getByLabelText("new todo"), "wait for CI{Enter}");
expect(screen.getByTestId("stats").textContent).toBe("0/2 done");
await user.click(screen.getByLabelText("ship recipe"));
expect(screen.getByTestId("stats").textContent).toBe("1/2 done");
await user.selectOptions(screen.getByLabelText("filter"), "done");
expect(screen.getAllByRole("listitem").map((li) => li.textContent)).toEqual(["ship recipe"]);
expect(store.get(todosAtom)).toHaveLength(2); // the test can read the same store directly
});
it("can be seeded from outside and reacts to external store writes", async () => {
const store = createStore();
store.set(todosAtom, [{ id: 1, title: "seeded", done: true }]);
render(<Provider store={store}><TodoApp /></Provider>);
expect(screen.getByTestId("stats").textContent).toBe("1/1 done");
act(() => store.set(addTodoAtom, "from outside"));
expect(screen.getByTestId("stats").textContent).toBe("1/2 done");
});
it("async atoms suspend, then refetch when a dependency changes", async () => {
// A controllable fetch, so the test decides when each request settles.
const pending = new Map<number, (u: { name: string }) => void>();
const userAtom = makeUserAtom((id) => new Promise((resolve) => pending.set(id, resolve)));
const store = createStore();
// Under Testing Library (IS_REACT_ACT_ENVIRONMENT) the resumed Suspense boundary commits
// reliably only when the render and each resolve happen inside an async act.
await act(async () => { render(<Provider store={store}><UserPanel userAtom={userAtom} /></Provider>); });
expect(screen.getByText("Loading user")).toBeTruthy();
await act(async () => { pending.get(1)!({ name: "Ada" }); });
expect(screen.getByRole("heading", { name: "Ada" })).toBeTruthy();
await act(async () => { store.set(userIdAtom, 2); });
await act(async () => { pending.get(2)!({ name: "Grace" }); });
expect(screen.getByRole("heading", { name: "Grace" })).toBeTruthy();
expect([...pending.keys()]).toEqual([1, 2]);
});
});
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { environment: "happy-dom" },
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
TanStack Query + React Testing Library hooks and components (Vitest + jsdom)
Test React components and hooks that use TanStack Query v5 with Testing Library on jsdom: fresh QueryClient per test, retries off, mocked fetchers, mutations with invalidation.
Vanilla DOM form testing with Testing Library + user-event on happy-dom (Vitest)
Test framework-free DOM code (a validated signup form) the way users use it, with @testing-library/dom queries and user-event typing on happy-dom.
Astro 7 component rendering tests with the Container API (Vitest)
Render Astro components to HTML in Vitest with the Container API and getViteConfig, checking props, slots and conditional markup.
AWS SDK v3 DynamoDB document client and SQS events, tested with Vitest matchers
A single-table DynamoDB order service on @aws-sdk/lib-dynamodb with conditional puts, begins_with queries and optimistic locking, publishing SQS events, tested offline with aws-sdk-client-mock and aws-sdk-client-mock-vitest matchers.
All jotai recipesAll react recipesAll @testing-library/react recipesAll happy-dom recipesAll vitest recipes