Acciones asíncronas
Receta
Define funciones async como acciones del store. Usa set para gestionar los estados de carga y error junto con la operación async. No se necesita middleware especial.
import { create } from "zustand";
interface User {
id: string;
name: string;
email: string;
}
interface UserStore {
users: User[];
isLoading: boolean;
error: string | null;
fetchUsers: () => Promise<void>;
}
export const useUserStore = create<UserStore>((set) => ({
users: [],
isLoading: false,
error: null,
fetchUsers: async () => {
set({ isLoading: true, error: null });
try {
const res = await fetch("/api/users");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const users = await res.json();
set({ users, isLoading: false });
} catch (err) {
set({ error: (err as Error).message, isLoading: false });
}
},
}));Ejemplo funcional
// stores/product-store.ts
import { create } from "zustand";
interface Product {
id: string;
name: string;
price: number;
stock: number;
}
interface ProductStore {
products: Product[];
selectedProduct: Product | null;
isLoading: boolean;
error: string | null;
fetchProducts: () => Promise<void>;
fetchProduct: (id: string) => Promise<void>;
createProduct: (input: Omit<Product, "id">) => Promise<Product>;
updateStock: (id: string, delta: number) => Promise<void>;
}
export const useProductStore = create<ProductStore>((set, get) => ({
products: [],
selectedProduct: null,
isLoading: false,
error: null,
fetchProducts: async () => {
set({ isLoading: true, error: null });
try {
const res = await fetch("/api/products");
const products = await res.json();
set({ products, isLoading: false });
} catch (err) {
set({ error: (err as Error).message, isLoading: false });
}
},
fetchProduct: async (id) => {
set({ isLoading: true, error: null });
try {
const res = await fetch(`/api/products/${id}`);
const product = await res.json();
set({ selectedProduct: product, isLoading: false });
} catch (err) {
set({ error: (err as Error).message, isLoading: false });
}
},
createProduct: async (input) => {
set({ isLoading: true, error: null });
try {
const res = await fetch("/api/products", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
const newProduct = await res.json();
set((state) => ({
products: [...state.products, newProduct],
isLoading: false,
}));
return newProduct;
} catch (err) {
set({ error: (err as Error).message, isLoading: false });
throw err;
}
},
updateStock: async (id, delta) => {
// Actualización optimista
const previousProducts = get().products;
set((state) => ({
products: state.products.map((p) =>
p.id === id ? { ...p, stock: p.stock + delta } : p
),
}));
try {
await fetch(`/api/products/${id}/stock`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ delta }),
});
} catch (err) {
// Revertir en caso de fallo
set({ products: previousProducts, error: (err as Error).message });
}
},
}));// components/product-list.tsx
"use client";
import { useEffect } from "react";
import { useProductStore } from "@/stores/product-store";
export function ProductList() {
const products = useProductStore((s) => s.products);
const isLoading = useProductStore((s) => s.isLoading);
const error = useProductStore((s) => s.error);
const fetchProducts = useProductStore((s) => s.fetchProducts);
useEffect(() => {
fetchProducts();
}, [fetchProducts]);
if (isLoading) return <div>Cargando productos...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
{products.map((p) => (
<div key={p.id}>
<h3>{p.name}</h3>
<p>${p.price} — {p.stock} en stock</p>
</div>
))}
</div>
);
}Análisis profundo
Cómo funciona
- Las acciones de Zustand son funciones simples. No hay distinción entre acciones sync y async a nivel del store.
setpuede llamarse varias veces en una acción async: una para establecer la carga, otra para establecer los datos o el error.- Cada llamada a
setprovoca una actualización de state sincrónica. React 18+ agrupa los renders, por lo que llamadas secuenciales rápidas asetpueden agruparse. - Las actualizaciones optimistas usan
get()para capturar el state actual antes de la operación async y luego revierten en caso de fallo. - Las acciones async pueden devolver valores, lo que permite al llamador reaccionar al resultado (p. ej., navegar tras la creación).
Variaciones
Helper genérico para acciones async:
function asyncAction<T>(
set: any,
fn: () => Promise<T>,
onSuccess: (data: T) => Partial<any>
) {
set({ isLoading: true, error: null });
return fn()
.then((data) => {
set({ ...onSuccess(data), isLoading: false });
return data;
})
.catch((err) => {
set({ error: (err as Error).message, isLoading: false });
throw err;
});
}
// Uso
const useStore = create((set) => ({
items: [],
isLoading: false,
error: null,
fetchItems: () =>
asyncAction(set, () => fetch("/api/items").then((r) => r.json()), (items) => ({ items })),
}));Estados de carga por acción:
interface Store {
data: Record<string, unknown>;
loading: Record<string, boolean>;
setLoading: (key: string, value: boolean) => void;
}
const useStore = create<Store>((set) => ({
data: {},
loading: {},
setLoading: (key, value) =>
set((s) => ({ loading: { ...s.loading, [key]: value } })),
}));AbortController para cancelación:
const useStore = create((set) => {
let controller: AbortController | null = null;
return {
data: null,
fetchData: async () => {
controller?.abort();
controller = new AbortController();
try {
const res = await fetch("/api/data", { signal: controller.signal });
set({ data: await res.json() });
} catch (err) {
if ((err as Error).name !== "AbortError") {
set({ error: (err as Error).message });
}
}
},
};
});Notas de TypeScript
- Las acciones async devuelven
Promise<void>oPromise<T>y deben tiparse en consecuencia. - El estrechamiento de errores con
(err as Error).messagees habitual, ya que los bloquescatchrecibenunknown.
interface AsyncState {
isLoading: boolean;
error: string | null;
}
interface WithAsync<T> extends AsyncState {
data: T | null;
fetch: () => Promise<void>;
}Errores comunes
- Un único booleano
isLoadingcompartido entre varias acciones async puede entrar en conflicto. SifetchProductsyfetchProductusan el mismoisLoading, uno puede sobrescribir al otro. - Las condiciones de carrera ocurren cuando la misma acción se llama varias veces de forma rápida. La última llamada gana, pero las llamadas intermedias a
setsiguen ejecutándose. Usa AbortControllers o ignora respuestas obsoletas. setdentro de un try/catch con código async no revierte automáticamente en caso de error. Debes gestionar la lógica de reversión manualmente.- Si una acción async lanza un error después de que el componente se desmonte, la llamada a
setsigue actualizando el store (sin error, pero la limpieza del efecto del componente antiguo ya no existe). - Zustand no rastrea promesas pendientes. Si necesitas deduplicar solicitudes en curso, implementa esa lógica tú mismo.
Alternativas
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| Acciones async inline | Simple, sin middleware | Gestión manual de estados de carga y error |
| SWR o React Query para fetching | Caché, deduplicación y reintento automáticos | Separado del store de Zustand |
| Redux Toolkit createAsyncThunk | Ciclo de vida estructurado (pending, fulfilled, rejected) | Requiere el ecosistema Redux |
| Middleware async personalizado | Reutilizable entre acciones | Complejidad añadida |
FAQs
¿Necesitas middleware especial para usar acciones async en Zustand?
- No. Las acciones de Zustand son funciones simples; no hay distinción entre sync y async a nivel del store.
- Solo define una función
asyncque llame aseten distintos puntos (carga, éxito, error).
¿Cómo gestionas los estados de carga y error para una acción async?
fetchUsers: async () => {
set({ isLoading: true, error: null });
try {
const res = await fetch("/api/users");
const users = await res.json();
set({ users, isLoading: false });
} catch (err) {
set({ error: (err as Error).message, isLoading: false });
}
},¿Cómo funciona el patrón de actualización optimista en Zustand?
- Usa
get()para capturar el state actual antes de la operación async. - Aplica el cambio optimista de inmediato con
set. - Si la llamada async falla, revierte llamando a
setcon la captura del state.
¿Puede una acción async devolver un valor al llamador?
- Sí. Las acciones async pueden devolver
Promise<T>. - El componente que llama puede hacer
awaitdel resultado, p. ej., para navegar tras una creación exitosa.
Trampa común: ¿Qué ocurre cuando dos acciones async comparten un único booleano isLoading?
- Entran en conflicto. Si
fetchProductsyfetchProductestablecenisLoading, uno puede sobrescribir el estado de carga del otro. - Usa claves de carga por acción (p. ej.,
loading: Record<string, boolean>) para evitarlo.
Trampa común: ¿Cómo gestionas las condiciones de carrera con llamadas async repetidas rápidamente?
- Usa un
AbortControllerpara cancelar la solicitud en curso anterior. - Comprueba
AbortErroren el bloque catch e ignóralo.
controller?.abort();
controller = new AbortController();
const res = await fetch(url, { signal: controller.signal });¿set dentro de un bloque catch revierte automáticamente las llamadas anteriores a set?
- No. Zustand no revierte automáticamente.
- Debes implementar la lógica de reversión manualmente usando una captura del state tomada antes de la operación async.
¿Qué ocurre si se llama a set después de que el componente que disparó la acción se desmonte?
- La llamada a
setsigue actualizando el store: los stores de Zustand viven fuera de React. - No hay error, pero la limpieza del efecto del componente desmontado ya no existe.
¿Cómo tipas una acción async en TypeScript?
interface UserStore {
users: User[];
isLoading: boolean;
error: string | null;
fetchUsers: () => Promise<void>;
createUser: (input: Omit<User, "id">) => Promise<User>;
}- Usa
Promise<void>para acciones fire-and-forget yPromise<T>cuando devuelvas datos.
¿Cómo tipas el error en un bloque catch con TypeScript?
- Los bloques
catchrecibenunknownen TypeScript. - Haz un cast explícito a
Error:(err as Error).message. - Alternativamente, usa un type guard para un estrechamiento más seguro.
¿Zustand deduplica automáticamente las solicitudes en curso?
- No. Zustand no rastrea promesas pendientes.
- Si necesitas deduplicación, impleméntala tú mismo (p. ej., con una bandera o AbortController).
¿Cómo reduce el boilerplate un helper genérico para acciones async?
- Envuelve el patrón
set({ isLoading: true })/ try / catch /set({ isLoading: false })en una función reutilizable. - Cada acción solo necesita proporcionar la lógica de fetch y un mapper de éxito.