Jotai Counter: Atomic State Example
Jotai's atomic model invites a different coding pattern than Zustand. Instead of a single store with many methods, you create individual atoms and combine them in components. This article builds the same counter, but atom-first, showing how Jotai's composition style works.
I rebuilt a shopping-cart state layer from Zustand to Jotai in 2024 and found that the atomic approach made it easier to test individual pieces and reuse atoms across pages.
Step 1: Install Jotai
Install Jotai from npm:
npm install jotai
Jotai is similarly small and dependency-free. It works with React 16.8+ and has strong TypeScript support.
Step 2: Define Atoms
Create a file counterAtoms.ts and define atoms using the atom() function:
import { atom } from 'jotai';
export const countAtom = atom(0);
export const incrementAtom = atom(null, (get, set) => {
const current = get(countAtom);
set(countAtom, current + 1);
});
export const decrementAtom = atom(null, (get, set) => {
const current = get(countAtom);
set(countAtom, current - 1);
});
export const resetAtom = atom(null, (get, set) => {
set(countAtom, 0);
});
The atom(initialValue) form creates a read-write atom. The atom(initialValue, (get, set) => {}) form (write-only atom) defines a derived action atom. When you set an action atom, the write function runs, allowing you to perform complex mutations or side effects.
Step 3: Create a Derived Atom
Derived atoms read other atoms and compute new values. For example, create an atom that is double the count:
export const doubleCountAtom = atom((get) => {
const count = get(countAtom);
return count * 2;
});
Derived atoms are read-only. When countAtom changes, doubleCountAtom automatically recomputes. This is Jotai's composition model: atoms depend on each other, forming a dependency graph.
Step 4: Use Atoms in Components
Use the useAtom() hook to read and write atoms:
import { useAtom } from 'jotai';
import { countAtom, incrementAtom, decrementAtom, resetAtom } from './counterAtoms';
export function Counter() {
const [count] = useAtom(countAtom);
const [, increment] = useAtom(incrementAtom);
const [, decrement] = useAtom(decrementAtom);
const [, reset] = useAtom(resetAtom);
return (
<div>
<h1>Count: {count}</h1>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
<button onClick={reset}>Reset</button>
</div>
);
}
The useAtom() hook returns [value, setter], like useState(). For read-only derived atoms, you use only the first element. For action atoms, you use only the setter.
Step 5: Use Derived Atoms
In another component, read the derived atom:
import { useAtomValue } from 'jotai';
import { doubleCountAtom, countAtom } from './counterAtoms';
export function CountDisplay() {
const count = useAtomValue(countAtom);
const doubled = useAtomValue(doubleCountAtom);
return (
<div>
<p>Count: {count}</p>
<p>Double: {doubled}</p>
</div>
);
}
useAtomValue() reads an atom without the setter, similar to useState()[0]. When count changes, both count and doubled re-render in this component because they depend on the changed atom.
Step 6: Add Persistence
Jotai does not ship with a persistence middleware, but you can use the atomWithStorage() helper from jotai/utils:
import { atomWithStorage } from 'jotai/utils';
export const countAtom = atomWithStorage('count', 0);
Now, countAtom syncs to localStorage automatically. On page reload, the stored value restores.
Key Takeaways
- Atoms are individual pieces of state created with
atom(initialValue). - Derived atoms read other atoms using
atom((get) => ...)and compute new values. useAtom()returns[value, setter]for read-write atoms. UseuseAtomValue()for read-only.- Action atoms (write-only) group mutations together:
atom(null, (get, set) => {...}). - Jotai automatically batches updates to dependent atoms, reducing unnecessary re-renders.
atomWithStorage()syncs atoms to localStorage with one function call.
Frequently Asked Questions
Why would I use an action atom instead of a callback in the component?
Action atoms are reusable and testable in isolation. They also centralize logic, like all Zustand methods. If you have the same mutation in multiple components, an action atom avoids code duplication.
How do I handle async operations with Jotai atoms?
Use atom((get) => ...) to return a Promise, or use atomWithAsync() from jotai/utils for automatic loading states. Jotai also supports async effects for side effects like data fetching.
Can I use Jotai with TypeScript?
Yes. Type atoms with generics: atom<number>(0) or atom<Promise<User>>(fetchUser()). TypeScript infers types from initial values, but explicit generics are recommended for clarity.
Does Jotai re-render more or less frequently than Zustand?
Depends on your atoms. Fine-grained atoms (one atom per field) re-render more components but each component is smaller. Zustand with coarse selectors re-renders fewer components but each re-render does more work. For most apps, performance is equivalent.