Slices: partir el store sin romper el store
El patrón slice de Zustand no divide el estado en varios stores: divide el inicializador en funciones que reciben el mismo set y el mismo get y cuyos resultados se funden en un único objeto. Esta lección desarrolla la mecánica de esa fusión, el tipado con StateCreator y sus cuatro parámetros genéricos, la intersección que produce el tipo del store completo y el detalle que rompe a casi todos la primera vez: cómo se declaran los mutadores de los middleware cuando el inicializador vive en otro archivo. Termina con la parte que no es sintaxis sino diseño, que es dónde poner las fronteras, cuándo un slice puede leer a otro con get y por qué el acoplamiento cruzado sin control convierte la modularidad en una ilusión.
La libertad que hace agradable a Zustand es también su riesgo estructural: como nada te obliga a organizar el store, un archivo que empezó con tres campos termina con setenta y una función de trescientas líneas que nadie se atreve a tocar. El patrón slice es la respuesta idiomática, y conviene decir desde el principio qué no es: no crea varios stores, no crea espacios de nombres y no aísla nada en tiempo de ejecución. Lo único que hace es partir el inicializador en funciones que reciben el mismo set y el mismo get, y cuyos objetos se funden en uno solo. La modularidad que ganas es de código fuente, no de estado, y entender esa distinción es la diferencia entre usar slices con criterio y creer que te protegen de algo que no te protegen.
- Escribir slices como funciones creadoras y combinarlas en una sola llamada a
create. - Tipar el resultado con
StateCreatory la intersección de los tipos de cada slice. - Declarar los mutadores de middleware cuando el inicializador vive fuera de
create. - Decidir las fronteras entre slices y controlar el acoplamiento cruzado que permite
get.
La mecánica: una fusión de objetos, nada más
Un slice es una función con la misma firma que el inicializador —recibe set, get y api— que devuelve solo su porción del estado y sus acciones. Combinarlos consiste en propagar los mismos argumentos a todas las funciones creadoras y fundir sus resultados en un objeto. La expresión idiomática usa el operador de resto para no repetir los tres parámetros.
import { create } from 'zustand'
const crearAuth = (set, get) => ({
usuario: null,
entrar: (u) => set({ usuario: u }),
salir: () => set({ usuario: null }),
})
const crearCarrito = (set, get) => ({
lineas: [],
anadir: (l) => set((s) => ({ lineas: [...s.lineas, l] })),
})
export const useTienda = create((...a) => ({
...crearAuth(...a),
...crearCarrito(...a),
}))
Conviene ver con precisión qué ha ocurrido aquí, porque de ello se derivan todas las trampas del patrón. El estado resultante es plano: usuario y lineas conviven al mismo nivel, no hay un objeto auth ni un objeto carrito. Los slices comparten el mismo set, de modo que una acción de crearAuth puede escribir perfectamente un campo de crearCarrito sin que nada se lo impida. Y comparten el mismo get, así que cualquier slice ve el estado completo. La separación existe en los archivos y en tu cabeza; en memoria hay un único objeto.
flowchart TD A[crearAuth con set get api] --> D[fusion de objetos] B[crearCarrito con set get api] --> D C[crearUI con set get api] --> D D --> E[un unico estado plano en un unico store] E --> F[selectores que atraviesan cualquier slice] style D fill:#f9e2af,color:#11111b style E fill:#a6e3a1,color:#11111b
Que el estado sea plano tiene una consecuencia positiva que a menudo se pasa por alto: los selectores no se enteran de la partición. Un componente puede leer usuario y lineas en el mismo selector, y un slice nuevo no obliga a tocar ni un consumidor. La partición es reversible, algo que casi nunca ocurre cuando se elige la alternativa de montar varios stores independientes.
Tipar la intersección con StateCreator
En TypeScript cada slice necesita declarar dos cosas distintas: qué devuelve él y en qué store completo va a vivir, porque su set y su get operan sobre el store entero, no sobre su porción. StateCreator expresa justo eso con cuatro parámetros genéricos: el estado completo, la tupla de mutadores aplicados por fuera, la tupla de los aplicados por dentro y el tipo devuelto por esta función concreta.
import { create, type StateCreator } from 'zustand'
export type AuthSlice = {
usuario: string | null
entrar: (u: string) => void
salir: () => void
}
export type CarritoSlice = {
lineas: Linea[]
anadir: (l: Linea) => void
vaciarSiSinSesion: () => void
}
export type Tienda = AuthSlice & CarritoSlice // la interseccion es el store
export const crearAuth: StateCreator<Tienda, [], [], AuthSlice> = (set) => ({
usuario: null,
entrar: (u) => set({ usuario: u }),
salir: () => set({ usuario: null }),
})
export const useTienda = create<Tienda>()((...a) => ({
...crearAuth(...a),
...crearCarrito(...a),
}))
La tentación es tipar cada slice contra sí mismo, y produce un error desconcertante en cuanto un slice necesita leer a otro: get devuelve solo su propia porción. La dirección correcta es la inversa. El tipo Tienda se define arriba como intersección de todos los slices, y cada StateCreator lo recibe como primer parámetro; así set y get conocen el store completo mientras el cuarto parámetro sigue restringiendo lo que esta función está obligada a devolver. Es una dependencia circular entre tipos que TypeScript resuelve sin problema porque los tipos no se evalúan, y es la clave de todo el patrón.
Al añadir middleware, la tupla de mutadores deja de estar vacía y hay que escribirla en el segundo parámetro de cada slice que reciba un set alterado. Si usas immer, todos lo reciben.
type ConImmer = [['zustand/immer', never]]
export const crearCarrito: StateCreator<Tienda, ConImmer, [], CarritoSlice> = (set, get) => ({
lineas: [],
anadir: (l) => set((s) => { s.lineas.push(l) }),
vaciarSiSinSesion: () => {
if (get().usuario === null) set((s) => { s.lineas = [] }) // lee otro slice
},
})
Fronteras: dónde cortar y cuánto dejar cruzar
Cortar por dominio
Un slice por área del dominio con vida propia: sesión, carrito, preferencias de interfaz. No por tipo de dato ni por pantalla, que cambian mucho más rápido.
Lectura cruzada con get
Un slice puede consultar a otro con get. Es legítimo y a veces inevitable, pero cada lectura cruzada es una arista del grafo de acoplamiento que ya no puedes deshacer sin tocar dos archivos.
Escritura cruzada
Que un slice escriba campos de otro es el camino más corto a un store donde nadie sabe quién cambió qué. Si hace falta, expón una acción en el slice dueño y llámala.
Prefijos por convención
Como el estado es plano, dos slices pueden colisionar en un nombre y el último gana en silencio. Un prefijo corto en las claves ambiguas cuesta nada y elimina la clase entera de bugs.
Hay una variante que aparece en cuanto un slice necesita configuración de arranque: convertir la función creadora en una fábrica que recibe parámetros y devuelve el StateCreator. Cuesta un nivel más de paréntesis y resuelve limpiamente el sembrado de estado inicial desde el servidor o desde un test.
export const crearCarrito =
(inicial: Linea[] = []): StateCreator<Tienda, [], [], CarritoSlice> =>
(set, get) => ({
lineas: inicial,
anadir: (l) => set((s) => ({ lineas: [...s.lineas, l] })),
vaciarSiSinSesion: () => {
if (get().usuario === null) set({ lineas: [] })
},
})
export const useTienda = create<Tienda>()((...a) => ({
...crearAuth(...a),
...crearCarrito(lineasDelServidor)(...a),
}))
El criterio de corte que mejor envejece no es técnico sino de propiedad: un slice debe agrupar el estado que cambia por las mismas razones y a la misma cadencia. La sesión cambia al entrar y al salir; el carrito, con cada interacción de compra; el tema, casi nunca. Cortar así hace que las lecturas cruzadas sean pocas y direccionales, y una dirección clara —el carrito consulta la sesión, la sesión ignora el carrito— es lo que impide que el grafo se convierta en una maraña.
Cuando la lectura cruzada deja de bastar y un slice necesita reaccionar a los cambios de otro, la respuesta no es más get dentro de las acciones sino una suscripción explícita con subscribeWithSelector, declarada fuera de ambos slices. Así la relación queda escrita en un solo lugar en vez de repartida en las acciones de los dos, que es la diferencia entre un acoplamiento documentado y uno emergente.
Hay una crítica al patrón slice que suena demoledora y merece tomarse en serio: como el estado resultante es un único objeto plano, con un solo set y un solo get compartidos por todos, la modularidad es puramente cosmética; nada impide que cualquier slice escriba en cualquier otro, no existe encapsulación real, y por tanto lo que llamas frontera es un acuerdo social sin ninguna garantía que lo respalde. La crítica es literalmente correcta y sin embargo concluye mal, porque parte de la premisa de que la única modularidad valiosa es la que se impone por construcción. Lo que los slices dividen no es el espacio de estado sino el espacio de atención: reducen la cantidad de contexto que hay que sostener en la cabeza para modificar algo, permiten que dos personas trabajen sin colisionar y hacen que el diseño del dominio quede legible en la estructura de archivos. Eso no es poco, y es exactamente lo mismo que consiguen las carpetas por feature en Redux, que tampoco impiden que un reductor conozca el estado global. La pregunta interesante no es si el aislamiento es real, sino qué habrías tenido que pagar para que lo fuera: el aislamiento por construcción existe —son varios stores independientes— y su precio es que las lecturas cruzadas dejan de ser una línea con get para convertirse en sincronización explícita entre dos fuentes de verdad, con todo lo que eso arrastra de orden de actualización, estados intermedios inconsistentes y transacciones imposibles. Zustand toma partido por lo contrario: mantiene un único estado atómico —cada set deja el store en un estado consistente para todos los lectores a la vez— y deja las fronteras como disciplina. Es la misma apuesta que hace la librería entera y que ya conoces de la primera lección: quitar la ceremonia y devolverte la responsabilidad. Con equipos pequeños y dominios que caben en una cabeza, ese intercambio es casi siempre favorable; con muchas manos y ningún acuerdo escrito, las fronteras se erosionan exactamente a la velocidad a la que crece la prisa, y no habrá compilador que te avise.
- Toma un store con al menos veinte campos y córtalo en tres slices por dominio. Comprueba que ni un solo componente consumidor necesita cambiar.
- Tipa cada slice con
StateCreatorusando la intersección como primer parámetro. Prueba a tiparlo contra sí mismo y observa exactamente qué error aparece al usarget. - Añade
immera la pila y declara la tupla de mutadores en cada slice. Quítala en uno solo y anota el mensaje del compilador para reconocerlo la próxima vez. - Provoca una colisión de nombres deliberada entre dos slices y comprueba que el último gana sin ningún aviso. Decide qué convención de prefijos adoptas.
- Escribe una lectura cruzada con
gety luego la misma relación con una suscripción externa mediantesubscribeWithSelector. Compara cuál de las dos se entiende sin abrir el otro archivo. - Dibuja el grafo de dependencias entre tus slices marcando la dirección de cada lectura. Si aparece un ciclo, replantea el corte: casi siempre significa que las dos mitades eran un solo dominio.