TechLead

Jotai

Gestión de estado atómica primitiva y flexible para React

Jotai - Estado Primitivo y Flexible

Jotai (japonés para "estado") toma un enfoque atómico para la gestión de estado en React. En lugar de un único store, creas pequeñas piezas independientes de estado llamadas átomos. Los componentes se suscriben solo a los átomos que usan, proporcionando re-renders óptimos desde el principio.

¿Por Qué Jotai?

  • Atómico — Estado dividido en átomos independientes
  • API Mínima — Solo atom() y useAtom()
  • Sin Providers — Funciona sin envolver tu app
  • Estado Derivado — Valores computados fáciles
  • TypeScript — Inferencia completa de tipos

Instalación

npm install jotai

Átomos Básicos

import { atom, useAtom } from 'jotai';

// Crea átomos - piezas primitivas de estado
const countAtom = atom(0);
const nameAtom = atom('');
const darkModeAtom = atom(false);

// Usa átomos en componentes
function Counter() {
  const [count, setCount] = useAtom(countAtom);
  
  return (
    <div>
      <span>{count}</span>
      <button onClick={() => setCount(c => c + 1)}>+</button>
      <button onClick={() => setCount(c => c - 1)}>-</button>
    </div>
  );
}

// Hook de solo lectura para rendimiento
import { useAtomValue, useSetAtom } from 'jotai';

function Display() {
  // Solo se suscribe a la lectura
  const count = useAtomValue(countAtom);
  return <span>{count}</span>;
}

function Controls() {
  // Solo obtiene el setter, nunca re-renderiza por cambios en count
  const setCount = useSetAtom(countAtom);
  return (
    <button onClick={() => setCount(c => c + 1)}>
      Incrementar
    </button>
  );
}

Átomos Derivados

import { atom, useAtomValue } from 'jotai';

// Átomos primitivos
const priceAtom = atom(100);
const quantityAtom = atom(1);
const taxRateAtom = atom(0.08);

// Átomo derivado de solo lectura
const subtotalAtom = atom((get) => {
  const price = get(priceAtom);
  const quantity = get(quantityAtom);
  return price * quantity;
});

const taxAtom = atom((get) => {
  const subtotal = get(subtotalAtom);
  const taxRate = get(taxRateAtom);
  return subtotal * taxRate;
});

const totalAtom = atom((get) => {
  return get(subtotalAtom) + get(taxAtom);
});

// Uso
function PriceSummary() {
  const subtotal = useAtomValue(subtotalAtom);
  const tax = useAtomValue(taxAtom);
  const total = useAtomValue(totalAtom);
  
  return (
    <div>
      <p>Subtotal: ${subtotal.toFixed(2)}</p>
      <p>Impuesto: ${tax.toFixed(2)}</p>
      <p>Total: ${total.toFixed(2)}</p>
    </div>
  );
}

Átomos Derivados Escribibles

import { atom, useAtom } from 'jotai';

const celsiusAtom = atom(25);

// Átomo derivado de lectura-escritura
const fahrenheitAtom = atom(
  // Getter
  (get) => get(celsiusAtom) * 9/5 + 32,
  // Setter
  (get, set, newFahrenheit) => {
    const celsius = (newFahrenheit - 32) * 5/9;
    set(celsiusAtom, celsius);
  }
);

function TemperatureConverter() {
  const [celsius, setCelsius] = useAtom(celsiusAtom);
  const [fahrenheit, setFahrenheit] = useAtom(fahrenheitAtom);
  
  return (
    <div>
      <label>
        Celsius:
        <input 
          type="number" 
          value={celsius}
          onChange={(e) => setCelsius(Number(e.target.value))}
        />
      </label>
      <label>
        Fahrenheit:
        <input 
          type="number" 
          value={fahrenheit}
          onChange={(e) => setFahrenheit(Number(e.target.value))}
        />
      </label>
    </div>
  );
}

Ejemplo de App de Tareas

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

// Átomo para la lista de tareas
const todosAtom = atom([]);

// Átomos derivados
const todoCountAtom = atom((get) => get(todosAtom).length);
const completedCountAtom = atom((get) => 
  get(todosAtom).filter(t => t.completed).length
);

// Átomos de acción (solo escritura)
const addTodoAtom = atom(null, (get, set, text) => {
  set(todosAtom, (prev) => [...prev, {
    id: Date.now(),
    text,
    completed: false,
  }]);
});

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

const deleteTodoAtom = atom(null, (get, set, id) => {
  set(todosAtom, (prev) => prev.filter(todo => todo.id !== id));
});

// Componentes
function AddTodo() {
  const [text, setText] = useState('');
  const addTodo = useSetAtom(addTodoAtom);
  
  const handleSubmit = (e) => {
    e.preventDefault();
    if (text.trim()) {
      addTodo(text);
      setText('');
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <button type="submit">Agregar</button>
    </form>
  );
}

function TodoList() {
  const todos = useAtomValue(todosAtom);
  const toggleTodo = useSetAtom(toggleTodoAtom);
  const deleteTodo = useSetAtom(deleteTodoAtom);
  
  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          <input
            type="checkbox"
            checked={todo.completed}
            onChange={() => toggleTodo(todo.id)}
          />
          {todo.text}
          <button onClick={() => deleteTodo(todo.id)}>×</button>
        </li>
      ))}
    </ul>
  );
}

function Stats() {
  const total = useAtomValue(todoCountAtom);
  const completed = useAtomValue(completedCountAtom);
  
  return <p>{completed} de {total} completadas</p>;
}

Átomos Asíncronos

import { atom, useAtomValue } from 'jotai';
import { Suspense } from 'react';

// Átomo asíncrono - devuelve una promesa
const userAtom = atom(async () => {
  const response = await fetch('/api/user');
  return response.json();
});

// Átomo asíncrono derivado
const userPostsAtom = atom(async (get) => {
  const user = await get(userAtom);
  const response = await fetch(`/api/users/${user.id}/posts`);
  return response.json();
});

// Usa con Suspense
function UserProfile() {
  const user = useAtomValue(userAtom);
  
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

function App() {
  return (
    <Suspense fallback={<div>Cargando...</div>}>
      <UserProfile />
    </Suspense>
  );
}

Átomo con Almacenamiento

import { atomWithStorage } from 'jotai/utils';

// Se sincroniza automáticamente con localStorage
const themeAtom = atomWithStorage('theme', 'light');
const userPrefsAtom = atomWithStorage('userPrefs', {
  notifications: true,
  language: 'es',
});

function ThemeToggle() {
  const [theme, setTheme] = useAtom(themeAtom);
  
  return (
    <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
      Actual: {theme}
    </button>
  );
}

Jotai vs Zustand

Aspecto Jotai Zustand
Modelo mental Átomos (bottom-up) Store (top-down)
Estructura del estado Átomos distribuidos Objeto único
Re-renders Suscripciones por átomo Basado en selectores
Estado derivado Átomos integrados get() en store

💡 Mejores Prácticas

  • • Mantén los átomos pequeños y enfocados
  • • Usa átomos derivados para valores computados
  • • Usa useAtomValue/useSetAtom para acceso de solo lectura/solo escritura
  • • Organiza átomos por característica o dominio
  • • Usa atomWithStorage para estado persistente