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?
- Why Atomic State?
- Atomic vs Traditional State Management
- Jotai: Minimalist Atomic State
- Recoil: Meta's Atomic Solution
- Real-World Examples
- Advanced Patterns
- When to Use Atomic State
- Performance Considerations
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
| Aspect | Traditional (Redux/Zustand) | Atomic (Jotai/Recoil) |
|---|---|---|
| State Structure | Single store | Many atoms |
| Re-renders | Manual optimization with selectors | Automatic fine-grained subscriptions |
| Derived State | Computed in store or selectors | Derived atoms (automatic memoization) |
| Code Organization | Centralized store | Distributed atoms |
| Learning Curve | Familiar (store pattern) | New mental model (atoms) |
| Bundle Size | Varies (Redux ~12KB, Zustand ~1KB) | Jotai ~3KB, Recoil ~14KB |
| TypeScript | Good | Excellent (better inference) |
| DevTools | Excellent | Good |
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 Case | When 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
| Feature | Jotai | Recoil |
|---|---|---|
| Bundle Size | ~3KB | ~14KB |
| API Style | Functional | Object-oriented |
| Keys Required | No | Yes (for DevTools) |
| TypeScript | Excellent | Good |
| Async Atoms | Built-in | Requires Suspense |
| Learning Curve | Easier | Steeper |
| Community | Growing | Established (Meta) |
| DevTools | Community | Official |
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
- Jotai Documentation - Official docs with examples
- Recoil Documentation - Meta's atomic state library
- Jotai vs Recoil Comparison - Detailed comparison
- Atomic Design Principles - Design philosophy that inspired atomic state
- Jotai Recipes - Community patterns and examples