Jotai 2 — Atomic State Management for React and Next.js: From Zero to Production

A primitive, bottom-up approach to React state. Jotai treats state as a collection of atoms — small, independent units that compose together to form your application state. No reducers, no providers by default, no selector boilerplate. In this tutorial, you will build a real-world shopping cart with Next.js 15 App Router, learning every Jotai pattern you need for production.
What You Will Learn
By the end of this tutorial, you will:
- Set up Jotai 2 in a Next.js 15 App Router project with TypeScript
- Create primitive atoms, derived atoms, and write-only atoms
- Handle async atoms with Suspense and the
loadableutility - Persist state to
localStoragewithatomWithStorage - Hydrate server-rendered state safely with
useHydrateAtoms - Build atom families for dynamic collections
- Debug with Jotai DevTools
- Ship a production shopping cart with a real atomic architecture
Prerequisites
Before starting, ensure you have:
- Node.js 20+ installed (
node --version) - TypeScript experience (types, generics, hooks)
- Familiarity with Next.js App Router basics (layouts, pages, server/client components)
- Basic understanding of React state (
useState,useContext)
Why Jotai in 2026?
React state management has evolved into three broad camps: store-based (Redux, Zustand), atomic (Jotai, Recoil), and signal-based (Preact Signals, Solid). Jotai is the most popular atomic option and pairs beautifully with React 19 and Next.js 15.
| Feature | Jotai | Zustand | Redux Toolkit | Context API |
|---|---|---|---|---|
| Bundle size | ~3 KB | ~1 KB | ~12 KB | 0 KB (built-in) |
| Mental model | Atomic (bottom-up) | Store (top-down) | Store + reducers | Tree-based |
| Boilerplate | Minimal | Minimal | Moderate | High at scale |
| TypeScript DX | Excellent | Excellent | Good | Manual |
| Async primitives | First-class | Manual | Via thunks/sagas | Manual |
| SSR support | Built-in | Built-in | Extra setup | Built-in |
| Re-render control | Per atom (automatic) | Selector-based | Selector-based | Whole tree |
The atomic model shines when your state graph has many independent slices that derive from each other. Change one atom and only the components that actually read it re-render — no selectors, no memoization gymnastics.
Step 1: Project Setup
Create a new Next.js 15 project with TypeScript and Tailwind:
npx create-next-app@latest jotai-cart --typescript --tailwind --app --src-dir
cd jotai-cartInstall Jotai and its DevTools extension:
npm install jotai
npm install -D jotai-devtoolsYour structure will look like this:
jotai-cart/
├── src/
│ ├── app/
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── atoms/ # Jotai atoms live here
│ ├── components/
│ └── lib/
├── package.json
└── tsconfig.json
Create the atoms/ folder — atoms will live there, organized by domain.
mkdir -p src/atoms src/componentsStep 2: Your First Atom
The building block of Jotai is the atom. Think of an atom as a piece of state that any component can read and write, without a provider boilerplate.
Create src/atoms/counter.ts:
import { atom } from "jotai";
export const countAtom = atom(0);That is it — no store, no slice, no reducer. Use it in a component:
// src/components/Counter.tsx
"use client";
import { useAtom } from "jotai";
import { countAtom } from "@/atoms/counter";
export function Counter() {
const [count, setCount] = useAtom(countAtom);
return (
<div className="flex items-center gap-3">
<button
onClick={() => setCount((c) => c - 1)}
className="rounded bg-slate-200 px-3 py-1"
>
-
</button>
<span className="text-xl font-semibold">{count}</span>
<button
onClick={() => setCount((c) => c + 1)}
className="rounded bg-slate-200 px-3 py-1"
>
+
</button>
</div>
);
}Then drop <Counter /> into a page. useAtom returns the same tuple shape as useState, but the value is shared across the entire app.
Important — "use client". Jotai hooks (useAtom, useSetAtom, useAtomValue) only work in client components. Add "use client" at the top of any component file that uses them.
Read-only and write-only variants
If a component only reads, use useAtomValue to skip the setter. If it only writes, use useSetAtom to skip re-rendering when the value changes.
import { useAtomValue, useSetAtom } from "jotai";
import { countAtom } from "@/atoms/counter";
function Display() {
const count = useAtomValue(countAtom);
return <span>{count}</span>;
}
function IncrementButton() {
const setCount = useSetAtom(countAtom);
return <button onClick={() => setCount((c) => c + 1)}>+1</button>;
}IncrementButton never re-renders when count changes, even though it writes to the same atom. That is automatic — no memoization needed.
Step 3: Derived Atoms
A derived atom reads from other atoms and recomputes whenever any of them change. This is the heart of atomic state — you declare relationships, and Jotai keeps them in sync.
Create src/atoms/cart.ts:
import { atom } from "jotai";
export type CartItem = {
id: string;
name: string;
price: number;
quantity: number;
};
export const cartItemsAtom = atom<CartItem[]>([]);
export const cartTotalAtom = atom((get) => {
const items = get(cartItemsAtom);
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
});
export const cartCountAtom = atom((get) => {
const items = get(cartItemsAtom);
return items.reduce((sum, item) => sum + item.quantity, 0);
});cartTotalAtom and cartCountAtom are read-only derived atoms. They depend on cartItemsAtom, so whenever items change, totals update — and only components reading those derived atoms re-render.
Writable derived atoms
You can also create atoms that transform writes. The second argument to atom is a write function:
export const addItemAtom = atom(
null, // no read value — write-only
(get, set, newItem: CartItem) => {
const items = get(cartItemsAtom);
const existing = items.find((i) => i.id === newItem.id);
if (existing) {
set(
cartItemsAtom,
items.map((i) =>
i.id === newItem.id
? { ...i, quantity: i.quantity + newItem.quantity }
: i,
),
);
} else {
set(cartItemsAtom, [...items, newItem]);
}
},
);
export const removeItemAtom = atom(null, (get, set, id: string) => {
set(
cartItemsAtom,
get(cartItemsAtom).filter((i) => i.id !== id),
);
});Action atoms like these keep your components free of business logic. A button just calls setAddItem(newItem) without knowing the rules.
"use client";
import { useSetAtom } from "jotai";
import { addItemAtom } from "@/atoms/cart";
export function AddToCart({ item }: { item: CartItem }) {
const addItem = useSetAtom(addItemAtom);
return (
<button onClick={() => addItem({ ...item, quantity: 1 })}>
Add to cart
</button>
);
}Step 4: Persistent State with atomWithStorage
Real carts survive refreshes. Jotai ships a atomWithStorage utility that syncs an atom with localStorage, sessionStorage, or any custom storage:
import { atomWithStorage } from "jotai/utils";
import type { CartItem } from "./cart";
export const persistedCartAtom = atomWithStorage<CartItem[]>(
"noqta-cart-v1",
[],
);Replace cartItemsAtom with persistedCartAtom where you want persistence. The API is identical — it is a drop-in upgrade.
Guarding against SSR hydration mismatches
On the server, localStorage does not exist. atomWithStorage returns the initial value on the server and hydrates the real value on the client. To avoid the flash-of-default-content pattern, wait for mount:
"use client";
import { useEffect, useState } from "react";
import { useAtomValue } from "jotai";
import { persistedCartAtom } from "@/atoms/cart";
export function CartBadge() {
const [mounted, setMounted] = useState(false);
const items = useAtomValue(persistedCartAtom);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return <span className="badge">{items.length}</span>;
}Tip. You can also use the getOnInit: true option so atomWithStorage reads storage synchronously during hydration. That avoids a flash, but only works safely in fully client-rendered pages.
Step 5: Async Atoms for Data Fetching
Jotai atoms can be async. The read function may return a Promise, and Jotai integrates with React Suspense to wait on it.
import { atom } from "jotai";
export type Product = {
id: string;
name: string;
price: number;
};
export const productsAtom = atom(async (): Promise<Product[]> => {
const res = await fetch("/api/products");
if (!res.ok) throw new Error("Failed to load products");
return res.json();
});Read it with useAtomValue — the value is already unwrapped:
"use client";
import { Suspense } from "react";
import { useAtomValue } from "jotai";
import { productsAtom } from "@/atoms/products";
function ProductList() {
const products = useAtomValue(productsAtom);
return (
<ul>
{products.map((p) => (
<li key={p.id}>
{p.name} — ${p.price}
</li>
))}
</ul>
);
}
export function ProductsPage() {
return (
<Suspense fallback={<p>Loading products...</p>}>
<ProductList />
</Suspense>
);
}Skipping Suspense with loadable
If you prefer granular loading and error states instead of Suspense, wrap the atom with loadable:
import { loadable } from "jotai/utils";
export const productsLoadableAtom = loadable(productsAtom);const value = useAtomValue(productsLoadableAtom);
if (value.state === "loading") return <Skeleton />;
if (value.state === "hasError") return <Error error={value.error} />;
return <ProductList data={value.data} />;loadable is ideal for dashboards where you want independent loaders per widget, not a single fallback for the whole page.
Step 6: Atom Families for Dynamic Collections
Atom families create atoms on demand, keyed by a parameter. They are perfect for "one atom per item" patterns — think a todo list where each todo has its own atom.
import { atomFamily } from "jotai/utils";
import { atom } from "jotai";
export type Todo = { id: string; text: string; done: boolean };
export const todoAtomFamily = atomFamily((id: string) =>
atom<Todo>({ id, text: "", done: false }),
);Use it inside a component by calling the family with an id:
function TodoItem({ id }: { id: string }) {
const [todo, setTodo] = useAtom(todoAtomFamily(id));
return (
<label>
<input
type="checkbox"
checked={todo.done}
onChange={(e) => setTodo({ ...todo, done: e.target.checked })}
/>
{todo.text}
</label>
);
}Updating one todo re-renders only that TodoItem — sibling items do not flicker. That is atomic granularity in action.
Step 7: SSR Hydration in Next.js App Router
Next.js 15 renders pages on the server. To seed atoms with server data, use useHydrateAtoms from jotai/utils. Wrap the initial values in a client component that runs once, then render children.
Create src/components/AtomsHydrator.tsx:
"use client";
import { useHydrateAtoms } from "jotai/utils";
import type { ReactNode } from "react";
import type { Product } from "@/atoms/products";
import { productsAtom } from "@/atoms/products";
type HydrateProps = {
products: Product[];
children: ReactNode;
};
export function AtomsHydrator({ products, children }: HydrateProps) {
useHydrateAtoms([[productsAtom, products]]);
return <>{children}</>;
}Fetch on the server, then pass the data in:
// src/app/page.tsx (server component)
import { AtomsHydrator } from "@/components/AtomsHydrator";
import { ProductList } from "@/components/ProductList";
async function getProducts() {
const res = await fetch("https://api.noqta.tn/products", {
next: { revalidate: 60 },
});
return res.json();
}
export default async function Page() {
const products = await getProducts();
return (
<AtomsHydrator products={products}>
<ProductList />
</AtomsHydrator>
);
}When the client boots, productsAtom already contains the server data — no loading flash, no refetch.
Why not just a Provider with initialValues? In Jotai v1 you could pass initialValues to <Provider>. In Jotai 2 the API is useHydrateAtoms. It is a single hook that works whether or not you use a Provider.
Provider scoping for multi-tenant state
Most apps do not need a <Provider>. The default scope is global, which is usually what you want. Reach for a Provider only when you need isolated atom instances — for example, a modal that should not leak state to siblings, or multi-tenant dashboards where different panes hold different users.
import { Provider } from "jotai";
export function TenantScope({ children }: { children: ReactNode }) {
return <Provider>{children}</Provider>;
}Anything inside TenantScope reads its own copy of every atom used within, isolated from the rest of the tree.
Step 8: Debugging with Jotai DevTools
Install was done in Step 1. Mount the DevTools component only in development:
// src/components/DevTools.tsx
"use client";
import { DevTools as JotaiDevTools } from "jotai-devtools";
import "jotai-devtools/styles.css";
export function DevTools() {
if (process.env.NODE_ENV !== "development") return null;
return <JotaiDevTools theme="dark" />;
}Render it at the top of your root layout. You get a panel showing every live atom, its value, dependencies, and a time-travel history slider.
Step 9: Putting It All Together — The Shopping Cart
Create src/atoms/shop.ts:
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
export type Product = { id: string; name: string; price: number };
export type CartLine = { productId: string; quantity: number };
export const productsAtom = atom<Product[]>([]);
export const cartLinesAtom = atomWithStorage<CartLine[]>("noqta-cart", []);
export const cartDetailsAtom = atom((get) => {
const products = get(productsAtom);
const lines = get(cartLinesAtom);
return lines
.map((line) => {
const product = products.find((p) => p.id === line.productId);
if (!product) return null;
return {
...product,
quantity: line.quantity,
subtotal: product.price * line.quantity,
};
})
.filter(Boolean);
});
export const cartTotalAtom = atom((get) =>
get(cartDetailsAtom).reduce((sum, l) => sum + (l?.subtotal ?? 0), 0),
);
export const addToCartAtom = atom(
null,
(get, set, productId: string, quantity = 1) => {
const lines = get(cartLinesAtom);
const existing = lines.find((l) => l.productId === productId);
if (existing) {
set(
cartLinesAtom,
lines.map((l) =>
l.productId === productId
? { ...l, quantity: l.quantity + quantity }
: l,
),
);
} else {
set(cartLinesAtom, [...lines, { productId, quantity }]);
}
},
);
export const removeFromCartAtom = atom(null, (get, set, productId: string) => {
set(
cartLinesAtom,
get(cartLinesAtom).filter((l) => l.productId !== productId),
);
});And the cart summary component:
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import {
cartDetailsAtom,
cartTotalAtom,
removeFromCartAtom,
} from "@/atoms/shop";
export function CartSummary() {
const items = useAtomValue(cartDetailsAtom);
const total = useAtomValue(cartTotalAtom);
const remove = useSetAtom(removeFromCartAtom);
if (items.length === 0) return <p>Your cart is empty.</p>;
return (
<div className="space-y-3">
{items.map((item) =>
item ? (
<div
key={item.id}
className="flex items-center justify-between border-b py-2"
>
<div>
<p className="font-medium">{item.name}</p>
<p className="text-sm text-slate-500">
{item.quantity} x ${item.price}
</p>
</div>
<div className="flex items-center gap-3">
<span className="font-semibold">${item.subtotal}</span>
<button
onClick={() => remove(item.id)}
className="text-red-500 hover:underline"
>
Remove
</button>
</div>
</div>
) : null,
)}
<div className="flex justify-between pt-2 text-lg font-bold">
<span>Total</span>
<span>${total}</span>
</div>
</div>
);
}Three atoms, two actions, zero Redux boilerplate. Each component subscribes only to the atoms it reads — add a line and only the cart summary re-renders, not the product grid.
Testing Your Implementation
Jotai atoms are trivially testable because atoms are plain values. Use the createStore helper to get an isolated store per test:
import { createStore } from "jotai";
import { cartLinesAtom, addToCartAtom, cartTotalAtom } from "@/atoms/shop";
test("adds items and computes total", () => {
const store = createStore();
store.set(addToCartAtom, "p1", 2);
store.set(addToCartAtom, "p1", 1);
expect(store.get(cartLinesAtom)).toEqual([
{ productId: "p1", quantity: 3 },
]);
});Pair this with Vitest or Jest and you can cover every atom interaction without mounting React.
Troubleshooting
Components re-render more than expected. You are probably destructuring an object atom. Split large atoms into smaller ones, or use selectAtom to subscribe to a slice.
Hydration mismatch with atomWithStorage. Gate on a mounted flag inside client components, or render the storage-backed atom only after first mount.
"Cannot find module 'jotai/utils'". TypeScript sometimes needs an explicit path map. Ensure your tsconfig.json has "moduleResolution": "bundler" (Next.js 15 sets this by default).
Async atom throws during SSR. Wrap the consumer in <Suspense>, or switch to loadable so the state becomes checkable instead of suspended.
DevTools panel does not appear. Confirm you imported jotai-devtools/styles.css and that process.env.NODE_ENV equals "development" at runtime.
Next Steps
- Explore jotai-effect for global side effects tied to atom lifecycles
- Pair Jotai with TanStack Query using
jotai-tanstack-queryfor cached server state - Compare architectures by reading our Zustand + Next.js tutorial
- Learn about React Server Components patterns in our Next.js 15 PPR guide
Conclusion
Jotai is deceptively simple: a single atom primitive that composes into entire application architectures. The atomic model gives you automatic fine-grained reactivity, near-zero boilerplate, and a TypeScript story that is a joy to work with. When your React app outgrows useState and you want a state library that feels like native React, Jotai is the most ergonomic option in 2026.
You now have the complete toolkit: primitive atoms, derived atoms, writable actions, persistence, async data, SSR hydration, atom families, and devtools. Ship something with it.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Zustand + Next.js App Router: Modern React State Management from Zero to Production
Master modern React state management with Zustand and Next.js 15 App Router. This hands-on tutorial covers store creation, middleware, persistence, server-side hydration, and real-world patterns for scalable applications.

Build a Full-Stack CRUD App with MongoDB, Mongoose, and Next.js 15
Learn how to build a production-ready full-stack application with MongoDB Atlas, Mongoose ODM, and Next.js 15 App Router. This tutorial covers schema design, Server Actions, CRUD operations, validation, and deployment.

Neon Serverless Postgres with Next.js App Router: Build a Full-Stack App with Database Branching
Learn how to build a full-stack Next.js application powered by Neon serverless Postgres. This tutorial covers the Neon serverless driver, database branching for preview deployments, connection pooling, and production-ready patterns.