Front-end Engineering Lab

Atom-Based State (Recoil & Jotai)

Fine-grained state management with atoms for optimal performance

Atom-based state management breaks state into small, independent units (atoms) that components can subscribe to individually. This eliminates unnecessary re-renders and reduces boilerplate.

The Atom Concept

Traditional Context:
UserContext changes → All consumers re-render

Atoms:
nameAtom changes → Only name subscribers re-render
emailAtom changes → Only email subscribers re-render

Basic Setup

// No provider needed in most cases!
import { atom } from 'jotai';

// Define atoms
export const countAtom = atom(0);
export const nameAtom = atom('John');
export const emailAtom = atom('john@example.com');

Using Atoms

// components/Counter.tsx
import { useAtom } from 'jotai';
import { countAtom } from '@/atoms';

export function Counter() {
  const [count, setCount] = useAtom(countAtom);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(count - 1)}>Decrement</button>
    </div>
  );
}

// Read-only
export function CountDisplay() {
  const [count] = useAtom(countAtom);
  return <div>{count}</div>;
}

// Write-only (no re-render on value change!)
export function CountButtons() {
  const [, setCount] = useAtom(countAtom);
  
  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>+</button>
      <button onClick={() => setCount(c => c - 1)}>-</button>
    </>
  );
}

Derived Atoms

// atoms/user.ts
import { atom } from 'jotai';

export const firstNameAtom = atom('John');
export const lastNameAtom = atom('Doe');

// Derived atom (computed from other atoms)
export const fullNameAtom = atom(
  (get) => `${get(firstNameAtom)} ${get(lastNameAtom)}`
);

// Writable derived atom
export const fullNameWritableAtom = atom(
  (get) => `${get(firstNameAtom)} ${get(lastNameAtom)}`,
  (get, set, newName: string) => {
    const [first, last] = newName.split(' ');
    set(firstNameAtom, first);
    set(lastNameAtom, last);
  }
);

Async Atoms

// atoms/user.ts
import { atom } from 'jotai';

interface User {
  id: string;
  name: string;
  email: string;
}

// Async atom
export const userIdAtom = atom('123');

export const userAtom = atom(async (get) => {
  const userId = get(userIdAtom);
  const response = await fetch(`/api/users/${userId}`);
  return response.json() as Promise<User>;
});

// Usage (automatically suspends)
export function UserProfile() {
  const [user] = useAtom(userAtom);
  
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

// Wrap in Suspense
export function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <UserProfile />
    </Suspense>
  );
}

Atom Families (Dynamic Atoms)

// atoms/posts.ts
import { atomFamily } from 'jotai/utils';

// Create atom for each post ID
export const postAtomFamily = atomFamily((id: string) =>
  atom(async () => {
    const response = await fetch(`/api/posts/${id}`);
    return response.json();
  })
);

// Usage
export function Post({ id }: { id: string }) {
  const [post] = useAtom(postAtomFamily(id));
  return <div>{post.title}</div>;
}

Persistence

// atoms/settings.ts
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';

// Automatically persisted to localStorage
export const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light');

// Usage (automatic save/load)
export function ThemeToggle() {
  const [theme, setTheme] = useAtom(themeAtom);
  
  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Toggle Theme
    </button>
  );
}

Recoil (More Features, More Complex)

Setup

// app/layout.tsx
import { RecoilRoot } from 'recoil';

export default function RootLayout({ children }: Props) {
  return (
    <RecoilRoot>
      {children}
    </RecoilRoot>
  );
}

Atoms

// state/atoms.ts
import { atom } from 'recoil';

export const countState = atom({
  key: 'countState', // unique ID
  default: 0,
});

export const userState = atom({
  key: 'userState',
  default: {
    id: '',
    name: '',
    email: '',
  },
});

Selectors (Derived State)

// state/selectors.ts
import { selector } from 'recoil';
import { countState } from './atoms';

export const doubleCountState = selector({
  key: 'doubleCountState',
  get: ({ get }) => {
    const count = get(countState);
    return count * 2;
  },
});

// Async selector
export const userDataState = selector({
  key: 'userDataState',
  get: async ({ get }) => {
    const userId = get(userIdState);
    const response = await fetch(`/api/users/${userId}`);
    return response.json();
  },
});

Using in Components

import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { countState, doubleCountState } from '@/state/atoms';

// Read and write
export function Counter() {
  const [count, setCount] = useRecoilState(countState);
  
  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  );
}

// Read only
export function DoubleCount() {
  const doubleCount = useRecoilValue(doubleCountState);
  return <div>{doubleCount}</div>;
}

// Write only
export function CountControls() {
  const setCount = useSetRecoilState(countState);
  
  return (
    <button onClick={() => setCount(c => c + 1)}>
      Increment
    </button>
  );
}

Atom Effects (Side Effects)

// state/atoms.ts
import { atom } from 'recoil';

export const themeState = atom({
  key: 'themeState',
  default: 'light',
  effects: [
    // Sync with localStorage
    ({ onSet, setSelf }) => {
      const stored = localStorage.getItem('theme');
      if (stored) setSelf(stored);
      
      onSet((newValue) => {
        localStorage.setItem('theme', newValue);
      });
    },
    // Sync with document class
    ({ onSet }) => {
      onSet((newValue) => {
        document.documentElement.classList.toggle('dark', newValue === 'dark');
      });
    },
  ],
});

Comparison: Jotai vs Recoil

// Jotai (simpler)
const countAtom = atom(0);
const [count, setCount] = useAtom(countAtom);

// Recoil (more verbose)
const countState = atom({
  key: 'countState',
  default: 0,
});
const [count, setCount] = useRecoilState(countState);

Jotai Pros:

  • No provider needed
  • Less boilerplate
  • Smaller bundle size
  • TypeScript-first

Recoil Pros:

  • More mature
  • Better DevTools
  • Atom effects
  • More documentation

Advanced Patterns

Optimistic Updates

// atoms/posts.ts
import { atom } from 'jotai';

export const postsAtom = atom<Post[]>([]);

export const createPostAtom = atom(
  null,
  async (get, set, newPost: Omit<Post, 'id'>) => {
    // Optimistic update
    const tempPost = { ...newPost, id: 'temp' };
    set(postsAtom, [...get(postsAtom), tempPost]);
    
    try {
      // API call
      const response = await fetch('/api/posts', {
        method: 'POST',
        body: JSON.stringify(newPost),
      });
      const savedPost = await response.json();
      
      // Replace temp with real
      set(postsAtom, posts =>
        posts.map(p => p.id === 'temp' ? savedPost : p)
      );
    } catch (error) {
      // Rollback on error
      set(postsAtom, posts =>
        posts.filter(p => p.id !== 'temp')
      );
      throw error;
    }
  }
);

Reset Pattern

// atoms/form.ts
import { atom } from 'jotai';
import { RESET } from 'jotai/utils';

export const nameAtom = atom('');
export const emailAtom = atom('');

export const formAtom = atom(
  (get) => ({
    name: get(nameAtom),
    email: get(emailAtom),
  }),
  (get, set, update: typeof RESET | Partial<{ name: string; email: string }>) => {
    if (update === RESET) {
      set(nameAtom, '');
      set(emailAtom, '');
    } else {
      if (update.name !== undefined) set(nameAtom, update.name);
      if (update.email !== undefined) set(emailAtom, update.email);
    }
  }
);

// Usage
const [, setForm] = useAtom(formAtom);
setForm(RESET); // Reset all fields

Computed Arrays

// atoms/todos.ts
import { atom } from 'jotai';

export const todosAtom = atom<Todo[]>([]);
export const filterAtom = atom<'all' | 'active' | 'completed'>('all');

export const filteredTodosAtom = atom((get) => {
  const todos = get(todosAtom);
  const filter = get(filterAtom);
  
  switch (filter) {
    case 'active':
      return todos.filter(t => !t.completed);
    case 'completed':
      return todos.filter(t => t.completed);
    default:
      return todos;
  }
});

export const todoStatsAtom = atom((get) => {
  const todos = get(todosAtom);
  return {
    total: todos.length,
    active: todos.filter(t => !t.completed).length,
    completed: todos.filter(t => t.completed).length,
  };
});

Testing

// components/__tests__/Counter.test.tsx
import { renderHook, act } from '@testing-library/react';
import { useAtom } from 'jotai';
import { countAtom } from '@/atoms';

test('countAtom increments', () => {
  const { result } = renderHook(() => useAtom(countAtom));
  
  act(() => {
    result.current[1](5);
  });
  
  expect(result.current[0]).toBe(5);
});

DevTools

// Install jotai-devtools
import { useAtomsDebugValue } from 'jotai-devtools';

export function DebugAtoms() {
  useAtomsDebugValue();
  return null;
}

// Add to app
<DebugAtoms />

Best Practices

  1. Small atoms: One piece of state per atom
  2. Derive don't duplicate: Use computed atoms
  3. Co-locate: Define atoms near usage
  4. Type atoms: Full TypeScript support
  5. Lazy loading: Only create atoms when needed
  6. Persistence: Use atomWithStorage for saved state
  7. Testing: Test atoms independently
  8. DevTools: Use for debugging
  9. Async carefully: Handle loading/error states
  10. Reset pattern: Clear form state easily

When to Use Atoms

Use atoms when:

  • Need fine-grained subscriptions
  • Performance is critical
  • Minimal boilerplate desired
  • Lots of derived state
  • Dynamic state (atom families)

Don't use atoms when:

  • Simple global state (use Context)
  • Need time-travel (use Redux)
  • Team unfamiliar with concept
  • Debugging tools essential

Common Pitfalls

Too many atoms: One per field
Group related state

Not using derived atoms: Duplicate state
Compute from source atoms

Missing Suspense: Async atoms crash
Wrap in Suspense boundary

Forgetting write-only: Unnecessary re-renders
Use [, setAtom] for setters

Complex atom logic: Hard to debug
Keep atoms simple, compose with derived

Atom-based state provides fine-grained reactivity with minimal boilerplate—perfect for performance-critical apps!

On this page