← Back to DevBytes

Building Full-Stack Apps with Jotai

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:

Core Concepts

Before diving into code, understand these building blocks:

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

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles