Skip to content

Fix: Jotai Not Working — Atom Not Updating, Derived Atom Out of Sync, or atomWithStorage Hydration Error

FixDevs · (Updated: )

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 updated

Or 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 resolves

Why 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 mutationatom.init is just the initial value declaration. Modifying it does nothing. You must use useSetAtom or useAtom to 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.
  • atomWithStorage reads from localStorage on the client — during SSR, localStorage doesn’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 a Promise is an async atom. Components reading it must be wrapped in <Suspense>, or the component will crash trying to access undefined before 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 listening

Still 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 renderuseHydrateAtoms 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 identitiesatomFamily 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles