Skip to main content

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 Suspense boundaries; 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 userIdAtom changes, userAtom re-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.

Further Reading​