AdSense Leaderboard

Google AdSense Placeholder

HEADER SLOT

Jotai Basics: Primitive State Management for React 19

Last updated:
Controlled vs Uncontrolled: React Form State Patterns

Master Jotai's atom-based state management. Learn primitive atoms, derived atoms, async atoms, and atom composition patterns with TypeScript examples.

# Jotai Basics: Primitive State Management for React 19

Jotai represents a fundamentally different approach to state management. Instead of stores containing multiple pieces of state, Jotai uses atoms — the smallest possible units of state. This primitive-first design makes it exceptionally elegant for building reactive systems where you can compose complex state from simple building blocks.

If you think of Zustand as "lightweight Redux," think of Jotai as "primitive state atoms." It's a different philosophy that many developers find more intuitive and flexible.

# Table of Contents

  1. Why Jotai? The Atom Philosophy
  2. Core Concepts
  3. Creating Atoms
  4. Using Atoms in Components
  5. Derived Atoms
  6. Async Atoms
  7. Atom Dependencies
  8. Advanced Patterns
  9. Best Practices
  10. Jotai vs Zustand
  11. Real-World Examples
  12. FAQ

# Why Jotai? The Atom Philosophy {#why-jotai}

# The Problem with Store-Based Approaches

Traditional stores (Redux, Zustand) combine multiple pieces of state:

typescript
// Store: One big object with multiple pieces of state
const useStore = create(() => ({
  user: null,
  loading: false,
  error: null,
  posts: [],
  theme: 'light',
  notifications: [],
  // ... all state together
}));

// Problem: Every change to any piece triggers re-renders
// You must manually select subsets to avoid re-renders

# The Atom-Based Approach

Jotai separates state into independent atoms:

typescript
// Atoms: Each piece of state is separate and independent
const userAtom = atom<User | null>(null);
const loadingAtom = atom(false);
const errorAtom = atom<Error | null>(null);
const postsAtom = atom<Post[]>([]);
const themeAtom = atom('light');
const notificationsAtom = atom<Notification[]>([]);

// Benefit: Components subscribe only to atoms they need
// Changing one atom doesn't affect others
// Perfect composition and fine-grained reactivity

# Why This Matters

Aspect Store-Based Atom-Based
Bundle Size Zustand 2KB Jotai 3KB
Mental Model One big object Many small atoms
Composition Manual selection Natural dependencies
Re-render Control Automatic Automatic
Learning Curve Very easy Easy-Medium
Best For Simple to medium Medium to complex

# Core Concepts {#concepts}

# What is an Atom?

An atom is the smallest possible unit of state. Think of it as a container for a single value that can be read and written.

typescript
import { atom } from 'jotai';

// ✅ Primitive atoms: Contain a single value
const countAtom = atom(0);
const nameAtom = atom('John');
const isLoadingAtom = atom(false);
const userAtom = atom<User | null>(null);

# Atoms vs Hooks

Atoms are not hooks. They're separate objects that can be used by multiple components:

typescript
// ❌ WRONG: Confusing atom with a hook
const useCount = atom(0);  // This is wrong
const count = useCount();   // This won't work

// ✅ CORRECT: Atoms are objects
const countAtom = atom(0);

// ✅ CORRECT: Use the atom in components with a hook
const count = useAtom(countAtom);

# The Three Atom States

Every atom has three operations:

  1. Read — Get the current value
  2. Write — Update the value
  3. Subscribe — React to changes

# Creating Atoms {#creating-atoms}

# Primitive Atoms

The simplest atoms hold primitive values:

typescript
import { atom } from 'jotai';

// Primitive values
export const countAtom = atom(0);
export const nameAtom = atom('');
export const isVisibleAtom = atom(false);

// Objects and arrays
interface User {
  id: string;
  name: string;
  email: string;
}

export const userAtom = atom<User | null>(null);
export const todosAtom = atom<Todo[]>([]);

// Union types
type Status = 'idle' | 'loading' | 'success' | 'error';
export const statusAtom = atom<Status>('idle');

# Atoms with Initialization Functions

For complex initial values:

typescript
// Initialize with a function
const dateAtom = atom(() => new Date());

// Initialize array
const itemsAtom = atom(() => [
  { id: 1, name: 'Item 1' },
  { id: 2, name: 'Item 2' },
]);

// Initialize with side effects
const initialDataAtom = atom(() => {
  const stored = localStorage.getItem('data');
  return stored ? JSON.parse(stored) : null;
});

# Best Practice: Organize Atoms

Keep atoms in separate files by feature:

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

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

export const userAtom = atom<User | null>(null);
export const userLoadingAtom = atom(false);
export const userErrorAtom = atom<Error | null>(null);

// atoms/theme.ts
export const themeAtom = atom<'light' | 'dark'>('light');
export const fontSizeAtom = atom(14);

// atoms/index.ts
export * from './user';
export * from './theme';

# Using Atoms in Components {#using-atoms}

# Basic Usage with useAtom

typescript
import { useAtom } from 'jotai';
import { countAtom } from './atoms';

export function Counter() {
  // ✅ useAtom provides both read and write
  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: useAtomValue

When you only need to read:

typescript
import { useAtomValue } from 'jotai';

export function CountDisplay() {
  // ✅ Only subscribes to reads, not writes
  const count = useAtomValue(countAtom);

  return <p>Count: {count}</p>;
}

# Write-Only: useSetAtom

When you only need to write:

typescript
import { useSetAtom } from 'jotai';

export function CountControls() {
  // ✅ Only get the setter function
  const setCount = useSetAtom(countAtom);

  return (
    <>
      <button onClick={() => setCount((c) => c + 1)}>+</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </>
  );
}

# Update Functions

Like useState, you can use update functions:

typescript
// ✅ Update function receives previous value
setCount((prevCount) => prevCount + 1);

// ✅ Works with objects too
setUser((prevUser) => ({
  ...prevUser,
  name: 'New Name',
}));

// ✅ Update function in component
export function TodoAdder() {
  const setTodos = useSetAtom(todosAtom);

  const addTodo = (text: string) => {
    setTodos((todos) => [
      ...todos,
      { id: Date.now(), text, completed: false },
    ]);
  };

  return (
    // JSX
  );
}

# Multiple Atoms

Use multiple atoms independently:

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

interface Post {
  id: string;
  title: string;
  userId: string;
}

export function UserProfile() {
  const [user, setUser] = useAtom(userAtom);
  const [posts, setPosts] = useAtom(postsAtom);
  const [theme] = useAtom(themeAtom);

  return (
    <div className={`profile ${theme}`}>
      <h1>{user?.name}</h1>
      <p>Posts: {posts.length}</p>
    </div>
  );
}

# Derived Atoms {#derived-atoms}

# What are Derived Atoms?

Derived atoms compute their value from other atoms. They're read-only and update automatically:

typescript
import { atom } from 'jotai';

const countAtom = atom(0);

// ✅ Derived atom: reads from countAtom
const doubleAtom = atom((get) => {
  const count = get(countAtom);
  return count * 2;
});

// Derived atom automatically updates when countAtom changes
export function Display() {
  const double = useAtomValue(doubleAtom); // Always 2x the count
}

# Common Derived Atom Patterns

typescript
interface Todo {
  id: string;
  text: string;
  completed: boolean;
}

const todosAtom = atom<Todo[]>([]);

// ✅ Filter derived atom
const completedTodosAtom = atom((get) => {
  const todos = get(todosAtom);
  return todos.filter((t) => t.completed);
});

// ✅ Count derived atom
const todoCountAtom = atom((get) => {
  const todos = get(todosAtom);
  return todos.length;
});

// ✅ Completion percentage
const completionPercentageAtom = atom((get) => {
  const todos = get(todosAtom);
  if (todos.length === 0) return 0;
  const completed = todos.filter((t) => t.completed).length;
  return Math.round((completed / todos.length) * 100);
});

// ✅ Multiple atom dependencies
const summaryAtom = atom((get) => {
  const todos = get(todosAtom);
  const count = get(todoCountAtom);
  const completed = get(completedTodosAtom).length;

  return {
    total: count,
    completed,
    pending: count - completed,
    completionRate: count === 0 ? 0 : (completed / count) * 100,
  };
});

# Chaining Derived Atoms

Derived atoms can read from other derived atoms:

typescript
// Level 1: Read from primitive atom
const countAtom = atom(5);

// Level 2: Derives from level 1
const doubleAtom = atom((get) => get(countAtom) * 2);

// Level 3: Derives from level 2
const quadrupleAtom = atom((get) => get(doubleAtom) * 2);

// Result: quadrupleAtom = 20 when countAtom = 5
export function Display() {
  const quad = useAtomValue(quadrupleAtom); // 20
}

# Async Atoms {#async-atoms}

# Basic Async Atoms

Async atoms return promises:

typescript
import { atom } from 'jotai';

interface Post {
  id: number;
  title: string;
  body: string;
}

// ✅ Async atom: Returns a promise
const postsAtom = atom(async () => {
  const response = await fetch('https://api.example.com/posts');
  return response.json() as Promise<Post[]>;
});

export function PostList() {
  // Handle loading and error automatically
  const [posts, reFetch] = useAtom(postsAtom);

  if (posts instanceof Promise) {
    return <div>Loading...</div>;
  }

  return (
    <div>
      {posts.map((post) => (
        <div key={post.id}>{post.title}</div>
      ))}
      <button onClick={() => reFetch()}>Refresh</button>
    </div>
  );
}

# Suspense Integration

Use Suspense for async atoms:

typescript
import { Suspense } from 'react';
import { useAtomValue } from 'jotai';

export function PostListWithSuspense() {
  return (
    <Suspense fallback={<div>Loading posts...</div>}>
      <PostContent />
    </Suspense>
  );
}

function PostContent() {
  // ✅ Throws promise during loading, caught by Suspense
  const posts = useAtomValue(postsAtom);

  return (
    <div>
      {posts.map((post) => (
        <div key={post.id}>{post.title}</div>
      ))}
    </div>
  );
}

# Async Atoms with Dependencies

typescript
const userIdAtom = atom(1);

// ✅ Async atom depends on another atom
const userAtom = atom(async (get) => {
  const userId = get(userIdAtom);
  const response = await fetch(`/api/users/${userId}`);
  return response.json();
});

export function UserProfile() {
  const user = useAtomValue(userAtom);
  const setUserId = useSetAtom(userIdAtom);

  if (user instanceof Promise) {
    return <div>Loading user...</div>;
  }

  return (
    <div>
      <h1>{user.name}</h1>
      <button onClick={() => setUserId(2)}>Load User 2</button>
    </div>
  );
}

# Refetch Pattern

typescript
// ✅ Create a separate atom for refetch trigger
const refetchKeyAtom = atom(0);

const dataAtom = atom(async (get) => {
  get(refetchKeyAtom); // Depend on refetch key

  const response = await fetch('/api/data');
  return response.json();
});

export function DataComponent() {
  const data = useAtomValue(dataAtom);
  const setRefetchKey = useSetAtom(refetchKeyAtom);

  const refetch = () => {
    setRefetchKey((k) => k + 1); // Triggers re-fetch
  };

  return (
    <div>
      <div>{JSON.stringify(data)}</div>
      <button onClick={refetch}>Refetch</button>
    </div>
  );
}

# Atom Dependencies {#dependencies}

# Reading Multiple Atoms

Derived atoms can read from multiple atoms:

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

// ✅ Read from multiple atoms
const fullNameAtom = atom((get) => {
  const first = get(firstNameAtom);
  const last = get(lastNameAtom);
  return `${first} ${last}`;
});

# Conditional Dependencies

typescript
const userIdAtom = atom<number | null>(null);

// ✅ Conditionally read other atoms
const userAtom = atom(async (get) => {
  const userId = get(userIdAtom);
  
  if (userId === null) {
    return null; // No user selected
  }

  const response = await fetch(`/api/users/${userId}`);
  return response.json();
});

# Writing to Multiple Atoms

typescript
const firstNameAtom = atom('');
const lastNameAtom = atom('');

// ✅ Atom that writes to multiple atoms
const setFullNameAtom = atom(
  null,
  (_get, set, fullName: string) => {
    const [first, last] = fullName.split(' ');
    set(firstNameAtom, first);
    set(lastNameAtom, last);
  }
);

export function NameEditor() {
  const setFullName = useSetAtom(setFullNameAtom);

  return (
    <input
      onChange={(e) => setFullName(e.target.value)}
      placeholder="Enter full name"
    />
  );
}

# Advanced Patterns {#advanced-patterns}

# Write-Only Atoms

Atoms that write to other atoms:

typescript
const countAtom = atom(0);

// ✅ Write-only atom
const incrementAtom = atom(null, (_get, set) => {
  set(countAtom, (c) => c + 1);
});

export function Counter() {
  const increment = useSetAtom(incrementAtom);
  const count = useAtomValue(countAtom);

  return (
    <div>
      <p>{count}</p>
      <button onClick={increment}>+</button>
    </div>
  );
}

# Atom with Both Read and Write

typescript
const baseAtom = atom(0);

// ✅ Custom read and write logic
const customAtom = atom(
  // Read: returns value * 2
  (get) => get(baseAtom) * 2,
  // Write: sets base to value / 2
  (_get, set, newValue: number) => {
    set(baseAtom, newValue / 2);
  }
);

export function Component() {
  const [value, setValue] = useAtom(customAtom);
  // value is always double the baseAtom
  // Setting value updates baseAtom accordingly
}

# Atom Effects (Side Effects)

typescript
import { atom } from 'jotai';
import { atomEffect } from 'jotai-effect';

const countAtom = atom(0);

// ✅ Atom with side effect
const countWithSideEffectAtom = atomEffect(
  (get, set) => {
    const count = get(countAtom);
    
    // Side effect: log to console
    console.log('Count changed to:', count);
    
    // Can also set other atoms
    if (count > 10) {
      set(warningAtom, true);
    }
  },
  [countAtom]
);

# Best Practices {#best-practices}

# 1. Keep Atoms Focused

typescript
// ✅ GOOD: Each atom has one responsibility
const userAtom = atom<User | null>(null);
const userLoadingAtom = atom(false);
const userErrorAtom = atom<Error | null>(null);

// ❌ AVOID: Mixing concerns
const userStateAtom = atom({
  data: null,
  loading: false,
  error: null,
});

# 2. Use Derived Atoms for Computed Values

typescript
// ❌ WRONG: Storing derived values
const itemsAtom = atom<Item[]>([]);
const countAtom = atom(0); // Manually set and kept in sync

// ✅ CORRECT: Derive the count
const itemsAtom = atom<Item[]>([]);
const countAtom = atom((get) => get(itemsAtom).length);

# 3. Organize Atoms by Feature

typescript
src/
  atoms/
    user.ts      // User-related atoms
    posts.ts     // Post-related atoms
    ui.ts        // UI state atoms
    index.ts     // Export all atoms
  components/
  hooks/

# 4. Use useAtomValue for Read-Only Components

typescript
// ✅ GOOD: Only read, no re-render on write
const count = useAtomValue(countAtom);

// ⚠️ LESS EFFICIENT: Subscribes to both read and write
const [count] = useAtom(countAtom);

# 5. Type Your Atoms

typescript
// ✅ GOOD: Explicit types
const userAtom = atom<User | null>(null);
const postsAtom = atom<Post[]>([]);

// ❌ AVOID: Implicit types
const userAtom = atom(null);  // Type is 'null'

# Jotai vs Zustand {#vs-zustand}

Aspect Jotai Zustand
Philosophy Atoms (primitives) Store (single object)
Bundle Size 3KB 2KB
Learning Curve Medium Very Easy
Composition Natural Manual selection
DevTools Limited Excellent
Async Support Built-in Simple callbacks
Derived State Native Manual selectors
Best For Complex state trees Simple to medium
Middleware Via atoms Built-in

# When to Use Jotai

✅ Complex state with many dependencies
✅ Need fine-grained reactivity
✅ Prefer functional composition
✅ Atomic state updates

# When to Use Zustand

✅ Simple applications
✅ Prefer store-based model
✅ Need excellent DevTools
✅ Team familiar with Redux

# Real-World Examples {#examples}

# Example 1: Todo Application

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

interface Todo {
  id: string;
  text: string;
  completed: boolean;
}

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

// Derived atoms
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 statsAtom = atom((get) => {
  const todos = get(todosAtom);
  return {
    total: todos.length,
    completed: todos.filter((t) => t.completed).length,
    active: todos.filter((t) => !t.completed).length,
  };
});

// Write atoms
export const addTodoAtom = atom(null, (_get, set, text: string) => {
  set(todosAtom, (todos) => [
    ...todos,
    {
      id: Date.now().toString(),
      text,
      completed: false,
    },
  ]);
});

export const toggleTodoAtom = atom(
  null,
  (_get, set, id: string) => {
    set(todosAtom, (todos) =>
      todos.map((t) =>
        t.id === id ? { ...t, completed: !t.completed } : t
      )
    );
  }
);

// Usage
export function TodoApp() {
  const [todos, setTodos] = useAtom(filteredTodosAtom);
  const [filter, setFilter] = useAtom(filterAtom);
  const stats = useAtomValue(statsAtom);
  const addTodo = useSetAtom(addTodoAtom);
  const toggleTodo = useSetAtom(toggleTodoAtom);

  return (
    <div>
      <h1>Todos ({stats.total})</h1>
      
      <input
        onKeyDown={(e) => {
          if (e.key === 'Enter') {
            addTodo((e.target as HTMLInputElement).value);
            (e.target as HTMLInputElement).value = '';
          }
        }}
        placeholder="Add a todo"
      />

      <div>
        <button onClick={() => setFilter('all')}>All ({stats.total})</button>
        <button onClick={() => setFilter('active')}>
          Active ({stats.active})
        </button>
        <button onClick={() => setFilter('completed')}>
          Completed ({stats.completed})
        </button>
      </div>

      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id)}
            />
            <span style={{ textDecoration: todo.completed ? 'line-through' : '' }}>
              {todo.text}
            </span>
          </li>
        ))}
      </ul>
    </div>
  );
}

# Example 2: Form State Management

typescript
import { atom } from 'jotai';

interface FormData {
  email: string;
  password: string;
  rememberMe: boolean;
}

// Primitive atoms for each field
export const emailAtom = atom('');
export const passwordAtom = atom('');
export const rememberMeAtom = atom(false);

// Combined form data
export const formDataAtom = atom((get) => ({
  email: get(emailAtom),
  password: get(passwordAtom),
  rememberMe: get(rememberMeAtom),
}));

// Validation atoms
export const isValidEmailAtom = atom((get) => {
  const email = get(emailAtom);
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
});

export const isPasswordStrongAtom = atom((get) => {
  const password = get(passwordAtom);
  return password.length >= 8;
});

export const isFormValidAtom = atom((get) => {
  return get(isValidEmailAtom) && get(isPasswordStrongAtom);
});

// Submit handler
export const submitFormAtom = atom(
  null,
  async (_get, _set, formData: FormData) => {
    const response = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify(formData),
    });
    return response.json();
  }
);

// Usage
export function LoginForm() {
  const [email, setEmail] = useAtom(emailAtom);
  const [password, setPassword] = useAtom(passwordAtom);
  const [rememberMe, setRememberMe] = useAtom(rememberMeAtom);
  const isFormValid = useAtomValue(isFormValidAtom);
  const isValidEmail = useAtomValue(isValidEmailAtom);
  const isPasswordStrong = useAtomValue(isPasswordStrongAtom);
  const submit = useSetAtom(submitFormAtom);

  return (
    <form
      onSubmit={async (e) => {
        e.preventDefault();
        await submit({ email, password, rememberMe });
      }}
    >
      <input
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
      />
      {!isValidEmail && <span className="error">Invalid email</span>}

      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
      />
      {!isPasswordStrong && (
        <span className="error">Password must be 8+ characters</span>
      )}

      <label>
        <input
          type="checkbox"
          checked={rememberMe}
          onChange={(e) => setRememberMe(e.target.checked)}
        />
        Remember me
      </label>

      <button disabled={!isFormValid}>Login</button>
    </form>
  );
}

# FAQ {#faq}

Q: What's the difference between an atom and a hook?

A: Atoms are values/objects. Hooks (useAtom, useAtomValue) are how you use atoms in components. An atom can be used by multiple components, while hooks are component-specific.

Q: Do I need a provider like Redux?

A: No. Jotai works without any provider setup, though you can wrap your app in <Provider> for better performance control.

Q: Can atoms replace useState?

A: Yes, for shared state. For local component state, useState is fine. Use atoms when multiple components need the same state.

Q: How do I handle async operations?

A: Create async atoms that return promises. Combine with Suspense or check if value is a Promise.

Q: What's the performance impact?

A: Jotai is highly optimized. Only components using atoms re-render when those atoms change. No unnecessary re-renders.

Q: Can I use Jotai with TypeScript?

A: Full TypeScript support. Atoms have proper generic types for type safety.

Q: How do I debug Jotai atoms?

A: Use browser console logging, or install Jotai DevTools browser extension. Atoms are accessible via useAtomValue directly.

Q: Should I split atoms or combine them?

A: Split atoms by logical concern. A form might have email, password, and submit status as separate atoms, but user profile data might be one atom.


Master Jotai's atomic approach: Once you understand atoms, you'll appreciate their elegance and composability. Start simple with primitive atoms and gradually use derived atoms for complex state logic.

Next Steps: Explore Zustand Guide for comparison, learn custom hooks patterns for component logic, and master async patterns for data fetching.

Sponsored Content

Google AdSense Placeholder

CONTENT SLOT

Sponsored

Google AdSense Placeholder

FOOTER SLOT