Fix: Jotai Not Working — Atom Not Updating, Derived Atom Out of Sync, or atomWithStorage Hydration Error
Part of: React & Frontend Errors
Quick Answer
How to fix Jotai state management issues — atom scope, derived atoms, async atoms with Suspense, atomWithStorage SSR, useAtomValue vs useSetAtom, and debugging stale state.
The Problem
A component doesn’t re-render when an atom’s value changes:
const countAtom = atom(0);
function Counter() {
const count = useAtomValue(countAtom);
return <div>{count}</div>;
// count never updates — always shows 0
}
function Controls() {
const increment = () => {
// WRONG — this doesn't update the atom
countAtom.init = countAtom.init + 1;
};
return <button onClick={increment}>+</button>;
}Or a derived atom shows stale data:
const userAtom = atom<User | null>(null);
const displayNameAtom = atom((get) => get(userAtom)?.name ?? 'Guest');
// displayNameAtom shows 'Guest' even after userAtom is updatedOr atomWithStorage causes a hydration mismatch in Next.js:
Error: Text content does not match server-rendered HTML.
Server: "light"
Client: "dark"Or async atoms break without Suspense:
const usersAtom = atom(async () => fetchUsers());
// TypeError: Cannot read properties of undefined (reading 'map')
// Component renders before the promise resolvesWhy This Happens
Jotai’s reactivity model requires specific patterns, and the first instinct when “the atom isn’t updating” is usually wrong. You look at the setter, confirm it’s being called, and wonder why subscribers don’t see the new value. The setter is rarely the bug. The bug is almost always one of three things: there’s no Provider wrapping the tree at the right level, an async atom is suspending in an unexpected way, or the Devtools provider is missing and what looks like “no update” is actually an update happening in a different store.
The Provider issue is the most common silent failure. Jotai v2+ uses a default global store, so the first version of an app works fine without a <Provider>. Then you add a second instance of the same component tree — for a modal, a sidebar, a portal — and the two instances unexpectedly share state. You add a <Provider> around one of them. Now that subtree no longer sees atoms set from the rest of the app, because each Provider creates an isolated store. The fix is either to share a store prop across providers or to remove the provider entirely.
Async atoms suspending also catches teams off guard. An atom(async () => ...) doesn’t just return a Promise — it ties the component into React Suspense. If the nearest Suspense boundary is far up the tree, the entire upper tree unmounts and remounts when the atom resolves, throwing away local state in everything between. The atom looks like it’s “not updating” because the component holding the result was just torn down.
- Atoms are read and written with hooks, not direct mutation —
atom.initis just the initial value declaration. Modifying it does nothing. You must useuseSetAtomoruseAtomto update atom values. - Derived atoms are read-only by default — a derived atom defined with
atom((get) => ...)only reads from its dependencies. Trying to write to it throws an error. Use a read-write atom with both getter and setter to allow updates. atomWithStoragereads fromlocalStorageon the client — during SSR,localStoragedoesn’t exist, so the atom uses its initial value. On the client, it reads the stored value. If those differ, React sees a hydration mismatch.- Async atoms always require
Suspense— an atom that returns aPromiseis an async atom. Components reading it must be wrapped in<Suspense>, or the component will crash trying to accessundefinedbefore the promise resolves.
Diagnostic Timeline
A typical “my atom isn’t updating” debugging session usually moves like this:
Minute 0 — first guess: the atom isn’t updating. You see a value stuck at its initial state, even though the setter clearly runs. You add a console.log in the setter — it fires. You add another log in the reader — also fires, but with the old value. You start suspecting that Jotai’s reactivity is broken.
Minute 3 — check whether there’s a Provider mismatch. Wrap a console.log(store.get(theAtom)) in useEffect from a component near the root, and the same log from the component that’s not updating. If the values differ, you have two stores. Search the tree for <Provider> (from jotai) — one likely wraps part of the tree while the other doesn’t. Either remove the unnecessary Provider or pass an explicit store prop so all providers share the same store.
Minute 7 — async atom is suspending and remounting. If a parent component’s local state resets every time the atom updates, the atom is suspending higher up than expected. Wrap the consuming component in its own <Suspense fallback={...}> immediately above it. Any Suspense boundary catches the suspension and stops the unmount cascade. For data you don’t want to suspend at all, wrap the atom with loadable() from jotai/utils.
Minute 12 — Devtools provider missing. You added <DevTools /> from jotai-devtools to inspect atom state, but the panel shows no atoms. The cause is that <DevTools /> reads from the default store, but your app uses a custom store via <Provider store={myStore}>. Pass the same store to DevTools: <DevTools store={myStore} />. Same applies to useAtomsDevtools('label', { store: myStore }).
Minute 18 — atomWithStorage SSR hydration. The atom looks like it doesn’t update on first paint, but after a refresh it works. The cause is SSR: during the server render, localStorage doesn’t exist and the atom emits the initial value. The client hydrates with the same initial value, then on the next tick reads localStorage and re-renders. Any component reading the atom in useEffect sees the right value; any component reading it in render gets the initial-then-stored flicker. Fix with getOnInit: true only on the client, or hydrate via useHydrateAtoms with the server-known value.
Fix 1: Read and Write Atoms Correctly
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
// Primitive atom
const countAtom = atom(0);
const textAtom = atom('');
const userAtom = atom<User | null>(null);
// Component that both reads and writes
function Counter() {
const [count, setCount] = useAtom(countAtom); // [value, setter]
return (
<div>
<span>{count}</span>
<button onClick={() => setCount(c => c + 1)}>+</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
// Component that only reads (no unnecessary re-renders from writes)
function CountDisplay() {
const count = useAtomValue(countAtom); // Only re-renders when count changes
return <span>{count}</span>;
}
// Component that only writes (never re-renders due to atom changes)
function CountControls() {
const setCount = useSetAtom(countAtom); // No re-render when count changes
return (
<button onClick={() => setCount(c => c + 1)}>Increment</button>
);
}Derived (computed) atoms:
// Read-only derived atom
const uppercaseAtom = atom((get) => get(textAtom).toUpperCase());
// Read-write derived atom (transforms read AND write)
const doubleCountAtom = atom(
(get) => get(countAtom) * 2, // Read: double the count
(get, set, value: number) => { // Write: halve the value
set(countAtom, value / 2);
}
);
// Derived from multiple atoms
const summaryAtom = atom((get) => {
const user = get(userAtom);
const count = get(countAtom);
return `${user?.name ?? 'Guest'} — ${count} items`;
});
// Compound derived with validation
const isValidEmailAtom = atom((get) => {
const email = get(emailAtom);
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
});Write-only atoms (actions):
// Action atom — no read value, only performs side effects
const incrementAtom = atom(null, (get, set) => {
const current = get(countAtom);
set(countAtom, current + 1);
// Can update multiple atoms in one action
set(logAtom, prev => [...prev, `Incremented to ${current + 1}`]);
});
function IncrementButton() {
const increment = useSetAtom(incrementAtom);
return <button onClick={increment}>+</button>;
}Fix 2: Fix Async Atoms and Suspense
Async atoms return Promises — components reading them must be wrapped in Suspense:
// Async atom — fetches data
const usersAtom = atom(async () => {
const res = await fetch('/api/users');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<User[]>;
});
// Component — MUST be inside Suspense
function UserList() {
const users = useAtomValue(usersAtom);
// No need to handle loading — Suspense does it
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
// Parent component — provides Suspense boundary
function App() {
return (
<Suspense fallback={<Spinner />}>
<ErrorBoundary fallback={<ErrorMessage />}>
<UserList />
</ErrorBoundary>
</Suspense>
);
}Async atom with parameters — loadable helper:
import { loadable } from 'jotai/utils';
// Wrap async atom with loadable to avoid Suspense requirement
const loadableUsersAtom = loadable(usersAtom);
function UserList() {
const loadable = useAtomValue(loadableUsersAtom);
if (loadable.state === 'loading') return <Spinner />;
if (loadable.state === 'hasError') return <Error error={loadable.error} />;
const users = loadable.data;
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}Async atom that depends on other atoms:
const selectedUserIdAtom = atom<number | null>(null);
// Re-fetches when selectedUserIdAtom changes
const selectedUserAtom = atom(async (get) => {
const id = get(selectedUserIdAtom);
if (!id) return null;
const res = await fetch(`/api/users/${id}`);
return res.json() as Promise<User>;
});
// Refresh mechanism — add a refresh atom
const refreshCounterAtom = atom(0);
const refreshableUsersAtom = atom(async (get) => {
get(refreshCounterAtom); // Subscribe to trigger refreshes
return fetchUsers();
});
function RefreshButton() {
const refresh = useSetAtom(refreshCounterAtom);
return <button onClick={() => refresh(c => c + 1)}>Refresh</button>;
}Fix 3: Fix atomWithStorage for SSR
atomWithStorage reads from localStorage, which doesn’t exist during SSR:
import { atomWithStorage } from 'jotai/utils';
// WRONG — causes hydration mismatch in Next.js
const themeAtom = atomWithStorage('theme', 'light');
// CORRECT — use a custom storage that handles SSR
import { createJSONStorage } from 'jotai/utils';
const ssrSafeStorage = createJSONStorage(() => {
if (typeof window !== 'undefined') return localStorage;
// Return a no-op storage for SSR
return {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
};
});
const themeAtom = atomWithStorage('theme', 'light', ssrSafeStorage);Handle hydration with useHydrateAtoms:
import { useHydrateAtoms } from 'jotai/utils';
// Server-side: get initial values
export async function getServerSideProps() {
const theme = getThemeFromCookie();
return { props: { initialTheme: theme } };
}
// Client component — hydrate atoms with server values
function Page({ initialTheme }: { initialTheme: string }) {
// Hydrate atoms before render — prevents flash
useHydrateAtoms([[themeAtom, initialTheme]]);
const theme = useAtomValue(themeAtom);
return <div className={theme}>{/* ... */}</div>;
}Next.js App Router pattern:
// app/providers.tsx
'use client';
import { Provider, createStore } from 'jotai';
// Create a store per request to avoid state leaking between users
export function JotaiProvider({ children }: { children: React.ReactNode }) {
return <Provider>{children}</Provider>;
}
// For SSR data hydration:
import { useHydrateAtoms } from 'jotai/utils';
export function JotaiHydrator({
initialValues,
children,
}: {
initialValues: Parameters<typeof useHydrateAtoms>[0];
children: React.ReactNode;
}) {
useHydrateAtoms(initialValues);
return <>{children}</>;
}Fix 4: Use Atom Families for Dynamic Data
atomFamily creates atom instances keyed by a parameter:
import { atomFamily } from 'jotai/utils';
// Create one atom per user ID
const userAtomFamily = atomFamily((userId: number) =>
atom(async () => {
const res = await fetch(`/api/users/${userId}`);
return res.json() as Promise<User>;
})
);
// Each userId gets its own cached atom
function UserCard({ userId }: { userId: number }) {
const user = useAtomValue(userAtomFamily(userId));
return <div>{user.name}</div>;
}
// atomFamily for writable atoms
const todoAtomFamily = atomFamily((id: string) =>
atom<Todo | null>(null)
);
// Clean up atom family instances to prevent memory leaks
function useCleanupAtomFamily(id: string) {
useEffect(() => {
return () => {
todoAtomFamily.remove(id); // Remove when component unmounts
};
}, [id]);
}Array of atoms pattern:
// Atoms that store lists of other atoms
const todoIdsAtom = atom<string[]>([]);
const todoAtomsAtom = atom((get) =>
get(todoIdsAtom).map(id => todoAtomFamily(id))
);
// Add a todo
const addTodoAtom = atom(null, (get, set, text: string) => {
const id = crypto.randomUUID();
set(todoAtomFamily(id), { id, text, done: false });
set(todoIdsAtom, prev => [...prev, id]);
});Fix 5: Use Atom Scope and Provider
By default, atoms use a global store. Scope atoms to a specific part of the component tree:
import { createStore, Provider, atom } from 'jotai';
const countAtom = atom(0);
// Create isolated stores for independent instances
function IndependentCounter() {
const store = createStore();
return (
<Provider store={store}>
{/* This counter is isolated — changes don't affect others */}
<Counter />
</Provider>
);
}
// Multiple counters — each independent
function App() {
return (
<div>
<IndependentCounter />
<IndependentCounter />
</div>
);
}
// Access the store programmatically (outside React)
const store = createStore();
store.set(countAtom, 5);
const value = store.get(countAtom);
store.sub(countAtom, () => {
console.log('Count changed:', store.get(countAtom));
});Fix 6: Debug Atom State
import { useAtomsDevtools } from 'jotai-devtools';
// Add devtools to see all atom values in Redux DevTools
function App() {
useAtomsDevtools('MyApp');
return <YourApp />;
}
// Or use the DevTools component
import { DevTools } from 'jotai-devtools';
import 'jotai-devtools/styles.css';
function App() {
return (
<>
<DevTools /> {/* Floating panel showing all atoms */}
<YourApp />
</>
);
}Debug specific atoms with debugLabel:
const countAtom = atom(0);
countAtom.debugLabel = 'count'; // Shows in DevTools
const userAtom = atom<User | null>(null);
userAtom.debugLabel = 'currentUser';
// Or use the atom factory with a label
function createLabeledAtom<T>(initialValue: T, label: string) {
const a = atom(initialValue);
a.debugLabel = label;
return a;
}Read atom value outside React (for debugging or testing):
import { createStore } from 'jotai';
const store = createStore();
// Get current value
const value = store.get(countAtom);
// Set value
store.set(countAtom, 42);
// Subscribe to changes
const unsubscribe = store.sub(countAtom, () => {
console.log('New value:', store.get(countAtom));
});
unsubscribe(); // Stop listeningStill Not Working?
Atom value is stale after async update — async atoms in Jotai re-run whenever their dependencies change. If the async atom depends on no other atoms and returns the same Promise instance, Jotai may cache the initial Promise. Add a refresh mechanism:
const refreshAtom = atom(0);
const dataAtom = atom(async (get) => {
get(refreshAtom); // Just reading this atom creates the dependency
return fetchData();
});
// Force re-fetch
const setRefresh = useSetAtom(refreshAtom);
setRefresh(n => n + 1);useAtomValue in a non-React context throws — Jotai hooks (useAtom, useAtomValue, useSetAtom) are React hooks. They must be called inside a React component or another hook. For accessing atoms outside React (in event handlers, services, or tests), use store.get() and store.set() from a createStore() instance.
Provider not found error — if you see “Could not find Jotai Provider” in an older version, you may have configured your app without a <Provider>. Jotai v2+ uses a default global store without requiring a Provider. If you’re using Jotai v1, add <Provider> at the root. If you’re on v2 and still see this, check that you’re not mixing Jotai v1 and v2 imports.
useHydrateAtoms runs every render — useHydrateAtoms is meant to run once per mount, but if you pass a new array reference on every render (useHydrateAtoms([[atom, value]])), it re-hydrates and clobbers any client-side updates. Memoize the tuple: const initialValues = useMemo(() => [[atom, value]] as const, [value]). Better: only hydrate on the first render by gating with a ref.
Atom updates fire but components above the consumer re-render too — if you call useAtom in a parent component when only a child needs the value, the parent subscribes and re-renders on every change. Move the useAtom call into the leaf component, or use useAtomValue (read-only) which still subscribes but at least doesn’t expose the setter. For derived atoms, split into smaller atoms so consumers subscribe to the narrowest slice they need.
Two atoms with the same value are different identities — atomFamily deduplicates by parameter equality. If you pass an object as the parameter, two calls with structurally equal but referentially different objects create two atoms. Pass primitive keys (strings, numbers) to atomFamily, or supply a custom areEqual function as the second argument.
For related React state and SSR patterns, see Fix: Zustand Not Working, Fix: TanStack Query Not Working, Fix: React Suspense Not Triggering, and Fix: React Hydration Error.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: TanStack Query Not Working — Data Not Fetching, Cache Not Updating, or Mutation Not Triggering Re-render
How to fix TanStack Query (React Query v5) issues — query keys, stale time, enabled flag, mutation callbacks, optimistic updates, QueryClient setup, and SSR with prefetchQuery.
Fix: Valtio Not Working — Component Not Re-rendering, Snapshot Stale, or Proxy Mutation Not Tracked
How to fix Valtio state management issues — proxy vs snapshot, useSnapshot for React, subscribe for side effects, derived state with computed, async actions, and Valtio with React Server Components.
Fix: Zustand Not Working — Component Not Re-rendering, State Reset on Refresh, or Selector Causing Infinite Loop
How to fix Zustand state management issues — selector optimization, persist middleware, shallow comparison, devtools setup, slice pattern for large stores, and common subscription mistakes.
Fix: AutoAnimate Not Working — Transitions Not Playing, List Items Not Animating, or React State Changes Ignored
How to fix @formkit/auto-animate issues — parent ref setup, React useAutoAnimate hook, Vue directive, animation customization, disabling for specific elements, and framework integration.