Skip to main content

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:

bash
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:

jsx
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:

  1. We create a countAtom with an initial value of 0
  2. We use the useAtom hook to read and update the atom's value
  3. 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:

jsx
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:

jsx
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:

jsx
// _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:

jsx
// 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:

jsx
// 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
}
}
)
jsx
// 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>
)
}
jsx
// 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:

jsx
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:

jsx
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's useState
  • šŸ“¦ Jotai's small bundle size and minimal API make it ideal for Next.js applications

Additional Resourcesā€‹

Exercisesā€‹

  1. Create a theme switcher that toggles between light and dark mode using Jotai and persists the selection to localStorage.
  2. Build a shopping cart application with Jotai that allows adding/removing items and shows the total price.
  3. Create a multi-step form with Jotai where data is preserved between steps.
  4. Implement an authentication system using Jotai to store and retrieve user information.
  5. 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! :)