Building Full-Stack Apps with Jotai
What is Jotai?
Jotai is a primitive and flexible state management library for React. Its name comes from the Japanese word for "state" (State, jotai). At its core, Jotai provides a minimal API based on the concept of atoms – independent, atomic units of state. Atoms can be derived from other atoms, enabling a composable and reactive data flow without boilerplate like reducers or actions. While often used for client-side state, Jotai’s design makes it an excellent choice for managing state across the full stack, especially in modern React frameworks like Next.js or Remix.
Why Use Jotai for Full-Stack Applications?
Full-stack applications must handle both server-side data (fetched from APIs) and client-side UI state (filters, modals, form inputs). Traditional approaches often split this into separate tools – Redux for client state, React Query for server state. Jotai unifies this by treating everything as atoms, including asynchronous server state. This reduces cognitive overhead and keeps your codebase consistent. Key benefits include:
- Unified mental model: One pattern for local state, derived state, and remote data.
- No boilerplate: Atoms are plain objects; no action types, dispatch, or middleware.
- Excellent performance: Atoms subscribe individually, so only components consuming changed atoms re-render.
- Framework agnostic: Works seamlessly with Next.js, Remix, Vite, or any React setup.
- Server-side rendering (SSR) friendly: Jotai can be hydrated from the server with minimal effort.
Core Concepts
Before diving into code, understand these building blocks:
- Atom: A unit of state. Created with
atom(initialValue). Read and write viauseAtomhook. - Derived atom: An atom whose value is computed from other atoms using a
getfunction insideatom(get => ...). - Async atom: An atom that returns a Promise (e.g., from a fetch call). Jotai handles Suspense and error boundaries.
- Write atom: An atom that can be updated by calling its setter, or a derived atom that performs side effects (like API calls) via its
setfunction. - Store: An optional container for atoms, useful for isolation in testing or SSR.
Setting Up a Full-Stack Project
Let’s create a minimal full-stack app using Next.js (App Router) and Jotai. We’ll build a simple task manager where the backend provides a REST API and the frontend uses Jotai atoms for state management.
First, initialize a Next.js project:
npx create-next-app@latest jotai-fullstack --typescript
cd jotai-fullstack
npm install jotai
Create a simple Express-like API route inside app/api/tasks/route.ts (Next.js App Router):
// app/api/tasks/route.ts
import { NextResponse } from 'next/server';
// In-memory store for demo
let tasks = [
{ id: 1, title: 'Learn Jotai', completed: false },
{ id: 2, title: 'Build full-stack app', completed: false },
];
export async function GET() {
return NextResponse.json(tasks);
}
export async function POST(request: Request) {
const body = await request.json();
const newTask = { id: Date.now(), title: body.title, completed: false };
tasks.push(newTask);
return NextResponse.json(newTask, { status: 201 });
}
export async function PUT(request: Request) {
const body = await request.json();
tasks = tasks.map(t => t.id === body.id ? { ...t, ...body } : t);
return NextResponse.json(tasks.find(t => t.id === body.id));
}
export async function DELETE(request: Request) {
const body = await request.json();
tasks = tasks.filter(t => t.id !== body.id);
return NextResponse.json({ success: true });
}
Now set up the Jotai provider in app/layout.tsx:
// app/layout.tsx
import type { Metadata } from 'next';
import { Provider } from 'jotai';
export const metadata: Metadata = {
title: 'Jotai Full-Stack Demo',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<Provider>
{children}
</Provider>
</body>
</html>
);
}
Managing Server State with Jotai
Create an atoms folder and define atoms that fetch data from the API. We’ll use async atoms and a custom hook to manage tasks.
// atoms/tasksAtom.ts
import { atom } from 'jotai';
// Base atom that holds the raw task array
const tasksBaseAtom = atom([]);
// Async atom that fetches tasks from the server
export const tasksAtom = atom(
async (get) => {
// If we want to re-fetch on demand, we can combine with a refresh atom
const res = await fetch('/api/tasks');
if (!res.ok) throw new Error('Failed to fetch tasks');
return res.json();
}
);
// Write atom to add a task
export const addTaskAtom = atom(
null, // read value (unused)
async (_get, set, title: string) => {
const res = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
const newTask = await res.json();
// Update the tasks atom (re-fetch or optimistically update)
set(tasksAtom, (prev) => [...prev, newTask]); // This will not work directly because tasksAtom is async
// Better approach: use a separate atom for the list
}
);
// Instead, use a writable async atom pattern:
export const tasksAsyncAtom = atom>(async () => {
const res = await fetch('/api/tasks');
return res.json();
});
// Writable async atom that supports updates via setter
export const tasksWriteAtom = atom(
(get) => get(tasksAsyncAtom),
async (_get, set, update: Task[] | ((prev: Task[]) => Task[])) => {
// For simplicity, we re-fetch after mutations
set(tasksAsyncAtom, async () => {
const res = await fetch('/api/tasks');
return res.json();
});
}
);
For a cleaner pattern, we’ll use Jotai’s atomWithQuery from jotai-tanstack-query or a manual approach with a refresh trigger. Let’s keep it simple with a refresh atom:
// atoms/tasksAtom.ts (final version)
import { atom } from 'jotai';
export type Task = {
id: number;
title: string;
completed: boolean;
};
// Trigger atom – increment to re-fetch
export const refreshAtom = atom(0);
// Derived async atom that depends on refreshAtom
export const tasksAtom = atom(async (get) => {
get(refreshAtom); // subscribe to refresh
const res = await fetch('/api/tasks');
if (!res.ok) throw new Error('Failed to fetch');
return (await res.json()) as Task[];
});
// Write atom for adding a task (optimistic update + re-fetch)
export const addTaskAtom = atom(
null,
async (_get, set, title: string) => {
// Optimistic update (optional)
const tempId = Date.now();
const optimisticTask: Task = { id: tempId, title, completed: false };
set(tasksAtom, (prev) => [...prev, optimisticTask]); // not directly possible because tasksAtom is async
// Instead, we can update the base atom or use a different pattern.
// Simpler: just call API then trigger refresh
await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
set(refreshAtom, (prev) => prev + 1);
}
);
export const toggleTaskAtom = atom(
null,
async (_get, set, task: Task) => {
await fetch('/api/tasks', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...task, completed: !task.completed }),
});
set(refreshAtom, (prev) => prev + 1);
}
);
export const deleteTaskAtom = atom(
null,
async (_get, set, id: number) => {
await fetch('/api/tasks', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
set(refreshAtom, (prev) => prev + 1);
}
);
Using Atoms in Components
Now build the UI in app/page.tsx:
// app/page.tsx
'use client';
import { useAtom, useSetAtom } from 'jotai';
import { Suspense, useState } from 'react';
import { tasksAtom, addTaskAtom, toggleTaskAtom, deleteTaskAtom, Task } from '@/atoms/tasksAtom';
function TaskList() {
const [tasks] = useAtom(tasksAtom);
const toggleTask = useSetAtom(toggleTaskAtom);
const deleteTask = useSetAtom(deleteTaskAtom);
return (
<ul>
{tasks.map((task) => (
<li key={task.id} style={{ textDecoration: task.completed ? 'line-through' : 'none' }}>
<input
type="checkbox"
checked={task.completed}
onChange={() => toggleTask(task)}
/>
{task.title}
<button onClick={() => deleteTask(task.id)}>Delete</button>
</li>
))}
</ul>
);
}
function AddTask() {
const [title, setTitle] = useState('');
const addTask = useSetAtom(addTaskAtom);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
await addTask(title);
setTitle('');
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="New task"
/>
<button type="submit">Add</button>
</form>
);
}
export default function Home() {
return (
<main>
<h1>Task Manager</h1>
<AddTask />
<Suspense fallback={<div>Loading tasks...</div>}>
<TaskList />
</Suspense>
</main>
);
}
Note: We use <Suspense> because tasksAtom is async. Jotai integrates naturally with React Suspense. Alternatively, you can use useAtomValue with a fallback value.
Syncing Client and Server with Derived Atoms
Derived atoms are powerful for computed state. For example, show only incomplete tasks:
// atoms/tasksAtom.ts (add)
export const incompleteTasksAtom = atom((get) => {
const tasks = get(tasksAtom);
return tasks.filter(t => !t.completed);
});
Use it in a component:
const incompleteTasks = useAtomValue(incompleteTasksAtom);
You can also create atoms that combine server data with local UI state, such as a search filter:
export const searchQueryAtom = atom('');
export const filteredTasksAtom = atom((get) => {
const tasks = get(tasksAtom);
const query = get(searchQueryAtom).toLowerCase();
return tasks.filter(t => t.title.toLowerCase().includes(query));
});
Best Practices for Full-Stack Jotai
- Separate server and client atoms: Keep atoms that fetch data separate from atoms that hold UI state (e.g., modals, form inputs). This avoids unnecessary re-fetches.
- Use a refresh trigger pattern: Instead of trying to update an async atom directly, use a simple numeric atom that increments to signal a re-fetch. This keeps mutations side-effect free and readable.
- Avoid over-subscribing: Only read atoms in components that need them. Use
useAtomValuefor read-only access anduseSetAtomfor write-only access to prevent unnecessary re-renders. - Leverage Jotai’s built-in utilities: Consider
atomWithStoragefor persisting local state,atomFamilyfor dynamic atoms, andselectAtomfor fine-grained subscriptions. - Handle loading and error states gracefully: Wrap async atoms in Suspense or use
loadablefrom Jotai to access pending/error states without throwing. - Keep atoms small and focused: Each atom should represent a single piece of state. Combine them via derived atoms rather than creating large monolithic atoms.
- Use TypeScript: Define types for your atoms (e.g.,
atom) to catch errors at compile time. - Test atoms in isolation: Jotai atoms are pure functions – you can test them without rendering components by using the
createStoreAPI.
Conclusion
Jotai brings a refreshing simplicity to full-stack state management by unifying client and server data under the same atomic paradigm. Its minimal API, built-in support for async operations, and seamless integration with React Suspense make it an ideal choice for modern full-stack applications built with React and frameworks like Next.js. By following the patterns shown in this tutorial – using async atoms for server data, write atoms for mutations, and derived atoms for computed values – you can build scalable, maintainable apps with less boilerplate and more clarity. As you grow your application, Jotai’s flexibility allows you to introduce more advanced patterns (like atom families or persistence) without changing your fundamental mental model. Start small, keep atoms focused, and let Jotai handle the reactivity. Your future self – and your teammates – will thank you.