Skip to content

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

FixDevs ·

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:

  • 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.

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.

For related React state patterns, see Fix: Zustand Not Working and Fix: TanStack Query Not Working.

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