Next.js Jotai
Introductionā
Jotai is a primitive and flexible state management library for React applications that works exceptionally well with Next.js. Created by the team behind Zustand, Jotai takes inspiration from React's useState but provides a global state solution with an atomic approach. Unlike other state management libraries like Redux or MobX, Jotai focuses on simplicity and minimal API surface, making it perfect for beginners while still being powerful enough for complex applications.
In this guide, we'll explore how to use Jotai in your Next.js applications, understand its atomic model, and see how it handles server-side rendering (SSR) in the Next.js environment.
What is Jotai?ā
Jotai (which means "state" in Japanese) follows an atomic approach to state management. Instead of creating a large global state object, Jotai lets you define small, independent "atoms" of state that can be composed together. This makes your state management more modular and easier to reason about.
Key benefits of Jotai include:
- šŖ¶ Lightweight: Minimal bundle size increase
- š No boilerplate: Simpler than Redux or Context API
- š§© Atomic: Build complex state from simple atoms
- š Derived state: Create atoms that depend on other atoms
- ā” React Suspense support
- š„ļø SSR compatible: Works well with Next.js
Getting Started with Jotai in Next.jsā
Installationā
First, let's install Jotai in your Next.js project:
npm install jotai
# or
yarn add jotai
# or
pnpm add jotai
Creating and Using Atomsā
The basic unit of state in Jotai is an "atom". Let's see how to create and use atoms:
import { atom, useAtom } from 'jotai'
// Create an atom
const countAtom = atom(0)
function Counter() {
// Use the atom in a component
const [count, setCount] = useAtom(countAtom)
return (
<div>
<h3>Count: {count}</h3>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 1)}>Decrement</button>
</div>
)
}
In this example:
- We create a
countAtom
with an initial value of0
- We use the
useAtom
hook to read and update the atom's value - The component automatically re-renders when the atom's value changes
Derived Atomsā
One of Jotai's powerful features is the ability to create atoms that derive their value from other atoms:
import { atom, useAtom } from 'jotai'
const countAtom = atom(0)
const doubleCountAtom = atom(
get => get(countAtom) * 2
)
function DoubleCounter() {
const [count, setCount] = useAtom(countAtom)
const [doubleCount] = useAtom(doubleCountAtom)
return (
<div>
<h3>Count: {count}</h3>
<h3>Double Count: {doubleCount}</h3>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
)
}
Here, doubleCountAtom
depends on countAtom
. When countAtom
updates, components using doubleCountAtom
will automatically re-render with the new derived value.
Writable Derived Atomsā
Jotai atoms can also be both readable and writable:
import { atom, useAtom } from 'jotai'
const countAtom = atom(0)
const countryAtom = atom('USA')
// Combined atom that can read and write to multiple atoms
const formAtom = atom(
get => ({
count: get(countAtom),
country: get(countryAtom)
}),
(get, set, newValues) => {
set(countAtom, newValues.count)
set(countryAtom, newValues.country)
}
)
function Form() {
const [form, setForm] = useAtom(formAtom)
return (
<div>
<input
type="number"
value={form.count}
onChange={e => setForm({...form, count: Number(e.target.value)})}
/>
<input
value={form.country}
onChange={e => setForm({...form, country: e.target.value})}
/>
<div>
Count: {form.count}, Country: {form.country}
</div>
</div>
)
}
Using Jotai with Next.jsā
Provider Setupā
While Jotai works without a provider in simple cases, for more complex scenarios (especially with SSR or when you need separate atom scopes), you'll want to use the Provider
:
// _app.js or _app.tsx
import { Provider } from 'jotai'
import type { AppProps } from 'next/app'
function MyApp({ Component, pageProps }: AppProps) {
return (
<Provider>
<Component {...pageProps} />
</Provider>
)
}
export default MyApp
Handling Server-Side Renderingā
For SSR in Next.js, Jotai provides a way to initialize atoms with server-side data:
// pages/index.js
import { useHydrateAtoms } from 'jotai/utils'
import { atom, useAtom } from 'jotai'
const userAtom = atom(null)
export default function Home({ initialUserData }) {
// Hydrate atoms with initial data from SSR
useHydrateAtoms([[userAtom, initialUserData]])
const [user] = useAtom(userAtom)
return (
<div>
<h1>Welcome, {user?.name || 'Guest'}!</h1>
</div>
)
}
export async function getServerSideProps() {
// Fetch data on the server
const res = await fetch('https://api.example.com/user')
const user = await res.json()
return {
props: {
initialUserData: user
}
}
}
Real-World Example: Todo List Applicationā
Let's build a simple todo list application using Next.js and Jotai:
// atoms/todoAtoms.js
import { atom } from 'jotai'
// The main todos atom
export const todosAtom = atom([
{ id: 1, text: 'Learn Next.js', completed: true },
{ id: 2, text: 'Learn Jotai', completed: false },
{ id: 3, text: 'Build an app', completed: false },
])
// New todo input atom
export const todoInputAtom = atom('')
// Filter type: 'all' | 'completed' | 'incomplete'
export const filterAtom = atom('all')
// Filtered todos atom (derived)
export const filteredTodosAtom = atom(
get => {
const filter = get(filterAtom)
const todos = get(todosAtom)
switch (filter) {
case 'completed':
return todos.filter(todo => todo.completed)
case 'incomplete':
return todos.filter(todo => !todo.completed)
default:
return todos
}
}
)
// Stats atom (derived)
export const statsAtom = atom(
get => {
const todos = get(todosAtom)
return {
total: todos.length,
completed: todos.filter(todo => todo.completed).length,
incomplete: todos.filter(todo => !todo.completed).length
}
}
)
// components/TodoApp.jsx
import { useAtom } from 'jotai'
import { todosAtom, todoInputAtom, filterAtom, filteredTodosAtom, statsAtom } from '../atoms/todoAtoms'
export default function TodoApp() {
const [todos, setTodos] = useAtom(todosAtom)
const [input, setInput] = useAtom(todoInputAtom)
const [filter, setFilter] = useAtom(filterAtom)
const [filteredTodos] = useAtom(filteredTodosAtom)
const [stats] = useAtom(statsAtom)
const addTodo = (e) => {
e.preventDefault()
if (!input.trim()) return
setTodos([
...todos,
{ id: Date.now(), text: input, completed: false }
])
setInput('')
}
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
))
}
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id))
}
return (
<div className="p-4 max-w-md mx-auto">
<h1 className="text-2xl font-bold mb-4">Todo List</h1>
<form onSubmit={addTodo} className="mb-4 flex">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
className="flex-grow p-2 border rounded-l"
placeholder="Add a new todo..."
/>
<button
type="submit"
className="bg-blue-500 text-white px-4 py-2 rounded-r"
>
Add
</button>
</form>
<div className="mb-4">
<button
onClick={() => setFilter('all')}
className={`mr-2 px-3 py-1 ${filter === 'all' ? 'bg-blue-500 text-white' : 'bg-gray-200'} rounded`}
>
All
</button>
<button
onClick={() => setFilter('completed')}
className={`mr-2 px-3 py-1 ${filter === 'completed' ? 'bg-blue-500 text-white' : 'bg-gray-200'} rounded`}
>
Completed
</button>
<button
onClick={() => setFilter('incomplete')}
className={`px-3 py-1 ${filter === 'incomplete' ? 'bg-blue-500 text-white' : 'bg-gray-200'} rounded`}
>
Incomplete
</button>
</div>
<div className="mb-4">
<div className="text-sm text-gray-600">
Total: {stats.total} | Completed: {stats.completed} | Incomplete: {stats.incomplete}
</div>
</div>
<ul className="border rounded divide-y">
{filteredTodos.map(todo => (
<li key={todo.id} className="p-3 flex items-center justify-between">
<div className="flex items-center">
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
className="mr-2"
/>
<span className={todo.completed ? 'line-through text-gray-400' : ''}>
{todo.text}
</span>
</div>
<button
onClick={() => deleteTodo(todo.id)}
className="text-red-500 hover:text-red-700"
>
Delete
</button>
</li>
))}
{filteredTodos.length === 0 && (
<li className="p-3 text-gray-500 text-center">No todos found</li>
)}
</ul>
</div>
)
}
// pages/index.js
import TodoApp from '../components/TodoApp'
export default function Home() {
return (
<div className="container mx-auto p-4">
<TodoApp />
</div>
)
}
Advanced Jotai Patternsā
Using with React Suspenseā
Jotai supports React Suspense for data fetching:
import { atom, useAtom } from 'jotai'
import { Suspense } from 'react'
// Async atom that fetches data
const userAtom = atom(async () => {
const response = await fetch('https://api.github.com/users/octocat')
const data = await response.json()
return data
})
function UserProfile() {
const [user] = useAtom(userAtom)
return (
<div>
<h2>{user.name}</h2>
<img src={user.avatar_url} alt={user.login} width="100" />
<p>{user.bio}</p>
</div>
)
}
export default function App() {
return (
<Suspense fallback={<div>Loading user data...</div>}>
<UserProfile />
</Suspense>
)
}
Using with localStorageā
You can persist atoms to localStorage:
import { atom, useAtom } from 'jotai'
import { useEffect } from 'react'
import { useHydrateAtoms } from 'jotai/utils'
const themeAtom = atom('light')
function ThemeSelector() {
const [theme, setTheme] = useAtom(themeAtom)
// Load from localStorage on mount
useEffect(() => {
const savedTheme = localStorage.getItem('theme')
if (savedTheme) {
setTheme(savedTheme)
}
}, [])
// Save to localStorage when changed
useEffect(() => {
localStorage.setItem('theme', theme)
}, [theme])
return (
<div>
<h3>Current Theme: {theme}</h3>
<button onClick={() => setTheme('light')}>Light</button>
<button onClick={() => setTheme('dark')}>Dark</button>
</div>
)
}
Summaryā
Jotai provides a lightweight and flexible approach to state management in Next.js applications. Its atomic model allows you to build complex state from simple pieces while avoiding the boilerplate of other state management solutions.
Key points to remember:
- š Atoms are the basic units of state in Jotai
- š§© Derived atoms allow you to compose state from other atoms
- ā” Jotai works well with Next.js's server-side rendering
- š The
useAtom
hook provides a familiar API similar to React'suseState
- š¦ Jotai's small bundle size and minimal API make it ideal for Next.js applications
Additional Resourcesā
- Official Jotai Documentation
- Jotai GitHub Repository
- Comparison with other state management libraries
Exercisesā
- Create a theme switcher that toggles between light and dark mode using Jotai and persists the selection to localStorage.
- Build a shopping cart application with Jotai that allows adding/removing items and shows the total price.
- Create a multi-step form with Jotai where data is preserved between steps.
- Implement an authentication system using Jotai to store and retrieve user information.
- Create a Jotai atom that fetches data from an API and displays it using React Suspense.
If you spot any mistakes on this website, please let me know at [email protected]. Iād greatly appreciate your feedback! :)