Introduction
State management is one of the most challenging aspects of building modern React applications. As applications grow in complexity, managing state across components while maintaining performance becomes increasingly difficult. Enter Jotai--a lightweight, atomic state management library that offers a fresh perspective on how we handle state in React and Next.js applications.
Jotai takes a fundamentally different approach compared to traditional state management solutions. Rather than requiring a single global store that holds all application state, Jotai embraces atomic state management where each piece of state exists as an independent atom. This granular approach leads to more precise re-renders, better performance, and cleaner code that naturally follows React's component model.
In this guide, we'll explore how to effectively use Jotai with Next.js, covering everything from basic setup to advanced patterns for server-side rendering and performance optimization. Whether you're building a small application or a large-scale production system, understanding these concepts will help you make informed decisions about state management in your Next.js projects.
Why Atomic State Management Matters
Traditional state management libraries often treat the entire application state as a single object. While this approach works, it can lead to several issues:
- Unnecessary re-renders: Updating one piece of state can trigger re-renders across components that don't actually use that data
- Boilerplate code: Managing complex state transformations requires significant setup and configuration
- Tight coupling: State logic becomes intertwined with component logic, making testing and maintenance harder
Jotai's atomic approach solves these problems by treating state as composable, independent units. Each atom is responsible for a single piece of state, and components only re-render when the specific atoms they're subscribed to change. This precision leads to better performance and more maintainable code.
What Makes Jotai Different
Named after the Japanese word for "state," Jotai was designed from the ground up with simplicity in mind. The library's philosophy emphasizes minimalism and predictability, making it an excellent choice for Next.js applications that require both developer experience and runtime performance. Unlike Redux, which requires providers, actions, and reducers, Jotai's core API consists of just one function: atom. This simplicity doesn't sacrifice power--Jotai supports derived atoms, async state, and sophisticated patterns through composition rather than complex configuration.
The atomic model also aligns naturally with React's component architecture. When you subscribe a component to an atom, only that component re-renders when the atom changes. This fine-grained reactivity is built into the library's core and requires no additional optimization from the developer. For teams building high-performance React applications, this automatic optimization can significantly reduce rendering issues and improve user experience.
For applications requiring advanced server-side rendering techniques, understanding atomic state management pairs well with streaming SSR patterns that can further enhance performance and user perceived load times.
Core Philosophy
Jotai's design philosophy centers on a few key principles that guide its development and usage. First, simplicity over configuration--every API should be intuitive and require minimal setup. Second, composability over complexity--powerful features emerge from combining simple pieces rather than adding complex single-purpose functions. Third, performance by default--reactivity should be efficient without requiring manual optimization.
These principles make Jotai particularly well-suited for Next.js applications, where server-side rendering and performance are critical concerns. The library's small footprint means minimal impact on bundle size, while its request-scoped state isolation ensures secure multi-user operation during SSR.
Why modern web development teams are choosing atomic state management
Minimal Bundle Size
Jotai's core is incredibly lightweight at approximately 1KB gzipped, ensuring your application loads quickly without the overhead of larger state management libraries.
SSR Compatible
Built-in support for server-side rendering with request-scoped state isolation, preventing data leakage between users in Next.js applications.
Fine-Grained Reactivity
Components only re-render when the specific atoms they depend on change, eliminating unnecessary renders and improving overall performance.
Simple API
The API surface is intentionally small and intuitive, reducing the learning curve while still supporting advanced use cases through composition.
Setting Up Jotai in Your Next.js Project
Getting started with Jotai in a Next.js project is straightforward. The first step is installation, after which you can begin creating and using atoms throughout your application.
Installation
Install Jotai using your preferred package manager:
npm install jotai
# or
yarn add jotai
# or
pnpm add jotai
The core package includes everything you need for basic state management. For additional utilities like location sync or persistence, you can install supplementary packages as needed. The modular architecture means you only import what you use, keeping your bundle size minimal.
Provider Configuration for SSR
While Jotai can work without a Provider in simple cases, Next.js applications benefit from using the Provider component to isolate state per request. This prevents state leakage between users in server-side rendered applications. The Provider creates a new store instance for each request, ensuring secure and predictable behavior in multi-user environments.
For the App Router (Next.js 13+), add the Provider in your root layout:
// app/layout.tsx
'use client'
import { Provider } from 'jotai'
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<Provider>
{children}
</Provider>
</body>
</html>
)
}
For the Pages Router, add the Provider in _app.tsx:
// pages/_app.tsx
import type { AppProps } from 'next/app'
import { Provider } from 'jotai'
export default function App({ Component, pageProps }: AppProps) {
return (
<Provider>
<Component {...pageProps} />
</Provider>
)
}
The Provider creates a request-scoped store, ensuring that server-rendered state doesn't leak between different users' requests. This is crucial for security and data integrity in multi-user applications. According to the Jotai official documentation, this pattern is essential for any production Next.js application using Jotai.
Provider-Less Mode
For simpler applications or specific use cases, Jotai supports operation without a Provider. In this mode, atoms use a default global store that persists across the application lifecycle. This can be useful for small widgets, prototypes, or isolated components that don't need state isolation. However, for full Next.js applications, especially those with server-side rendering, the Provider pattern is strongly recommended to avoid unexpected behavior and security issues.
When using provider-less mode, be aware that all components sharing the same atom will read from and write to the same store. This is acceptable for single-user applications or components with no sensitive data, but should be avoided in server-rendered contexts where multiple users could potentially access shared state.
Core Atom Patterns
Jotai's power comes from its simple yet flexible atom system. Understanding the different types of atoms and how they compose together is key to effective state management in your applications.
Primitive atoms hold simple values and serve as the foundation of your state. They can hold any JavaScript value including primitives, objects, and arrays. A primitive atom is created by passing an initial value to the atom function, and it automatically infers the type from that initial value.
Derived atoms compute their values based on other atoms using a getter function. This enables reactive computations where the derived value automatically updates whenever any of its dependencies change. Derived atoms are read-only by default but can be combined with write functions for more complex patterns.
Read-write atoms provide custom logic for both reading and writing state. These are useful when you need to enforce invariants, validate data, or coordinate updates across multiple atoms in a single operation.
1// src/atoms/counter.ts2import { atom } from 'jotai'3 4// Primitive atom - holds a simple value5export const countAtom = atom(0)6 7// Atom with initial value from a source8export const userIdAtom = atom<string | null>(null)9 10// Read-write atom - can be read and updated11const basePriceAtom = atom(100)12 13// Derived atom - computes value from other atoms14export const priceWithTaxAtom = atom((get) => {15 const basePrice = get(basePriceAtom)16 return basePrice * 1.13 // 13% tax17})18 19// Read-write atom with custom getter and setter20export const incrementAtom = atom(21 (get) => get(countAtom),22 (get, set, amount: number) => {23 const current = get(countAtom)24 set(countAtom, current + amount)25 }26)Server-Side Rendering Considerations
Server-side rendering introduces unique challenges for state management. The key concern is ensuring that server-rendered state properly hydrates on the client without causing mismatches or data leaks. Understanding these considerations is essential for building robust Next.js applications with Jotai.
Understanding the Hydration Challenge
By default, Jotai uses a global store that persists across the application lifecycle. In a server environment, this global store is shared between all requests, which can lead to serious bugs where one user's data appears in another user's session. The Provider component solves this by creating a new store instance for each request, providing proper isolation.
The hydration mismatch occurs when the server renders with one state and the client renders with a different state. This can cause the dreaded "Text content does not match server-rendered HTML" warning and potentially break the application. Proper state initialization through hydration prevents these issues and ensures a smooth transition from server to client.
Using useHydrateAtoms
The useHydrateAtoms hook allows you to pre-populate atoms with server-side values before the client-side render. This ensures that the client starts with the same state the server rendered, eliminating hydration mismatches. The hook takes an array of tuples containing atoms and their initial values:
import { useHydrateAtoms } from 'jotai/react'
// Define your atoms
const userAtom = atom<User | null>(null)
const themeAtom = atom<'light' | 'dark'>('light')
function App({ initialUser, initialTheme }) {
// Hydrate atoms with server data
useHydrateAtoms([
[userAtom, initialUser],
[themeAtom, initialTheme]
])
// Now these atoms have the server-provided values
const [user] = useAtom(userAtom)
const [theme] = useAtom(themeAtom)
return (
<div className={theme}>
Welcome, {user?.name}
</div>
)
}
As demonstrated in tutorials from LogRocket, this pattern is essential for passing server-fetched data to client components. The hydration process ensures that the initial render on both server and client produce identical HTML, which is critical for Next.js performance and SEO.
SSR-Safe Async Patterns
When working with async data in SSR contexts, you need to handle promises carefully. The atom's read function cannot return a promise during SSR because the rendering is synchronous. Instead, pre-fetch data on the server and pass it through initial state:
// Anti-pattern - this won't work in SSR
const postAtom = atom(async (get) => {
const id = get(postIdAtom)
return fetch(`/api/posts/${id}`).then(r => r.json())
})
// Correct approach - pre-fetch and hydrate
const postAtom = atom((get) => {
const id = get(postIdAtom)
const post = get(prefetchedPostAtom)
return post
})
The correct approach separates data fetching from state management. Fetch data in your server component or getServerSideProps, then pass the pre-fetched data to client components via hydration. This pattern ensures your SSR renders complete synchronously while still supporting async data loading on the client.
Preventing State Leakage
State leakage between requests is one of the most serious security concerns in SSR applications. Without proper isolation, one user's session data could appear in another user's browser, potentially exposing sensitive information. The Provider component with request-scoped stores is your primary defense against this vulnerability.
In development mode, state leakage might only cause confusing bugs. In production with multiple concurrent users, it could expose personal data, authentication tokens, or other sensitive information. Always use the Provider pattern in production Next.js applications, even if you're only using Jotai for seemingly innocuous state like UI preferences.
Performance During Hydration
Hydration can be performance-intensive if you're hydrating large amounts of state. To minimize impact, consider these strategies: only hydrate the state necessary for the initial render, use selective hydration where only interactive parts of the page receive client-side state, and consider deferring non-critical state hydration until after the page becomes interactive.
For applications with complex state requirements, breaking state into granular atoms reduces the amount of data that needs to be transferred during hydration. Each atom is independent, so you can hydrate only the atoms each specific component needs, rather than loading an entire application state object.
For more advanced patterns involving server actions and infinite scroll implementations, explore our guide on implementing infinite scroll with Next.js server actions to see how atomic state can coordinate complex UI interactions.
// app/layout.tsx
'use client'
import { Provider } from 'jotai'
export default function Layout({ children }) {
return <Provider>{children}</Provider>
}
// Using atoms in a component
'use client'
import { useAtom } from 'jotai'
import { countAtom } from '@/atoms/counter'
export function Counter() {
const [count, setCount] = useAtom(countAtom)
return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}
Advanced Patterns and Best Practices
Syncing State with URL
For state that should be shareable via URL--like filters, pagination, or view preferences--Jotai provides atomWithHash for two-way binding with URL parameters. This pattern is particularly useful for search interfaces and data-heavy applications where users need to share or bookmark specific views.
import { atomWithHash } from 'jotai-location'
// Creates an atom synced with the 'filter' URL parameter
const filterAtom = atomWithHash('filter', 'all', {
replaceState: true, // Updates browser history
})
// Usage in a component
function FilterSelect() {
const [filter, setFilter] = useAtom(filterAtom)
return (
<select value={filter} onChange={e => setFilter(e.target.value)}>
<option value="all">All Items</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
</select>
)
}
The atomWithHash utility automatically synchronizes the atom value with the URL hash or search parameters. This enables deep linking, browser history integration, and shareable URLs without additional code. As noted in Bits and Pieces, this pattern is invaluable for building bookmarkable, shareable application states.
Atom Families for Dynamic State
When you need multiple atoms of the same type--like a todo list where each todo has its own completion state--atom families provide an elegant solution. The atomFamily utility from Jotai's utils package creates atoms on demand based on a key parameter:
import { atomFamily } from 'jotai/utils'
// Create a family of atoms
const todoCompletionAtomFamily = atomFamily((todoId: string) => {
return atom(false)
})
// Usage
function TodoItem({ id }) {
const [isComplete, setComplete] = useAtom(todoCompletionAtomFamily(id))
return (
<label>
<input
type="checkbox"
checked={isComplete}
onChange={e => setComplete(e.target.checked)}
/>
Task {id}
</label>
)
}
Atom families are particularly powerful for lists, grids, and any scenario where you have dynamic collections of similar state. Each item gets its own atom, but the atoms are managed efficiently without creating thousands of independent atom definitions.
Performance Optimization
Jotai's fine-grained reactivity means components only re-render when their specific atoms change. However, there are patterns that help maintain optimal performance even as your application grows in complexity.
Split atoms for related state: Rather than storing related values in a single object atom, separate them into individual atoms. This ensures that only components interested in the changing value re-render.
// Instead of one large object
const userStateAtom = atom({ name: '', email: '', age: 0 })
// Use separate atoms
const userNameAtom = atom('')
const userEmailAtom = atom('')
const userAgeAtom = atom(0)
Use selectors strategically: Derived atoms can act as selectors, computing specific views of larger state objects. This allows components to subscribe only to the data they need while keeping related state organized together.
// Only subscribe to what you need
const [user] = useAtom(userAtom)
const name = user.name // Access nested data
// Versus subscribing to derived atom
const nameAtom = atom((get) => get(userAtom).name)
const [name] = useAtom(nameAtom)
Testing Strategies
Testing atoms is straightforward due to their isolated nature. You can test pure atom logic independently of components, and component integration tests verify proper usage. For pure atom tests, use a simple render-and-update pattern:
import { atom, useAtom } from 'jotai'
import { renderHook, act } from '@testing-library/react'
const countAtom = atom(0)
test('atom updates correctly', () => {
const { result } = renderHook(() => useAtom(countAtom))
// Initial value
expect(result.current[0]).toBe(0)
// Update value
act(() => {
result.current[1](5)
})
expect(result.current[0]).toBe(5)
})
For more complex scenarios, consider using a testing library like Vitest with React Testing Library to test atoms in the context of your actual components.
Project Organization
As your application grows, organizing atoms becomes critical for maintainability. A recommended structure groups atoms by feature rather than type:
src/
atoms/
index.ts # Global, cross-cutting atoms
user/ # User-related state
index.ts
profile.ts
auth.ts
todos/ # Feature-specific atoms
index.ts
todoItem.ts
This organization makes it easy to find related state, understand dependencies, and maintain separation of concerns. Each feature folder contains all atoms and related utilities for that feature, keeping related logic together.
TypeScript Best Practices
Jotai has excellent TypeScript support with full type inference. Atoms automatically infer types from their initial values, reducing explicit type annotations while maintaining type safety. For more complex scenarios, explicit typing ensures correctness:
interface Todo {
id: string
text: string
completed: boolean
}
// Type is inferred from initial value
export const todosAtom = atom<Todo[]>([])
// Generic atom factory
export function createAtomWithDefault<T>(defaultValue: T) {
return atom<T>(defaultValue)
}
Combining with Other Patterns
Jotai atoms can coexist with React Context, using each for appropriate use cases. Atoms excel at global shared state that multiple components need to read and write, while Context works well for dependency injection, theming, and other cross-cutting concerns. This complementary approach lets you use the right tool for each situation without forcing a single pattern across your entire application.
For teams working across multiple frameworks, comparing approaches between Vue 3 and React for state management can help inform architectural decisions when building multi-platform applications.
For example, you might use Jotai for user session state and data caching, while using Context for passing theme preferences or providing localized formatting utilities. Both patterns can coexist in the same component tree without conflict.
Frequently Asked Questions
Sources
- Jotai Official Documentation - Next.js Guide - Primary source for hydration, Provider, and SSR patterns
- LogRocket: Using Jotai with Next.js - Practical patterns and code examples
- Bits and Pieces: Using Jotai in Your React Application - Best practices for maintainable state management
- Dev.to: Getting Started with Jotai - Simple guide to state management with Jotai