deep-dive2025-01-25Β·12Β·216/348

Jotai Atomic State Management: Patterns and Performance

Advanced Jotai patterns for derived atoms, async atoms, and debugging complex state graphs in large React applications.

Introduction

Jotai takes a fundamentally different approach to state management compared to traditional stores like Redux or Zustand. Instead of a single store with multiple state slices, Jotai uses atomic units that independently trigger re-renders. This fine-grained reactivity eliminates the selector optimization complexity that plagues other state management libraries.

After migrating a large dashboard application from Redux to Jotai, I discovered patterns that significantly improved both developer experience and runtime performance. This post covers the atomic state model, derived atoms, async atoms, and the debugging tools that make complex state graphs manageable.

Environment

node --version
# v20.11.0

npm list jotai react
# jotai@2.6.0
# react@18.2.0

npm list jotai-devtools
# jotai-devtools@0.5.0

The application is a data visualization dashboard with:

dashboard/
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ atoms/
β”‚   β”‚   β”œβ”€β”€ userAtoms.ts
β”‚   β”‚   β”œβ”€β”€ filterAtoms.ts
β”‚   β”‚   β”œβ”€β”€ dataAtoms.ts
β”‚   β”‚   └── uiAtoms.ts
β”‚   β”œβ”€β”€ components/
β”‚   β”‚   β”œβ”€β”€ Dashboard.tsx
β”‚   β”‚   β”œβ”€β”€ FilterPanel.tsx
β”‚   β”‚   └── Chart.tsx
β”‚   β”œβ”€β”€ hooks/
β”‚   β”‚   └── useAtomValue.ts
β”‚   └── store.ts

Problem

The initial Redux setup had three major issues. First, the selector functions were becoming complex as the dashboard grew, with nested selectors that were difficult to maintain. Second, the filter state changes triggered refetches of data that should have been cached. Third, the UI state (sidebar open, modal visible) was stored alongside business data, causing unnecessary re-renders in unrelated components.

Here is the Redux selector that became problematic:

// This selector was called on every state change
const selectFilteredData = (state: RootState) => {
  const { dateRange, category, searchTerm } = state.filters;
  const { rawData, isLoading, error } = state.data;

  // Complex filtering logic in selector
  return rawData
    .filter((item) => {
      const inDateRange = item.date >= dateRange.start && item.date <= dateRange.end;
      const matchesCategory = !category || item.category === category;
      const matchesSearch = !searchTerm || item.name.includes(searchTerm);
      return inDateRange && matchesCategory && matchesSearch;
    })
    .sort((a, b) => b.value - a.value);
};

// Component subscribed to this selector
// Every filter change caused a full data re-filter
function DataChart() {
  const filteredData = useSelector(selectFilteredData);
  return ;
}

The performance problem was that filtering happened synchronously in the selector, blocking the main thread for large datasets. Additionally, every filter change triggered a refetch because the component could not distinguish between filter changes and data updates.

Analysis

Jotai's atomic model solves these problems by separating concerns into independent atoms. Each atom manages a single piece of state, and derived atoms compute derived values lazily. When an atom changes, only components that depend on that specific atom re-render.

The key insight is that Jotai uses a dependency graph. When you create a derived atom that depends on multiple atoms, Jotai tracks these dependencies and only recomputes when the source atoms change. This is more efficient than Redux selectors that run on every state change regardless of which slice changed.

The async atom pattern allows data fetching to be encapsulated within the atom itself, eliminating the need for separate loading states and data states. The atom handles its own loading, error, and success states internally.

Solution

Create focused atoms for each concern:

// atoms/filterAtoms.ts
import { atom } from "jotai";

// Individual filter atoms - each is independent
export const dateRangeAtom = atom({
  start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
  end: new Date(),
});

export const categoryAtom = atom(null);
export const searchTermAtom = atom("");

// Derived atom that computes the filtered result
// Only recomputes when filter atoms change
export const filteredDataAtom = atom(async (get) => {
  const dateRange = get(dateRangeAtom);
  const category = get(categoryAtom);
  const searchTerm = get(searchTermAtom);
  const rawData = get(rawDataAtom);

  // Jotai tracks these dependencies automatically
  // and only recomputes when they change
  return rawData.filter((item) => {
    const inDateRange =
      item.date >= dateRange.start && item.date <= dateRange.end;
    const matchesCategory = !category || item.category === category;
    const matchesSearch = !searchTerm || item.name.includes(searchTerm);
    return inDateRange && matchesCategory && matchesSearch;
  });
});

Create async atoms for data fetching:

// atoms/dataAtoms.ts
import { atom } from "jotai";

// Async atom handles its own loading state
export const rawDataAtom = atom(async () => {
  const response = await fetch("/api/dashboard-data");
  if (!response.ok) {
    throw new Error("Failed to fetch dashboard data");
  }
  return response.json();
});

// Derived async atom for computed metrics
export const summaryMetricsAtom = atom(async (get) => {
  const data = await get(filteredDataAtom);

  return {
    total: data.length,
    sum: data.reduce((acc, item) => acc + item.value, 0),
    average: data.reduce((acc, item) => acc + item.value, 0) / data.length,
    max: Math.max(...data.map((item) => item.value)),
    min: Math.min(...data.map((item) => item.value)),
  };
});

Use atoms for UI state separately:

// atoms/uiAtoms.ts
import { atom } from "jotai";

// UI state atoms - these do not affect data
export const sidebarOpenAtom = atom(true);
export const modalVisibleAtom = atom(false);
export const activeTabAtom = atom("overview");

// Writing atom - only this component re-renders
// when sidebar state changes
export const toggleSidebarAtom = atom(
  (get) => get(sidebarOpenAtom),
  (get, set) => {
    set(sidebarOpenAtom, !get(sidebarOpenAtom));
  }
);

Use atoms in components:

// components/FilterPanel.tsx
import { useAtom } from "jotai";
import { dateRangeAtom, categoryAtom, searchTermAtom } from "../atoms/filterAtoms";

function FilterPanel() {
  const [dateRange, setDateRange] = useAtom(dateRangeAtom);
  const [category, setCategory] = useAtom(categoryAtom);
  const [searchTerm, setSearchTerm] = useAtom(searchTermAtom);

  return (
    
setSearchTerm(e.target.value)} />
); } // components/DataChart.tsx import { useAtomValue } from "jotai"; import { filteredDataAtom } from "../atoms/filterAtoms"; function DataChart() { // Only re-renders when filteredDataAtom changes const filteredData = useAtomValue(filteredDataAtom); return ; }

Enable the Jotai DevTools for debugging:

// components/DevTools.tsx
import { useAtomsDevtools } from "jotai-devtools";

function DevTools({ children }: { children: React.ReactNode }) {
  useAtomsDevtools("Dashboard");
  return <>{children};
}

// In App.tsx
function App() {
  return (
    
      
        
      
    
  );
}

Use write-only atoms for complex state updates:

// atoms/dataAtoms.ts
import { atom } from "jotai";

// Write-only atom for batch updates
export const batchUpdateAtom = atom(null, (get, set, updates: Partial) => {
  if (updates.dateRange !== undefined) {
    set(dateRangeAtom, updates.dateRange);
  }
  if (updates.category !== undefined) {
    set(categoryAtom, updates.category);
  }
  if (updates.searchTerm !== undefined) {
    set(searchTermAtom, updates.searchTerm);
  }
});

// Reset all filters atom
export const resetFiltersAtom = atom(null, (get, set) => {
  set(dateRangeAtom, {
    start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
    end: new Date(),
  });
  set(categoryAtom, null);
  set(searchTermAtom, "");
});

Lessons Learned

  1. Jotai's reactivity is automatic. You do not need selectors or memoization. Each atom independently triggers re-renders only in components that use it.

  2. Separate UI state from data state. Use different atoms for sidebar visibility and data fetching to prevent unrelated re-renders.

  3. Async atoms handle their own loading states. The useAtomValue hook returns the resolved value and throws if the promise rejects, which can be caught with error boundaries.

  4. Use the DevTools to visualize the atom dependency graph. This is essential for debugging complex state interactions.

  5. Write-only atoms (atom(null, (get, set) => ...)) provide a clean API for complex state transitions that modify multiple atoms atomically.

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.