Zustand and Jotai, despite their similar goals, approach state management in Next.js with fundamentally different philosophies, leading to distinct patterns for managing your application’s data.

Let’s see them in action.

Imagine you’re building a simple to-do list application. Here’s how you might set up a shared todos state using Zustand:

// store/todoStore.js
import { create } from 'zustand';

const useTodoStore = create((set) => ({
  todos: [],
  addTodo: (text) => set((state) => ({ todos: [...state.todos, { id: Date.now(), text, completed: false }] })),
  toggleTodo: (id) => set((state) => ({
    todos: state.todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    )
  })),
}));

export default useTodoStore;

And here’s the equivalent using Jotai:

// atoms/todoAtom.js
import { atom } from 'jotai';

export const todosAtom = atom([]);

export const addTodoAtom = atom(
  null, // Read function (not needed for this write-only atom)
  (get, set, text) => {
    const currentTodos = get(todosAtom);
    set(todosAtom, [...currentTodos, { id: Date.now(), text, completed: false }]);
  }
);

export const toggleTodoAtom = atom(
  null,
  (get, set, id) => {
    const currentTodos = get(todosAtom);
    set(todosAtom, currentTodos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  }
);

In your Next.js component (e.g., pages/index.js):

With Zustand:

import React from 'react';
import useTodoStore from '../store/todoStore';

function TodoList() {
  const todos = useTodoStore(state => state.todos);
  const addTodo = useTodoStore(state => state.addTodo);
  const toggleTodo = useTodoStore(state => state.toggleTodo);

  const handleAdd = () => {
    const text = prompt('Enter new todo:');
    if (text) addTodo(text);
  };

  return (
    <div>
      <h1>Todos</h1>
      <ul>
        {todos.map(todo => (

          <li key={todo.id} onClick={() => toggleTodo(todo.id)} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>

            {todo.text}
          </li>
        ))}
      </ul>
      <button onClick={handleAdd}>Add Todo</button>
    </div>
  );
}

export default TodoList;

With Jotai:

import React from 'react';
import { useAtom } from 'jotai';
import { todosAtom, addTodoAtom, toggleTodoAtom } from '../atoms/todoAtom';

function TodoList() {
  const [todos] = useAtom(todosAtom);
  const [, addTodo] = useAtom(addTodoAtom);
  const [, toggleTodo] = useAtom(toggleTodoAtom);

  const handleAdd = () => {
    const text = prompt('Enter new todo:');
    if (text) addTodo(text);
  };

  return (
    <div>
      <h1>Todos</h1>
      <ul>
        {todos.map(todo => (

          <li key={todo.id} onClick={() => toggleTodo(todo.id)} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>

            {todo.text}
          </li>
        ))}
      </ul>
      <button onClick={handleAdd}>Add Todo</button>
    </div>
  );
}

export default TodoList;

Zustand operates on a single store object. You define your state and actions within a create function, and components subscribe to slices of this state using hooks like useTodoStore(state => state.todos). This is akin to a centralized Redux store but with a much simpler API. The primary benefit here is its minimalistic nature and ease of setup for global state. You can easily add more state and actions to the same store, or create entirely new, independent stores.

Jotai, on the other hand, is built around the concept of "atoms." An atom is a piece of state. You can have many small, independent atoms, and combine them as needed. The useAtom hook allows components to subscribe to specific atoms. This granular approach means components only re-render when the specific atom they depend on changes, leading to highly optimized re-renders. Jotai also shines with derived state:

// atoms/derivedAtom.js
import { atom } from 'jotai';
import { todosAtom } from './todoAtom';

export const completedTodosAtom = atom((get) =>
  get(todosAtom).filter(todo => todo.completed)
);

export const pendingTodosCountAtom = atom((get) =>
  get(todosAtom).filter(todo => !todo.completed).length
);

In your component:

import React from 'react';
import { useAtom } from 'jotai';
import { todosAtom, addTodoAtom, toggleTodoAtom } from '../atoms/todoAtom';
import { completedTodosAtom, pendingTodosCountAtom } from '../atoms/derivedAtom';

function TodoDashboard() {
  const [todos] = useAtom(todosAtom);
  const [completedTodos] = useAtom(completedTodosAtom);
  const [pendingCount] = useAtom(pendingTodosCountAtom);

  // ... (addTodo and toggleTodo logic as before)

  return (
    <div>
      <h2>Dashboard</h2>
      <p>Total Todos: {todos.length}</p>
      <p>Completed: {completedTodos.length}</p>
      <p>Pending: {pendingCount}</p>
    </div>
  );
}

Here, completedTodosAtom and pendingTodosCountAtom are derived from todosAtom. When todosAtom changes, these derived atoms automatically update, and components subscribing to them will re-render. This declarative way of defining derived state is a core strength of Jotai.

A key pattern in Jotai is atom composition. You can build complex state logic by combining simpler atoms. For instance, an atom might read from several other atoms to calculate its value, or an action atom might modify multiple other atoms. This allows for highly modular and reusable state logic. Zustand’s pattern is more about grouping related state and actions within a single store, which can be simpler for monolithic state structures but less flexible for highly distributed state concerns.

The most surprising aspect for many is how Jotai’s atom-based system inherently encourages a more decentralized state architecture. Unlike Zustand’s centralized store, Jotai allows you to define atoms that are only relevant within a specific component tree or feature. This isolation prevents global state from becoming a dumping ground for everything, leading to better maintainability and understanding of where state lives and how it’s used.

The next step is often to explore how these libraries integrate with Next.js features like server-side rendering and API routes, and the patterns that emerge for managing asynchronous state.

Want structured learning?

Take the full Nextjs course →