ProvenPin Install

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
pinned versions10
jotai@3.0.1react@19.3.1react-dom@19.3.1@testing-library/react@16.3.3@testing-library/dom@10.4.2@testing-library/user-event@14.6.8happy-dom@21.0.1vitest@5.0.4@types/react@19.3.0@types/node@26.6.5
run it
npm install --no-audit --no-fund --ignore-scripts=false
npm test
test environment
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.

package.json
{
  "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"
  }
}
src/todos.tsx
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>;
}
test/todos.test.tsx
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]);
  });
});
vitest.config.ts
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.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.