← Back to DevBytes

Jotai Performance: Optimization Techniques and Benchmarks

Understanding Jotai Performance: The Rendering Model

Jotai is built on a bottom-up reactive model. Unlike React's top-down prop drilling, Jotai atoms form a dependency graph where updates propagate only to atoms that depend on the changed value. This granular reactivity is Jotai's primary performance advantage—components re-render only when the specific atoms they subscribe to change, not when unrelated state updates occur.

However, this model isn't magic. Poor atom design can lead to unnecessary re-renders, bloated dependency chains, and wasted computations. Understanding how Jotai tracks dependencies and triggers re-renders is essential before applying optimization techniques.

How Atom Subscriptions Trigger Re-renders

When a component uses useAtom, useAtomValue, or useSetAtom, Jotai creates a subscription to that atom. Any write operation to the atom (or its upstream dependencies) marks the atom as dirty, schedules a re-render for all subscribed components, and recomputes any derived atoms that depend on it. The key insight: the subscription is at the atom level, not the component level. If two components subscribe to the same atom, both re-render when that atom changes—even if they only use different parts of the atom's value.

Why Atom Design Impacts Performance Directly

Consider a common pitfall: placing all form state into a single object atom. Every keystroke updates the entire atom, causing every component that reads any field to re-render. The fix—splitting the object into multiple primitive atoms—is the first and most impactful optimization you can make. This principle extends to derived atoms, async atoms, and atom families throughout the Jotai ecosystem.

Optimization Techniques

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

1. Atom Granularity: Split Large State Objects

The most fundamental rule: atoms should represent the smallest unit of independent state. Instead of one atom holding a complex object, create separate atoms for each independently updating field.

Before — problematic single-atom design:

import { atom, useAtom } from 'jotai';

// ❌ Single large atom — any field change re-renders all consumers
const userProfileAtom = atom({
  name: 'Alice',
  email: 'alice@example.com',
  avatar: '/avatars/alice.png',
  preferences: { theme: 'dark', notifications: true },
  lastLogin: Date.now(),
});

function UserNameDisplay() {
  const [profile] = useAtom(userProfileAtom);
  // Re-renders when email, avatar, preferences, or lastLogin change!
  return <span>{profile.name}</span>;
}

function UserEmailInput() {
  const [profile, setProfile] = useAtom(userProfileAtom);
  // Every keystroke causes UserNameDisplay to re-render unnecessarily
  return (
    <input
      value={profile.email}
      onChange={(e) => setProfile({ ...profile, email: e.target.value })}
    />
  );
}

After — granular atom design:

import { atom, useAtom, useAtomValue } from 'jotai';

// ✅ Separate atoms for independent fields
const userNameAtom = atom('Alice');
const userEmailAtom = atom('alice@example.com');
const userAvatarAtom = atom('/avatars/alice.png');
const userThemeAtom = atom('dark');
const userNotificationsAtom = atom(true);
const userLastLoginAtom = atom(Date.now());

function UserNameDisplay() {
  const name = useAtomValue(userNameAtom);
  // Only re-renders when userNameAtom changes
  return <span>{name}</span>;
}

function UserEmailInput() {
  const [email, setEmail] = useAtom(userEmailAtom);
  // UserNameDisplay is completely unaffected by this input
  return (
    <input
      value={email}
      onChange={(e) => setEmail(e.target.value)}
    />
  );
}

The granular approach eliminates cascade re-renders. Each input field only triggers updates for components that specifically read that field. For forms with dozens of fields, this optimization alone can reduce re-render counts by orders of magnitude.

2. Use useAtomValue and useSetAtom Instead of useAtom

The useAtom hook returns both the current value and a setter function. If your component only reads state, using useAtomValue signals clear intent and avoids subscribing to setter changes. If your component only writes state, useSetAtom provides a stable setter reference that never causes re-renders when the atom value changes.

import { atom, useAtomValue, useSetAtom } from 'jotai';

const countAtom = atom(0);
const incrementCountAtom = atom(null, (get, set) => {
  set(countAtom, get(countAtom) + 1);
});

function ReadOnlyCounter() {
  // ✅ useAtomValue — subscribes only to the value, not the setter
  const count = useAtomValue(countAtom);
  return <div>Count: {count}</div>;
}

function IncrementButton() {
  // ✅ useSetAtom — the setter function reference never changes
  // This component NEVER re-renders due to countAtom changes
  const increment = useSetAtom(incrementCountAtom);
  return <button onClick={increment}>+1</button>;
}

// ❌ Avoid: useAtom when you only need the value
function BadReadOnly() {
  const [count] = useAtom(countAtom);
  // Works but subscribes to setter updates unnecessarily
  return <div>{count}</div>;
}

The performance gain here is subtle but compounds in large component trees. Components using useSetAtom for write-only actions become immune to re-renders from atom value changes—they only re-render when their own props or non-Jotai state change.

3. Atom Families for Parameterized State

When you need multiple instances of similar state (e.g., a list of counters, per-user settings, or per-item toggle states), atomFamily provides automatic memoization. It creates atoms on-demand based on a parameter and caches them, avoiding the overhead of managing a map of atoms manually.

import { atomFamily, useAtom, atom } from 'jotai';
import { useAtomValue } from 'jotai';

// atomFamily creates a memoized atom factory
const todoCheckedAtom = atomFamily((id: string) =>
  atom(false)
);

// The atom for each id is created once and cached
function TodoItem({ id, text }: { id: string; text: string }) {
  // Each TodoItem gets its own isolated atom instance
  const [checked, setChecked] = useAtom(todoCheckedAtom(id));

  return (
    <div>
      <input
        type="checkbox"
        checked={checked}
        onChange={() => setChecked((prev) => !prev)}
      />
      <span style={{ textDecoration: checked ? 'line-through' : 'none' }}>
        {text}
      </span>
    </div>
  );
}

// The family handles cleanup automatically via WeakMap
function TodoList() {
  const todos = [
    { id: '1', text: 'Learn Jotai' },
    { id: '2', text: 'Optimize performance' },
    { id: '3', text: 'Ship product' },
  ];

  return (
    <div>
      {todos.map((todo) => (
        <TodoItem key={todo.id} id={todo.id} text={todo.text} />
      ))}
    </div>
  );
}

Without atomFamily, you'd need to maintain a separate Map or object of atoms, manually creating and cleaning up atom instances. The family pattern also ensures that atoms for removed items are garbage collected when no component references them, preventing memory leaks in dynamic lists.

4. Selectors with selectAtom for Partial Subscriptions

Sometimes you have a large atom but can't split it (e.g., data from an external source). selectAtom lets components subscribe to a derived slice using a custom equality function, re-rendering only when the selected portion changes.

import { atom, useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils';

interface FullData {
  id: string;
  timestamp: number;
  metrics: { cpu: number; memory: number; disk: number };
  logs: string[];
}

const monitoringDataAtom = atom<FullData>({
  id: 'server-1',
  timestamp: Date.now(),
  metrics: { cpu: 45, memory: 72, disk: 30 },
  logs: ['INFO: Server running', 'WARN: High memory usage'],
});

// selectAtom creates a derived atom that only triggers re-renders
// when the selected value changes according to the equality function
const cpuMetricAtom = selectAtom(
  monitoringDataAtom,
  (data) => data.metrics.cpu,
  (a, b) => a === b // shallow equality for primitives
);

const memoryMetricAtom = selectAtom(
  monitoringDataAtom,
  (data) => data.metrics.memory,
  (a, b) => a === b
);

function CpuMonitor() {
  // Only re-renders when cpu value actually changes
  const cpu = useAtomValue(cpuMetricAtom);
  return <div>CPU: {cpu}%</div>;
}

function MemoryMonitor() {
  const memory = useAtomValue(memoryMetricAtom);
  return <div>Memory: {memory}%</div>;
}

// Updating the full atom still works correctly
function UpdateMetricsButton() {
  const [, setData] = useAtom(monitoringDataAtom);
  return (
    <button onClick={() => {
      setData((prev) => ({
        ...prev,
        timestamp: Date.now(),
        metrics: { ...prev.metrics, cpu: prev.metrics.cpu + 1 },
      }));
    }}>
      Simulate CPU Spike
    </button>
  );
}

The selectAtom utility shines with frequently updating data streams (WebSocket feeds, polling responses, animation frames) where only specific fields matter to specific components. The custom equality function prevents re-renders when the selected slice hasn't actually changed, even if other parts of the atom have.

5. Derived Atoms with Lazy Evaluation

Derived atoms in Jotai are evaluated lazily—they only recompute when their dependencies change and someone is actively subscribing to them. This means expensive computations can be deferred until needed, and intermediate derived atoms that lose all subscribers stop updating entirely.

import { atom, useAtomValue } from 'jotai';

const rawTransactionsAtom = atom<Transaction[]>([
  { id: '1', amount: 150, category: 'food', date: '2024-01-15' },
  { id: '2', amount: 45, category: 'transport', date: '2024-01-16' },
  { id: '3', amount: 200, category: 'food', date: '2024-01-17' },
  // ... thousands of transactions
]);

// Derived atom: only recomputes when rawTransactionsAtom changes
// AND a component is actively reading this atom
const totalByCategoryAtom = atom((get) => {
  const transactions = get(rawTransactionsAtom);
  console.log('Computing category totals...');

  const totals: Record<string, number> = {};
  for (const tx of transactions) {
    totals[tx.category] = (totals[tx.category] || 0) + tx.amount;
  }
  return totals;
});

// Further derived: even more granular
const foodTotalAtom = atom((get) => {
  const totals = get(totalByCategoryAtom);
  return totals.food || 0;
});

function FoodExpenses() {
  // Only foodTotalAtom is subscribed — if no one reads totalByCategoryAtom
  // directly, the intermediate computation is skipped after initial calculation
  const foodTotal = useAtomValue(foodTotalAtom);
  return <div>Food expenses: ${foodTotal}</div>;
}

Lazy evaluation means you can define complex derivation chains without paying the computation cost until components actually mount. When a component unmounts and no other component subscribes to the same derived atom, Jotai releases the cached value and stops recomputing it.

6. Stable References: Never Create Atoms Inside Components

Creating atoms inside a component body is a common mistake that leads to memory leaks and lost state. Atoms must be defined at module scope or created via useRef + atom pattern to maintain stable references across renders.

import { atom, useAtom, useRef, useEffect } from 'jotai';
import { atomFamily } from 'jotai/utils';

// ❌ TERRIBLE: Creates a new atom on every render
// The atom reference changes, causing infinite re-render loops
function BadComponent({ initialValue }: { initialValue: number }) {
  // This atom is recreated every render — subscriptions break
  const tempAtom = atom(initialValue);
  const [value, setValue] = useAtom(tempAtom);
  return <input value={value} onChange={(e) => setValue(+e.target.value)} />;
}

// ✅ Use useRef to create the atom once per component instance
function GoodComponentWithRef({ initialValue }: { initialValue: number }) {
  const atomRef = useRef(atom(initialValue));
  const [value, setValue] = useAtom(atomRef.current);
  return <input value={value} onChange={(e) => setValue(+e.target.value)} />;
}

// ✅ BEST: Use atomFamily for parameterized per-instance atoms
const valueAtomFamily = atomFamily((id: string) => atom(0));

function BestComponent({ id, initialValue }: { id: string; initialValue: number }) {
  const atomInstance = valueAtomFamily(id);
  const [value, setValue] = useAtom(atomInstance);

  useEffect(() => {
    if (initialValue !== undefined) setValue(initialValue);
  }, []);

  return <input value={value} onChange={(e) => setValue(+e.target.value)} />;
}

The useRef pattern works for one-off component instances, but atomFamily is the recommended approach for parameterized atoms—it handles memoization, avoids stale closures, and integrates with Jotai's internal scheduling.

7. Scoped Providers for State Isolation

Jotai's Provider component creates an isolated scope where atoms hold separate values from the parent scope. This is invaluable for performance: components within a scoped provider don't affect components outside, and vice versa. Use scoped providers for complex widgets, modals, or parallel instances of the same feature.

import { atom, useAtom, Provider, useAtomValue } from 'jotai';

const countAtom = atom(0);
const totalAtom = atom(0);

function CounterWidget() {
  const [count, setCount] = useAtom(countAtom);
  const total = useAtomValue(totalAtom);

  return (
    <div>
      <p>Widget count: {count}</p>
      <p>Global total: {total}</p>
      <button onClick={() => setCount((c) => c + 1)}>+1</button>
    </div>
  );
}

function App() {
  return (
    <div>
      {/* Each Provider creates an isolated countAtom scope */}
      <Provider>
        <CounterWidget />
        {/* This widget's countAtom is separate from the next one */}
      </Provider>

      <Provider>
        <CounterWidget />
        {/* Updates here don't cause re-renders in the first widget */}
      </Provider>

      {/* totalAtom is outside providers, shared globally */}
      <div>
        <GlobalTotalDisplay />
      </div>
    </div>
  );
}

Scoped providers prevent cross-contamination between parallel component instances. They're particularly effective for reusable components that you want to render multiple times without shared state interference. Each provider scope maintains its own atom value cache, completely independent from siblings.

8. Async Atom Optimization: Prevent Waterfall Requests

Async atoms with dependencies can create request waterfalls where each atom waits for the previous one to resolve. Flatten dependencies and use parallel async atoms when possible to reduce total loading time.

import { atom, useAtomValue } from 'jotai';

// ❌ Waterfall: userAtom must resolve before postsAtom can start
const userIdAtom = atom(async () => {
  const res = await fetch('/api/current-user');
  return res.json();
});

const userPostsAtom = atom(async (get) => {
  // This waits for userIdAtom to resolve first
  const user = await get(userIdAtom);
  const res = await fetch(`/api/users/${user.id}/posts`);
  return res.json();
});

// ✅ Parallel: both fetch independently, combined atom waits for both
const configAtom = atom(async () => {
  const res = await fetch('/api/config');
  return res.json();
});

const analyticsAtom = atom(async () => {
  const res = await fetch('/api/analytics/summary');
  return res.json();
});

// This atom waits for both, but they started in parallel
const dashboardDataAtom = atom(async (get) => {
  const [config, analytics] = await Promise.all([
    get(configAtom),
    get(analyticsAtom),
  ]);
  return { config, analytics };
});

function Dashboard() {
  const data = useAtomValue(dashboardDataAtom);
  // Both config and analytics fetched concurrently
  return <pre>{JSON.stringify(data, null, 2)}</pre>;
}

Jotai's async atoms use React Suspense integration. Independent async atoms start fetching as soon as they're subscribed, but dependent ones serialize. Restructure your atom dependency graph to maximize parallelism—this is a performance optimization that affects perceived loading speed more than runtime CPU usage.

9. Avoiding Deep Equality Checks When Unnecessary

By default, Jotai uses Object.is for equality comparisons. For atoms holding large objects that are replaced entirely on each update, this is fast. But if you're doing immutable updates that produce new object references, the default comparison is sufficient—don't add custom equality functions unless you have a specific need.

import { atom, useAtom } from 'jotai';

const itemsAtom = atom<Item[]>([]);

// ✅ Default Object.is works perfectly for immutable updates
// setItems([...items, newItem]) produces a new reference — Object.is detects it
function ItemList() {
  const [items, setItems] = useAtom(itemsAtom);

  const addItem = (item: Item) => {
    // New array reference — Object.is correctly triggers re-render
    setItems([...items, item]);
  };

  return (
    <div>
      {items.map((item) => <div key={item.id}>{item.name}</div>)}
      <button onClick={() => addItem({ id: Date.now(), name: 'New' })}>
        Add
      </button>
    </div>
  );
}

// ⚠️ Custom equality only needed when you want to PREVENT re-renders
// for objects that are structurally equal but referentially different
import { selectAtom } from 'jotai/utils';
import deepEqual from 'fast-deep-equal';

const largeConfigAtom = atom({ theme: 'dark', layout: {}, features: [] });

const themeAtom = selectAtom(
  largeConfigAtom,
  (config) => ({ theme: config.theme }),
  deepEqual // Custom deep equality to avoid re-renders on irrelevant changes
);

Adding deepEqual everywhere adds unnecessary overhead. Let Jotai's default Object.is handle most cases, and only reach for custom equality functions when profiling reveals excessive re-renders from structurally identical objects.

10. Early Bailout with useAtomValue and Suspense Boundaries

For async atoms, placing Suspense boundaries strategically prevents slow-loading data from blocking fast-loading UI sections. Combine this with atom granularity for optimal perceived performance.

import { atom, useAtomValue, Suspense } from 'jotai';

const fastDataAtom = atom(async () => {
  const res = await fetch('/api/quick-status');
  return res.json(); // Resolves in ~50ms
});

const slowDataAtom = atom(async () => {
  const res = await fetch('/api/heavy-analytics');
  return res.json(); // Resolves in ~2000ms
});

function App() {
  return (
    <div>
      {/* Fast data renders immediately, not blocked by slow data */}
      <Suspense fallback={<div>Loading quick status...</div>}>
        <QuickStatusPanel />
      </Suspense>

      {/* Slow data has its own fallback, doesn't delay the rest of the page */}
      <Suspense fallback={<div>Crunching analytics data...</div>}>
        <AnalyticsPanel />
      </Suspense>
    </div>
  );
}

function QuickStatusPanel() {
  const status = useAtomValue(fastDataAtom);
  return <div>Status: {status.health}</div>;
}

function AnalyticsPanel() {
  const analytics = useAtomValue(slowDataAtom);
  return <div>Analytics: {JSON.stringify(analytics)}</div>;
}

Each Suspense boundary creates an asynchronous isolation point. The fast panel appears as soon as its data resolves, independent of the slow panel's loading state. Without this pattern, a single Suspense wrapping both would delay the entire UI until the slowest atom resolves.

Benchmarking Jotai Performance in Practice

To quantify these optimizations, let's walk through a realistic benchmark scenario: a data table with 500 rows, each containing editable cells backed by Jotai atoms. We'll measure render counts and frame times across different atom designs.

Benchmark Setup: 500-Row Editable Table

import { atom, useAtom, useAtomValue, Provider } from 'jotai';
import { atomFamily } from 'jotai/utils';
import { useCallback, memo, Profiler } from 'react';

// Define cell data type
interface CellData {
  value: string;
  editing: boolean;
  error: string | null;
}

// ===== SCENARIO 1: Single atom for all cells (bad) =====
const allCellsAtom = atom<Record<string, CellData>>(
  Object.fromEntries(
    Array.from({ length: 500 }, (_, i) => [
      `cell-${i}`,
      { value: `Row ${i}`, editing: false, error: null },
    ])
  )
);

// ===== SCENARIO 2: atomFamily per cell (optimized) =====
const cellDataFamily = atomFamily((cellId: string) =>
  atom<CellData>({ value: '', editing: false, error: null })
);

// Initialize with data
const initializeCellAtom = atom(null, (get, set, cells: Record<string, CellData>) => {
  for (const [id, data] of Object.entries(cells)) {
    set(cellDataFamily(id), data);
  }
});

// Memoized cell component to isolate re-renders
const OptimizedCell = memo(function OptimizedCell({ cellId }: { cellId: string }) {
  const [cell, setCell] = useAtom(cellDataFamily(cellId));

  const startEditing = useCallback(() => {
    setCell((prev) => ({ ...prev, editing: true }));
  }, [setCell]);

  const updateValue = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    setCell((prev) => ({ ...prev, value: e.target.value }));
  }, [setCell]);

  const stopEditing = useCallback(() => {
    setCell((prev) => ({ ...prev, editing: false }));
  }, [setCell]);

  if (cell.editing) {
    return (
      <input
        value={cell.value}
        onChange={updateValue}
        onBlur={stopEditing}
        autoFocus
      />
    );
  }

  return <div onClick={startEditing}>{cell.value}</div>;
});

// Profiler wrapper to measure render counts
function BenchmarkApp() {
  const onRender = useCallback((id: string, phase: string, actualDuration: number) => {
    console.log(`${id} ${phase}: ${actualDuration.toFixed(2)}ms`);
  }, []);

  return (
    <Profiler id="table-benchmark" onRender={onRender}>
      <div>
        {Array.from({ length: 500 }, (_, i) => (
          <OptimizedCell key={`cell-${i}`} cellId={`cell-${i}`} />
        ))}
      </div>
    </Profiler>
  );
}

Benchmark Results Interpretation

In the single-atom scenario (Scenario 1), editing one cell updates the entire allCellsAtom, causing all 500 cell components to re-render. Each keystroke triggers ~500 re-renders with a frame time of approximately 15-25ms on a typical machine. As the user types, the cumulative lag becomes noticeable.

In the atomFamily scenario (Scenario 2), editing cell #42 only updates cellDataFamily('cell-42'). Only that single cell component re-renders. Frame time drops to under 1ms per keystroke, and the React Profiler shows exactly one commit per edit instead of 500.

The performance delta is roughly 500x fewer re-renders per interaction. For a table with 500 cells where a user edits 20 characters, Scenario 1 produces 10,000 re-renders (500 cells × 20 keystrokes), while Scenario 2 produces just 20 re-renders.

Memory and Garbage Collection Benchmarks

import { atomFamily } from 'jotai/utils';

// atomFamily uses WeakMap internally for garbage collection
const heavyAtomFamily = atomFamily((id: string) =>
  atom<HeavyObject>({
    id,
    buffer: new ArrayBuffer(1024 * 1024), // 1MB per atom
    data: Array.from({ length: 10000 }, (_, i) => i),
  })
);

function DynamicList({ ids }: { ids: string[] }) {
  return (
    <div>
      {ids.map((id) => (
        <HeavyItem key={id} id={id} />
      ))}
    </div>
  );
}

function HeavyItem({ id }: { id: string }) {
  const [data] = useAtom(heavyAtomFamily(id));
  return <div>Item {id}: {data.buffer.byteLength} bytes</div>;
}

// When ids change and old items unmount, the WeakMap in atomFamily
// allows garbage collection of atoms that are no longer referenced.
// No manual cleanup required — memory is freed automatically.

Jotai's atomFamily uses a WeakMap internally, meaning atoms for unmounted components are eligible for garbage collection. In a long-running app with dynamic lists, this prevents unbounded memory growth without manual cleanup code.

Best Practices Summary

Conclusion

Jotai's performance story centers on its granular reactivity model. The library gives you precise control over re-render boundaries through atom design—more than most state management solutions. By splitting state into small, focused atoms, using the appropriate hook variants (useAtomValue, useSetAtom), and leveraging utilities like atomFamily, selectAtom, and scoped Provider components, you can achieve near-minimal re-render profiles even in complex applications with hundreds of interactive elements. The benchmarks confirm that these techniques yield orders-of-magnitude improvements in render efficiency. The investment in thoughtful atom architecture pays dividends as your application scales, keeping the UI responsive and the codebase maintainable without requiring external memoization frameworks or manual performance plumbing.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles