Use Cases (Actualizado: 2/6/2026)

Jotai atoms con Claude Code: guía práctica para React

Diseña Jotai atoms con Claude Code: derivados, async, SSR, Provider, tests y prompts seguros.

Jotai atoms con Claude Code: guía práctica para React

Pedirle a Claude Code “agrega Jotai para gestionar estado” es demasiado abierto. Probablemente produzca código que compila, pero el tamaño de cada atom, el límite con el estado de servidor, la posición del Provider, la hidratación en SSR y las pruebas quedarán definidos por accidente. Cuando se mezclan filtros, formularios, lecturas asíncronas y UI de detalle, el coste aparece en revisión.

Este artículo trata los Jotai atoms como una herramienta de diseño para estado pequeño de React, no como un store global genérico. La documentación oficial de Jotai atom explica que un atom config es una definición y no guarda el valor; los valores viven en un store. Para async usamos la guía oficial de Jotai async y la referencia de React para <Suspense>. Para Provider y SSR, la base son Jotai Provider y SSR utilities.

Claude Code, según su overview oficial, puede leer el codebase, editar archivos y ejecutar comandos. Por eso conviene acotar el prompt y proteger secretos conpermissions.deny, documentado en Claude Code settings. Para el contexto React general revisa la guía React con Claude Code, y para estado de servidor compara con la guía de TanStack Query.

Fija primero el modelo mental de atom

Un atom no es el valor en sí. Es una definición estable que indica cómo leer o escribir un valor en un store. Si Claude Code no recibe este contexto, suele crear unpageStateAtom gigante con texto de búsqueda, respuesta de API, filas seleccionadas, toasts y borradores de formulario. Parece ordenado porque todo está junto, pero cada cambio afecta a toda la pantalla.

Antes de escribir código, define tres límites. ¿El valor es estado de UI o verdad del servidor? ¿Lo comparten componentes alejados o vive en un solo componente? ¿Se puede derivar desde otros atoms? Texto de búsqueda, filtros, pestañas activas, toasts cortos y borradores de formularios multi paso encajan bien. Listas de productos, tablas de usuarios, tokens, sesiones de pago e inventario real suelen pertenecer a una capa de server state o al servidor.

EstadoBuen uso de JotaiEvitar
Borrador de formularioCompartido entre pasosGuardar una orden completa ya persistida
Filtros de adminTabla, contador y URL los usanGuardar respuestas API completas
Modal y toastSe activan desde botones lejanosLogs largos o auditoría
PreferenciasTema, densidad, panel cerradoTokens, email, dirección, pagos

El fallo real de Masa en una pantalla administrativa fue tratar “usar Jotai” como objetivo. La primera versión guardaba filtros, filas descargadas, selección, estado de guardado y toasts juntos. Al volver a la lista, una selección antigua afectó a una nueva acción masiva. Separar datos de servidor, selección y UI efímera redujo el diff de Claude Code y facilitó el review.

Instalación y slice mínimo

En una app React con Vite o Next.js instala Jotai. Si necesitas atom families en código nuevo, prefierejotai-family; la documentación actual marca elatomFamily dejotai/utils como deprecado para Jotai v3.

npm i jotai jotai-family
npm i -D vitest @testing-library/react @testing-library/user-event

Este tablero de tareas es pequeño pero completo. Incluye atoms primitivos, atoms derivados y atoms solo de escritura en un ejemplo copiable.

import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";

export type TaskStatus = "todo" | "doing" | "done";

export type Task = {
  id: string;
  title: string;
  status: TaskStatus;
};

const createId = () =>
  globalThis.crypto?.randomUUID?.() ?? String(Date.now());

export const tasksAtom = atom<Task[]>([
  { id: "task-1", title: "Write release note", status: "todo" },
]);

export const filterAtom = atom<TaskStatus | "all">("all");
export const draftTitleAtom = atom("");

export const visibleTasksAtom = atom((get) => {
  const filter = get(filterAtom);
  const tasks = get(tasksAtom);
  return filter === "all"
    ? tasks
    : tasks.filter((task) => task.status === filter);
});

export const taskStatsAtom = atom((get) => {
  const tasks = get(tasksAtom);
  return {
    total: tasks.length,
    done: tasks.filter((task) => task.status === "done").length,
  };
});

export const addTaskAtom = atom(null, (get, set) => {
  const title = get(draftTitleAtom).trim();
  if (!title) return;

  set(tasksAtom, (tasks) => [
    ...tasks,
    { id: createId(), title, status: "todo" },
  ]);
  set(draftTitleAtom, "");
});

export const toggleTaskAtom = atom(null, (_get, set, id: string) => {
  set(tasksAtom, (tasks) =>
    tasks.map((task) =>
      task.id === id
        ? { ...task, status: task.status === "done" ? "todo" : "done" }
        : task,
    ),
  );
});

export function TaskBoard() {
  const [draft, setDraft] = useAtom(draftTitleAtom);
  const [filter, setFilter] = useAtom(filterAtom);
  const tasks = useAtomValue(visibleTasksAtom);
  const stats = useAtomValue(taskStatsAtom);
  const addTask = useSetAtom(addTaskAtom);
  const toggleTask = useSetAtom(toggleTaskAtom);

  return (
    <section>
      <p>
        Total: {stats.total} / Done: {stats.done}
      </p>

      <label>
        New task
        <input
          value={draft}
          onChange={(event) => setDraft(event.currentTarget.value)}
        />
      </label>
      <button type="button" onClick={addTask}>
        Add
      </button>

      <select
        value={filter}
        onChange={(event) =>
          setFilter(event.currentTarget.value as TaskStatus | "all")
        }
      >
        <option value="all">All</option>
        <option value="todo">Todo</option>
        <option value="doing">Doing</option>
        <option value="done">Done</option>
      </select>

      <ul>
        {tasks.map((task) => (
          <li key={task.id}>
            <span>{task.title}</span>
            <button
              type="button"
              aria-label={`Mark ${task.title} done`}
              onClick={() => toggleTask(task.id)}
            >
              {task.status === "done" ? "Undo" : "Done"}
            </button>
          </li>
        ))}
      </ul>
    </section>
  );
}

Después de generarlo, pide a Claude Code una revisión crítica: los valores derivados no deben guardarse dos veces, las acciones de escritura deben estar centralizadas y cada componente debe suscribirse solo a los atoms que renderiza.

Casos de uso para elegir la granularidad

El primer caso es una barra de filtros de administración. search, status, page y sort pueden vivir en atoms cuando los usan la tabla, el contador y la URL. La respuesta completa de la API no debe vivir ahí. Indica en el prompt: “separa condiciones visibles en URL de estado solo de UI, y deja las respuestas API en la capa de server state”.

El segundo caso es checkout u onboarding multi paso. Campos de borrador, paso actual y validación encajan bien. Órdenes enviadas, sesiones de pago e inventario no. Un atom write-only para resetear el borrador tras éxito es más seguro que limpiar campos desde varios componentes.

El tercer caso es UI de una pantalla de detalle. Filas expandidas, tab activo, ID seleccionado y cola corta de toasts pueden separarse en atoms pequeños para que solo se renderice lo necesario. UndetailPageAtom único se genera rápido, pero dificulta rendimiento y review.

El cuarto caso son preferencias. Tema, densidad y avisos cerrados pueden persistirse con helpers. Datos privados no deben persistirse en el navegador. En páginas monetizadas, el estado de apertura de un CTA es de bajo riesgo, pero emails de compradores e historial de cupones deben estar en servidor.

Atoms derivados y write-only atoms

Los atoms derivados calculan valores desde otros atoms. Totales, listas filtradas y resultados de validación normalmente se calculan, no se guardan. Los write-only atoms concentran acciones como patch, reset o normalización antes de guardar.

import { atom } from "jotai";

export type CheckoutDraft = {
  email: string;
  postalCode: string;
  agreed: boolean;
};

const emptyCheckoutDraft: CheckoutDraft = {
  email: "",
  postalCode: "",
  agreed: false,
};

export const checkoutDraftAtom = atom<CheckoutDraft>(emptyCheckoutDraft);

export const checkoutErrorsAtom = atom((get) => {
  const draft = get(checkoutDraftAtom);
  const errors: Partial<Record<keyof CheckoutDraft, string>> = {};

  if (!draft.email.includes("@")) {
    errors.email = "Check the email address";
  }

  if (!/^\d{3}-?\d{4}$/.test(draft.postalCode)) {
    errors.postalCode = "Enter a seven digit postal code";
  }

  if (!draft.agreed) {
    errors.agreed = "Agreement is required";
  }

  return errors;
});

export const patchCheckoutDraftAtom = atom(
  null,
  (_get, set, patch: Partial<CheckoutDraft>) => {
    set(checkoutDraftAtom, (draft) => ({ ...draft, ...patch }));
  },
);

export const resetCheckoutDraftAtom = atom(null, (_get, set) => {
  set(checkoutDraftAtom, emptyCheckoutDraft);
});

El fallo típico es guardar el resultado decheckoutErrorsAtomen otro atom. El borrador cambia, pero la foto de errores se queda vieja. La regla para Claude Code debe ser clara: “no guardes valores que se puedan derivar de atoms actuales”.

Async atoms y límite con server state

Los async atoms son útiles, pero no sustituyen todas las reglas de data fetching. Un async read atom puede devolver una Promise y Suspense puede mostrar un fallback mientras carga. Encaja bien para lecturas pequeñas dentro de una zona de UI.

import { Suspense } from "react";
import { atom, useAtomValue, useSetAtom } from "jotai";

type Profile = {
  id: string;
  name: string;
  plan: "free" | "pro";
};

export const profileIdAtom = atom("masa");

export const profileAtom = atom(async (get, { signal }) => {
  const id = get(profileIdAtom);
  const response = await fetch(`/api/profiles/${id}`, { signal });

  if (!response.ok) {
    throw new Error("Failed to load profile");
  }

  return (await response.json()) as Profile;
});

function ProfileCard() {
  const profile = useAtomValue(profileAtom);
  return <p>{profile.name} is on the {profile.plan} plan.</p>;
}

function ProfileSwitcher() {
  const setProfileId = useSetAtom(profileIdAtom);
  return (
    <button type="button" onClick={() => setProfileId("demo")}>
      Load demo user
    </button>
  );
}

export function ProfilePanel() {
  return (
    <>
      <ProfileSwitcher />
      <Suspense fallback={<p>Loading profile...</p>}>
        <ProfileCard />
      </Suspense>
    </>
  );
}

Si necesitas retry, stale time, paginación, mutaciones optimistas o invalidación tras guardar, usa una librería de server state. Jotai puede guardar parámetros de petición y UI local; la caché de respuestas debe quedar fuera.

Atom family, SSR y Provider

Atom family sirve cuando cada fila o pestaña necesita su propio estado de UI. El riesgo es la caché. La documentación oficial advierte que internamente se guardan atoms por parámetro, así que parámetros ilimitados sin limpieza pueden producir fugas de memoria. En código nuevo usajotai-family.

import { atom } from "jotai";
import { atomFamily } from "jotai-family";

type RowUi = {
  expanded: boolean;
  selected: boolean;
};

export const rowUiFamily = atomFamily((id: string) =>
  atom<RowUi>({ expanded: false, selected: false }),
);

rowUiFamily.setShouldRemove((createdAt) => {
  return Date.now() - createdAt > 10 * 60_000;
});

export const removeRowUiAtom = atom(null, (_get, _set, id: string) => {
  rowUiFamily.remove(id);
});

En SSR, el límite de Provider importa. Jotai funciona sin Provider explícito, pero si necesitas valores iniciales por request, aislamiento de subárbol o tests limpios, usa Provider. En Next.js App Router,useHydrateAtoms pertenece a un componente client.

"use client";

import { type PropsWithChildren } from "react";
import { Provider } from "jotai";
import { useHydrateAtoms } from "jotai/utils";
import { tasksAtom, type Task } from "./TaskBoard";

type Props = PropsWithChildren<{
  initialTasks: Task[];
}>;

function HydrateAtoms({ initialTasks, children }: Props) {
  useHydrateAtoms(new Map([[tasksAtom, initialTasks]]));
  return children;
}

export function JotaiRequestProvider(props: Props) {
  return (
    <Provider>
      <HydrateAtoms initialTasks={props.initialTasks}>
        {props.children}
      </HydrateAtoms>
    </Provider>
  );
}

El fallo frecuente es intentar hidratar una y otra vez el mismo atom cuando cambia usuario o tenant. Por defecto, la hidratación de un store es de una sola vez. Para cambios reales, remonta el Provider con otra key o crea una acción de reset explícita.

Tests y prompts seguros

La guía de testing de Jotai recomienda probar como interactúa el usuario y tratar Jotai como detalle de implementación. Para aceptar salida de Claude Code, cubre entrada, click, resultado visible y reset.

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Provider } from "jotai";
import { describe, expect, it } from "vitest";
import { TaskBoard } from "./TaskBoard";

describe("TaskBoard", () => {
  it("adds and completes a task", async () => {
    const user = userEvent.setup();

    render(
      <Provider>
        <TaskBoard />
      </Provider>,
    );

    await user.type(screen.getByLabelText("New task"), "Review atoms");
    await user.click(screen.getByRole("button", { name: "Add" }));

    expect(screen.getByText("Review atoms").textContent).toBe("Review atoms");

    await user.click(
      screen.getByRole("button", { name: "Mark Review atoms done" }),
    );

    expect(screen.getByText(/Done: 1/).textContent).toContain("Done: 1");
  });
});

Usa un prompt acotado:

Lee la pantalla React + TypeScript existente y reorganiza el estado con Jotai v2.
Solo puedes editar archivos bajo src/features/tasks.
No guardes respuestas API en atoms.
Usa atoms solo para estado de UI y borradores de formulario.
Incluye atoms derivados, write-only atoms, Provider boundary y tests Vitest.
Si hace falta atom family, usa jotai-family y añade limpieza.
Termina con una revisión crítica de fallos, renders y riesgos SSR.

Y excluye secretos en configuración:

{
  "permissions": {
    "deny": [
      "Read(./.env)",
      "Read(./.env.*)",
      "Read(./secrets/**)",
      "Read(./build)"
    ]
  }
}

CTA de monetización y verificación

Un artículo de Jotai no debe quedarse en snippets. El lector necesita una regla para decidir qué va en atoms, qué va a TanStack Query y qué queda en servidor. Para ordenar prompts de Claude Code,CLAUDE.md, reglas de review y pruebas de equipo, empieza por lachecklist gratuita de Claude Code y comparaZustand,TanStack Query yestrategias de testing.

Masa probó este patrón en una pantalla React pequeña. La mejora más grande ocurrió antes de escribir atoms: el primer prompt mezcló respuesta API, borrador de formulario y toast en un atom. El segundo prompt dijo “los datos de servidor quedan fuera”, “los valores derivados no se guardan” y “Provider más tests son obligatorios”. El diff bajó, Vitest cubrió agregar, completar y resetear, y la revisión encontró dos riesgos fáciles de olvidar: limpieza de atom family e hidratación SSR.

#Claude Code #Jotai #React #gestión de estado #atoms
Gratis

PDF gratis: cheatsheet de Claude Code

Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.

Cuidamos tus datos y no enviamos spam.

Masa

Sobre el autor

Masa

Ingeniero enfocado en workflows prácticos con Claude Code.