Jotai Async: Effects and Atom Effects
Jotai handles async differently than Zustand. Atoms can be Promises, and Jotai integrates with React Suspense natively. This is where Jotai shines in Next.js 13+ apps with server components. This article covers async atoms, effects, and how to model loading and error states atomically.
I built a real-time notification system in Jotai in 2025 and found that atom effects (Jotai's equivalent of side effects) kept the code remarkably clean and testable.
Pattern 1: Async Atoms Returning Promises
Define an atom that returns a Promise:
import { atom } from 'jotai';
export const usersAtom = atom(async () => {
const response = await fetch('/api/users');
return response.json();
});
When a component uses useAtom(usersAtom) or useAtomValue(usersAtom), Jotai suspends the component (throws the Promise) until the data loads. You must wrap the component in a Suspense boundary:
import { Suspense } from 'react';
import { useAtomValue } from 'jotai';
import { usersAtom } from './userAtoms';
export function UserListContent() {
const users = useAtomValue(usersAtom);
return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}
export function UserList() {
return (
<Suspense fallback={<p>Loading users...</p>}>
<UserListContent />
</Suspense>
);
}
This is cleaner than managing loading and error state manually. The Suspense boundary shows the fallback until the atom resolves.
Pattern 2: Atom Effects for Side Effects
The atomWithDefault() and atomEffect() utilities let you set up side effects when atoms mount or change:
import { atom } from 'jotai';
import { atomEffect } from 'jotai/utils';
export const countAtom = atom(0);
export const countEffectAtom = atomEffect((get, set) => {
const count = get(countAtom);
// Side effect: log when count changes
console.log('Count is now:', count);
// Optional: return cleanup function
return () => {
console.log('Count effect cleanup');
};
});
Effects run when atoms change or components mount/unmount. This is similar to useEffect() but in the atom layer. Use effects for logging, persisting, or subscriptions.
Pattern 3: Data Fetching with atomEffect
For data fetching, combine async atoms with effects to handle errors and refetching:
import { atom } from 'jotai';
export const userIdAtom = atom(1);
export const userAtom = atom(async (get) => {
const userId = get(userIdAtom);
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
return response.json();
});
export const userWithErrorAtom = atom(
async (get) => {
try {
return { data: await get(userAtom), error: null };
} catch (err) {
return { data: null, error: err.message };
}
}
);
Derive an atom that catches errors. When userIdAtom changes, userAtom re-fetches automatically. userWithErrorAtom wraps the fetch result and error state.
Pattern 4: Conditional Fetching
Sometimes you want to fetch only if a condition is true:
import { atom } from 'jotai';
export const fetchEnabledAtom = atom(true);
export const userIdAtom = atom(null);
export const userAtom = atom(async (get) => {
const enabled = get(fetchEnabledAtom);
const userId = get(userIdAtom);
if (!enabled || !userId) return null;
const response = await fetch(`/api/users/${userId}`);
return response.json();
});
If enabled is false or userId is null, the atom returns null without fetching. This is useful for conditional data loading.
Pattern 5: Debounced Async Atoms
For search inputs, debounce the fetch to avoid too many requests:
import { atom } from 'jotai';
import { atomWithDefault } from 'jotai/utils';
export const searchQueryAtom = atom('');
export const searchResultsAtom = atom(async (get) => {
const query = get(searchQueryAtom);
if (!query) return [];
// In real code, debounce this externally or use AbortController
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
return response.json();
});
For true debouncing, wrap the atom in middleware or use useTransition() in React 18+ to delay updates.
Key Takeaways
- Async atoms return Promises and work with Suspense for elegant loading states.
- Wrap async atoms in
Suspenseboundaries; show a fallback while data loads. - Atom effects run side effects (logging, subscriptions) when atoms change, with cleanup support.
- Derived atoms can wrap async atoms to add error handling.
- Jotai automatically re-fetches when dependent atoms change (e.g., if
userIdAtomchanges,userAtomre-fetches).
Frequently Asked Questions
Should I use Jotai atoms or React Query for data fetching?
Jotai atoms are lightweight for simple fetches. React Query is better for caching, refetching policies, and complex server state. For dashboards with many queries, use React Query. For a few async atoms, Jotai is simpler.
How do I handle errors with async atoms?
Wrap the atom in a derived atom that catches errors (see Pattern 3 above), or let the error bubble to an Error Boundary.
Can I cancel a fetch when a component unmounts?
Yes, use AbortController: create a signal, pass it to fetch(), and call signal.abort() in the cleanup function of an effect.
Does Suspense work with all async atoms?
Yes. Any atom that returns a Promise integrates with Suspense. Make sure every component reading that atom is wrapped in a Suspense boundary.