Fix: Jotai Not Working — Atom Not Updating, Derived Atom Out of Sync, or atomWithStorage Hydration Error
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:
- 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.
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.
For related React state patterns, see Fix: Zustand Not Working and Fix: TanStack Query Not Working.
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.