← Back to Posts

Atomic State Management: Building Composable React Applications

2026-01-22 · 5 min read, 7 min code

Atomic state management breaks state into small, composable pieces called "atoms" that automatically update when their dependencies change. Instead of one monolithic store, you have a graph of interconnected atoms that enable fine-grained reactivity.

Table of Contents

What is Atomic State?

Atomic state breaks state into small, independent units called atoms that compose together. Each atom represents a single piece of state, and atoms can depend on other atoms to create derived state.

Key properties:

  • Independence: Each atom is self-contained
  • Composability: Atoms create a dependency graph
  • Fine-grained reactivity: Components only re-render when their specific atoms change
// Traditional: one big store
const store = {
  user: { name: 'John', email: 'john@example.com' },
  filters: { category: 'electronics', price: [0, 100] },
}

// Atomic: many small atoms
const userNameAtom = atom('John')
const categoryFilterAtom = atom('electronics')
const priceFilterAtom = atom([0, 100])

// Derived atoms compose together
const filteredProductsAtom = atom((get) => {
  const products = get(productsAtom)
  const category = get(categoryFilterAtom)
  return products.filter(p => p.category === category)
})

Why Atomic State?

Problem: Unnecessary Re-renders

Traditional state management can trigger re-renders in components that don't need the updated data:

// Zustand: component re-renders when cart changes
const useStore = create((set) => ({
  theme: 'dark',
  cart: [...],
}))

function ThemeToggle() {
  const theme = useStore((state) => state.theme)
  const cart = useStore((state) => state.cart) // ❌ Unnecessary
}

With atomic state, components subscribe to specific atoms:

// Jotai: only re-renders when theme changes
const themeAtom = atom('dark')
const cartAtom = atom([...])

function ThemeToggle() {
  const [theme, setTheme] = useAtom(themeAtom) // ✅ Fine-grained
}

Problem: Complex Derived State

Traditional stores require manual memoization. Atomic state automatically memoizes derived atoms:

// Zustand: runs on every access
get filteredProducts() {
  const { products, filters } = get()
  return products.filter(/* ... */) // Not memoized
}

// Jotai: only recomputes when dependencies change
const filteredProductsAtom = atom((get) => {
  const products = get(productsAtom)
  const category = get(categoryFilterAtom)
  return products.filter(p => p.category === category) // Auto-memoized
})

Problem: State Organization

Traditional stores centralize everything, making dependencies unclear. Atomic state naturally organizes itself through the dependency graph.

Atomic vs Traditional State Management

AspectTraditional (Redux/Zustand)Atomic (Jotai/Recoil)
State StructureSingle storeMany atoms
Re-rendersManual optimization with selectorsAutomatic fine-grained subscriptions
Derived StateComputed in store or selectorsDerived atoms (automatic memoization)
Code OrganizationCentralized storeDistributed atoms
Learning CurveFamiliar (store pattern)New mental model (atoms)
Bundle SizeVaries (Redux ~12KB, Zustand ~1KB)Jotai ~3KB, Recoil ~14KB
TypeScriptGoodExcellent (better inference)
DevToolsExcellentGood

Jotai: Minimalist Atomic State

Jotai is a minimal atomic state library (~3KB) with excellent TypeScript support.

Core Concepts

Primitive Atoms: Basic state containers

import { atom } from 'jotai'

const countAtom = atom(0)
const nameAtom = atom('John')

Derived Atoms: Computed from other atoms

// Read-only
const doubleCountAtom = atom((get) => get(countAtom) * 2)

// Read-write
const incrementAtom = atom(null, (get, set) => {
  set(countAtom, get(countAtom) + 1)
})

Async Atoms: For async operations

const userIdAtom = atom(1)
const userDataAtom = atom(async (get) => {
  const userId = get(userIdAtom)
  const response = await fetch(`/api/users/${userId}`)
  return response.json()
})

Example: Dashboard Filters

Base atoms:

const dateRangeAtom = atom<[Date, Date]>([new Date('2026-01-01'), new Date('2026-01-31')])
const selectedRegionsAtom = atom<string[]>([])
const viewModeAtom = atomWithStorage<'chart' | 'table'>('dashboard-view', 'chart')

Derived query params:

const queryParamsAtom = atom((get) => {
  const [startDate, endDate] = get(dateRangeAtom)
  const regions = get(selectedRegionsAtom)
  return {
    startDate: startDate.toISOString(),
    endDate: endDate.toISOString(),
    regions: regions.join(','),
  }
})

Async data fetching:

const dashboardDataAtom = atom(async (get) => {
  const params = get(queryParamsAtom)
  const response = await fetch(`/api/dashboard?${new URLSearchParams(params)}`)
  return response.json()
})

// Derived: calculate totals
const totalsAtom = atom(async (get) => {
  const data = await get(dashboardDataAtom)
  return {
    totalRevenue: data.metrics.reduce((sum, m) => sum + m.revenue, 0),
    totalUsers: data.metrics.reduce((sum, m) => sum + m.users, 0),
  }
})

Component usage:

function DashboardFilters() {
  const [dateRange, setDateRange] = useAtom(dateRangeAtom)
  const [regions, setRegions] = useAtom(selectedRegionsAtom)
  // Only re-renders when filters change
  return <div>{/* filters UI */}</div>
}

function DashboardTotals() {
  const totals = useAtomValue(totalsAtom) // Only re-renders when totals change
  return <div>{totals.totalRevenue}</div>
}

Recoil: Meta's Atomic Solution

Recoil is Meta's atomic state library (~14KB). More feature-rich than Jotai but larger. See Comparison: Jotai vs Recoil for a detailed comparison.

Recoil Example:

import { atom, selector, useRecoilState, useRecoilValue } from 'recoil'

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

const doubleCountSelector = selector({
  key: 'doubleCountSelector',
  get: ({ get }) => get(countState) * 2,
})

function Counter() {
  const [count, setCount] = useRecoilState(countState)
  const doubleCount = useRecoilValue(doubleCountSelector)
  return <div>{count} × 2 = {doubleCount}</div>
}

Recoil requires unique key strings for DevTools support.

Real-World Examples

Example 1: E-commerce Product Filters

Base atoms:

const searchQueryAtom = atom('')
const categoryAtom = atom<string | null>(null)
const priceRangeAtom = atom<[number, number]>([0, 1000])
const sortByAtom = atomWithStorage<'price' | 'name' | 'rating'>('sort', 'price')
const productsAtom = atom<Product[]>([])

Derived: filtered and sorted products

Show code (23 lines, typescript)
const filteredProductsAtom = atom((get) => { const products = get(productsAtom) const query = get(searchQueryAtom).toLowerCase() const category = get(categoryAtom) const [minPrice, maxPrice] = get(priceRangeAtom) return products.filter(p => { if (query && !p.name.toLowerCase().includes(query)) return false if (category && p.category !== category) return false if (p.price < minPrice || p.price > maxPrice) return false return true }) }) const sortedProductsAtom = atom((get) => { const products = get(filteredProductsAtom) const sortBy = get(sortByAtom) return [...products].sort((a, b) => { if (sortBy === 'price') return a.price - b.price if (sortBy === 'name') return a.name.localeCompare(b.name) return b.rating - a.rating }) })

Pagination:

const pageAtom = atom(1)
const paginatedProductsAtom = atom((get) => {
  const products = get(sortedProductsAtom)
  const page = get(pageAtom)
  const start = (page - 1) * 12
  return products.slice(start, start + 12)
})

Advanced Patterns

Atom Families

For managing collections of similar state:

import { atomFamily, atomWithStorage } from 'jotai/utils'

const userPreferenceAtomFamily = atomFamily((key: string) =>
  atomWithStorage(`pref-${key}`, null)
)

function UserPreferences() {
  const [theme, setTheme] = useAtom(userPreferenceAtomFamily('theme'))
  const [language, setLanguage] = useAtom(userPreferenceAtomFamily('language'))
  return <div>{/* preferences UI */}</div>
}

Split Atoms

For minimizing re-renders in lists:

Show code (23 lines, typescript)
import { atom, useAtom, useAtomValue, PrimitiveAtom } from 'jotai' import { splitAtom } from 'jotai/utils' type Todo = { id: number; text: string; done: boolean } const todosAtom = atom<Todo[]>([ { id: 1, text: 'Learn Jotai', done: false }, { id: 2, text: 'Build app', done: false }, ]) const todoAtomsAtom = splitAtom(todosAtom) function TodoList() { const todoAtoms = useAtomValue(todoAtomsAtom) return ( <ul> {todoAtoms.map((todoAtom) => ( <TodoItem key={todoAtom.toString()} todoAtom={todoAtom} /> ))} </ul> ) } function TodoItem({ todoAtom }: { todoAtom: PrimitiveAtom<Todo> }) { const [todo, setTodo] = useAtom(todoAtom) return <li>{todo.text}</li> }

Async Atoms with Suspense

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

const userIdAtom = atom(1)
const userAtom = atom(async (get) => {
  const userId = get(userIdAtom)
  const response = await fetch(`/api/users/${userId}`)
  return response.json()
})

function UserProfile() {
  const user = useAtomValue(userAtom) // Suspends until data loads
  return <div>{user.name}</div>
}

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

When to Use Atomic State

Use CaseWhen to Use / Avoid
Complex derived state✅ Use: Multiple filters, calculations, transformations
Fine-grained reactivity✅ Use: Need to minimize re-renders
Composable state✅ Use: State that naturally breaks into pieces
Form state✅ Use: Multiple fields with interdependent validation
Dashboard/analytics✅ Use: Many metrics and filters
Real-time applications✅ Use: Frequent updates that need efficient propagation
Simple state❌ Avoid: A few pieces of global state (use Zustand)
Server state❌ Avoid: Data from APIs (use React Query)
Team unfamiliarity❌ Avoid: Team prefers traditional patterns
Small applications❌ Avoid: Overkill for simple apps

Performance Considerations

Automatic Memoization: Derived atoms only recompute when dependencies change:

const filteredProductsAtom = atom((get) => {
  const products = get(productsAtom)
  const category = get(categoryFilterAtom)
  const priceRange = get(priceRangeAtom)
  return products.filter(p => 
    p.category === category && 
    p.price >= priceRange[0] && 
    p.price <= priceRange[1]
  ) // Only runs when deps change
})

Selective Subscriptions: Components only re-render when their subscribed atoms change:

function ProductList() {
  const products = useAtomValue(filteredProductsAtom) // Only re-renders when this changes
  return <div>{/* ... */}</div>
}

Atom Splitting: For large lists, use splitAtom to minimize re-renders (see Split Atoms pattern above).

Best Practices

1. Organize Atoms by Domain:

// atoms/user.ts
export const userIdAtom = atom<string | null>(null)
export const userAtom = atom(async (get) => { /* ... */ })

// atoms/products.ts
export const productsAtom = atom<Product[]>([])

2. Use TypeScript:

const countAtom = atom<number>(0)
const userAtom = atom<User | null>(null)
// Derived atoms infer types automatically
const doubleCountAtom = atom((get) => get(countAtom) * 2)

3. Name Atoms Clearly:

// ✅ Good
const userEmailAtom = atom('')
const isAuthenticatedAtom = atom(false)

// ❌ Bad
const emailAtom = atom('') // Which email?
const authAtom = atom(false) // Unclear

4. Keep Atoms Small:

// ✅ Good: small, focused atoms
const firstNameAtom = atom('')
const lastNameAtom = atom('')
const fullNameAtom = atom((get) => `${get(firstNameAtom)} ${get(lastNameAtom)}`)

// ❌ Bad: large, complex atom
const userAtom = atom({ firstName: '', lastName: '', email: '', /* ... */ })

5. Use Utilities:

import { atomWithStorage, atomWithDefault } from 'jotai/utils'

const themeAtom = atomWithStorage('theme', 'dark')
const userAtom = atomWithDefault(async () => {
  const response = await fetch('/api/user')
  return response.json()
})

Comparison: Jotai vs Recoil

FeatureJotaiRecoil
Bundle Size~3KB~14KB
API StyleFunctionalObject-oriented
Keys RequiredNoYes (for DevTools)
TypeScriptExcellentGood
Async AtomsBuilt-inRequires Suspense
Learning CurveEasierSteeper
CommunityGrowingEstablished (Meta)
DevToolsCommunityOfficial

Recommendation: Start with Jotai unless you need Recoil's specific features or are already using other Meta libraries.

Conclusion

Atomic state management excels when you need fine-grained reactivity, complex derived state, and automatic memoization. Jotai is my preferred choice for its simplicity (~3KB) and excellent TypeScript support. Recoil is a solid alternative if you prefer Meta's approach.

Recommendations:

  • React Query for server state
  • Jotai for complex client state with derived values
  • Zustand for simple global client state
  • URL state for shareable state

Atomic state isn't always the answer, but when you have complex state relationships, it's an excellent tool.

Further Reading