a
Arquitectura Flux y Zustand
Hemos seguido la práctica recomendada de React para gestionar el estado de la aplicación: definir el estado que necesitan varios componentes y las funciones que lo manejan en los componentes situados más arriba de la jerarquía. La mayor parte del estado y sus funciones suelen definirse directamente en el componente raíz y pasarse mediante props a los componentes que los necesitan. Esto funciona hasta cierto punto, pero, a medida que la aplicación crece, la gestión del estado se vuelve más difícil.
Arquitectura Flux
Facebook desarrolló la arquitectura Flux durante los primeros años de React para aliviar los problemas de gestión del estado. En Flux, la gestión del estado se separa por completo de los componentes de React y se traslada a stores externos. El estado del store no se modifica directamente, sino mediante actions específicas creadas para ello.
Cuando una acción cambia el estado del store, las vistas se vuelven a renderizar:

Si una interacción con la aplicación —por ejemplo, pulsar un botón— exige cambiar el estado, el cambio se realiza mediante una acción. Esto provoca que la vista vuelva a renderizarse:

Por lo tanto, Flux proporciona una forma estándar de cómo y dónde se mantiene el estado de la aplicación y de realizar cambios en ella.
Redux
Redux, que sigue la arquitectura Flux, fue la solución de gestión de estado dominante para aplicaciones React durante casi una década. En este curso, Redux también se utilizó hasta la primavera de 2026. Redux siempre ha estado plagado de complejidad y una gran cantidad de código repetitivo. La situación mejoró significativamente con la introducción de Redux Toolkit, pero a pesar de esto, la comunidad continuó desarrollando soluciones alternativas de gestión del estado, como MobX, Recoil y Jotai. Su popularidad ha variado.
El más interesante, y sin duda el más popular de los recién llegados, es Zustand, y también es nuestra elección como solución de gestión del estado. Zustand parece haber alcanzado ya la popularidad de Redux:

Zustand
Familiaricémonos con Zustand implementando una vez más una aplicación de contador:

Crea una nueva aplicación Vite e instala Zustand:
npm install zustandLa primera versión, donde sólo funciona el incremento del contador, es la siguiente:
import { create } from 'zustand'
const useCounterStore = create(set => ({
counter: 0,
increment: () => set(state => ({ counter: state.counter + 1 })),
}))
const App = () => {
const counter = useCounterStore(state => state.counter)
const increment = useCounterStore(state => state.increment)
return (
<div>
<div>{counter}</div>
<div>
<button onClick={increment}>plus</button>
<button>minus</button>
<button>zero</button>
</div>
</div>
)
}La aplicación comienza creando un store, es decir, el estado global, mediante la función create de Zustand:
import { create } from 'zustand'
const useCounterStore = create(set => ({
counter: 0,
increment: () => set(state => ({ counter: state.counter + 1 })),
}))La función recibe como parámetro una función que devuelve el estado que se definirá para la aplicación. El parámetro es, por tanto, el siguiente:
set => ({
counter: 0,
increment: () => set(state => ({ counter: state.counter + 1 })),
})Por tanto, el estado tiene counter definido con un valor de cero y increment que es una función.
Los componentes de la aplicación pueden acceder a los valores y funciones definidos en el estado a través de la función useCounterStore definida usando create de Zustand. El componente App utiliza selectors para recuperar el valor counter y la función increment del estado:
const App = () => {
// using selector to pick right part of the store state const counter = useCounterStore(state => state.counter) const increment = useCounterStore(state => state.increment)
return (
<div>
<div>{counter}</div> <div>
<button onClick={increment}>plus</button> <button>minus</button>
<button>zero</button>
</div>
</div>
)
}El código almacena el valor del contador del store en una variable de la siguiente manera:
const counter = useCounterStore(state => state.counter)Se utiliza una función selectora state => state.counter, que determina lo que se devuelve del contenido del store. De la misma forma, la función almacenada en el store se recupera en la variable increment.
La función de estado increment, que se definió de la siguiente manera, se proporciona como controlador de clic para el botón "más":
const useCounterStore = create(set => ({
counter: 0,
increment: () => set(state => ({ counter: state.counter + 1 })),}))Veamos la definición de la función por separado:
() => set(state => ({ counter: state.counter + 1 }))Esta es una función que llama a la función set dando otra función como parámetro. Esta función pasada como parámetro define cómo cambia el estado:
state => ({ counter: state.counter + 1 })que es una abreviatura de:
state => {
return { counter: state.counter + 1 }
}La función devuelve un nuevo estado, que calcula en función del estado anterior al que puede acceder mediante el parámetro state. Entonces, si el antiguo estado es, por ejemplo:
{
counter: 1,
increment: // function definition
}el nuevo estado se convierte en:
{
counter: 2,
increment: // function definition
}El estado siempre contiene también la función de cambio de estado increment.
La función de transición de estado
state => ({ counter: state.counter + 1 })solo afecta el valor counter en el estado.
Nada impediría cambiar la función en el estado dentro de la función de transición de estado; por ejemplo, si lo definimos de la siguiente manera:
state => {
return {
counter: state.counter + 1 ,
increment: console.log('increment broken')
}
}el botón de incremento sólo funcionaría la primera vez; después de eso, presionar el botón solo imprimiría en la consola.
Cuando el nuevo estado se establece como:
state => ({ counter: state.counter + 1 })solo se actualiza el valor de la clave counter en el estado; el nuevo estado se obtiene fusionando el estado anterior con el valor devuelto por la función de cambio de estado. Es por eso que la siguiente función de transición de estado:
state => ({})No afecta en absoluto al estado.
Completemos también la solicitud para los botones restantes:
const useCounterStore = create(set => ({
counter: 0,
increment: () => set(state => ({ counter: state.counter + 1 })),
decrement: () => set(state => ({ counter: state.counter - 1 })),
zero: () => set(() => ({ counter: 0 })),
}))
const App = () => {
const counter = useCounterStore(state => state.counter)
const increment = useCounterStore(state => state.increment)
const decrement = useCounterStore(state => state.decrement)
const zero = useCounterStore(state => state.zero)
return (
<div>
<div>{counter}</div>
<div>
<button onClick={increment}>plus</button>
<button onClick={decrement}>minus</button>
<button onClick={zero}>zero</button>
</div>
</div>
)
}¿De dónde vienen set y state?
¿De dónde viene set? Es una función auxiliar que proporciona create para actualizar el estado. create llama a la función que recibe como parámetro y le pasa set automáticamente. No necesitas llamarla ni importarla; Zustand se encarga de ello.
¿De dónde viene state? Cuando se proporciona una función como parámetro para set (en lugar de un nuevo objeto de estado directamente), Zustand llama a esa función con el estado actual del store como argumento. De esta manera, las funciones de actualización de estado pueden acceder al estado anterior para calcular el nuevo.
Uso del estado desde distintos componentes
Refactoricemos la aplicación para que la definición del store se mueva a su propio archivo store.js y la vista se divida en varios componentes, cada uno definido en sus propios archivos.
El contenido de store.js es sencillo:
export const useCounterStore = create(set => ({
counter: 0,
increment: () => set(state => ({ counter: state.counter + 1 })),
decrement: () => set(state => ({ counter: state.counter - 1 })),
zero: () => set(() => ({ counter: 0 })),
}))El componente App se simplifica de la siguiente manera:
import Display from './Display'
import Controls from './Controls'
const App = () => {
return (
<div>
<Display />
<Controls />
</div>
)
}
export default AppLo que es digno de mención aquí es que el componente App ya no pasa el estado a sus componentes secundarios. De hecho, el componente no afecta el estado de ninguna manera y la definición del store se ha separado completamente fuera del componente.
El componente que renderiza el valor del contador es sencillo:
import { useCounterStore } from './store'
const Display = () => {
const counter = useCounterStore(state => state.counter)
return (
<div>{counter}</div>
)
}
export default DisplayEl componente accede al valor del contador a través de la función useCounterStore que define el store. Esto es conveniente en muchos sentidos, por ejemplo, no es necesario pasar el estado al componente a través de props.
El componente que define los botones tiene este aspecto:
import { useCounterStore } from './store'
const Controls = () => {
const increment = useCounterStore(state => state.increment)
const decrement = useCounterStore(state => state.decrement)
const zero = useCounterStore(state => state.zero)
return (
<div>
<button onClick={increment}>plus</button>
<button onClick={decrement}>minus</button>
<button onClick={zero}>zero</button>
</div>
)
}
export default ControlsLa función useCounterStore toma una función selectora como parámetro, que determina qué parte del estado usar. Por ejemplo:
const increment = useCounterStore(state => state.increment)Aquí, la función selectora state => state.increment selecciona el valor de la clave increment del estado (la función que incrementa el contador) y lo almacena en la variable increment.
También podríamos acceder a todo el estado de la siguiente manera:
const state = useCounterStore()
// does the same as useCounterStore(state => state), i.e., selects the entire stateLuego podríamos referirnos al valor del contador y las funciones usando notación de puntos, es decir, state.counter y state.increment.
Surge una pregunta natural: ¿sería posible utilizar múltiples partes del estado mediante la desestructuración?
import { useCounterStore } from './store'
const Controls = () => {
const { increment, decrement, zero } = useCounterStore()
return (
<div>
<button onClick={increment}>plus</button>
<button onClick={decrement}>minus</button>
<button onClick={zero}>zero</button>
</div>
)
}
export default ControlsLa solución funciona, pero tiene un inconveniente importante. La desestructuración hace que el componente Controls se vuelva a representar cada vez que cambia el valor del contador, aunque el componente solo muestra los botones y no el valor en sí.
Por lo tanto, la mejor práctica en Zustand es seleccionar del estado exactamente sólo aquellas piezas que se necesitan en el componente dado. Un componente se vuelve a renderizar solo cuando cambia la parte del estado que ha seleccionado. Cuando en lugar de escribir:
const { increment, decrement, zero } = useCounterStore() el componente ya no reacciona a los cambios en el valor del contador porque no lo ha seleccionado del estado.
Reorganización del estado
Sin embargo, podemos obtener una solución bastante clara reorganizando el estado de la siguiente manera:
export const useCounterStore = create(set => ({
counter: 0,
actions: {
increment: () => set(state => ({ counter: state.counter + 1 })),
decrement: () => set(state => ({ counter: state.counter - 1 })),
zero: () => set(() => ({ counter: 0 })),
}
}))Las funciones de cambio de estado ahora están agrupadas bajo su propia clave actions, y pueden seleccionarse como un todo y desestructurarse:
const Controls = () => {
const { increment, decrement, zero } = useCounterStore(state => state.actions)
return (
<div>
<button onClick={increment}>plus</button>
<button onClick={decrement}>minus</button>
<button onClick={zero}>zero</button>
</div>
)
}Ahora no se vuelve a renderizar, ya que solo se han seleccionado las funciones del estado y permanecen iguales durante toda la vida útil del store.
Según algunas buenas prácticas, no conviene exportar la función que da acceso al estado completo para utilizarla por toda la aplicación. Es preferible crear a partir de ella vistas más pequeñas que expongan solo las partes necesarias del estado. Modifiquemos store.js de la siguiente manera:
import { create } from 'zustand'
const useCounterStore = create(set => ({
counter: 0,
actions: {
increment: () => set(state => ({ counter: state.counter + 1 })),
decrement: () => set(state => ({ counter: state.counter - 1 })),
zero: () => set(() => ({ counter: 0 })),
}
}))
// the hook functions that are used elsewhere in app
export const useCounter = () => useCounterStore(state => state.counter)
export const useCounterControls = () => useCounterStore(state => state.actions)Ahora, fuera del módulo que define el estado, están disponibles las funciones useCounter, que devuelve el valor del contador cuando se llama, y useCounterControls, que devuelve las funciones que modifican el valor del contador. El uso cambia ligeramente:
import { useCounter } from './store'
const Display = () => {
const counter = useCounter()
return (
<div>{counter}</div>
)
}import { useCounterControls } from './store'
const Controls = () => {
const { increment, decrement, zero } = useCounterControls()
return (
<div>
<button onClick={increment}>plus</button>
<button onClick={decrement}>minus</button>
<button onClick={zero}>zero</button>
</div>
)
}Cuando se usa el estado de esta manera, ya no es necesario usar funciones selectoras, ya que su uso está oculto dentro de la definición de las nuevas funciones auxiliares.
Los más observadores han notado que las funciones relacionadas con Zustand se nombran comenzando con la palabra use. La razón de esto es que la función devuelta por la función create de Zustand (en nuestro ejemplo useCounterStore) es una función React enganche personalizado. Nuestras propias funciones auxiliares useCounter y useCounterControls también son esencialmente hooks personalizados, porque ocultan el uso del hook personalizado useCounterStore dentro de ellas.
Los hooks personalizados están sujetos a una serie de reglas; por ejemplo, sus nombres deben comenzar siempre por use. Las reglas de los hooks estudiadas en la parte 1 también se aplican a los hooks personalizados.
Notas de Zustand
Nuestro objetivo es crear una versión basada en Zustand de la antigua aplicación de notas.
La primera versión de la aplicación es la siguiente. El componente App:
import { useNotes } from './store'
const App = () => {
const notes = useNotes()
return (
<div>
<ul>
{notes.map(note => (
<li key={note.id}>
{note.important ? <strong>{note.content}</strong> : note.content}
</li>
))}
</ul>
</div>
)
}
export default AppEl store se define inicialmente de la siguiente manera:
import { create } from 'zustand'
const useNoteStore = create(set => ({
notes: [
{
id: 1,
content: 'Zustand is less complex than Redux',
important: true,
},
],
}))
export const useNotes = () => useNoteStore(state => state.notes)Por ahora, la aplicación no permite añadir notas nuevas y el store tampoco lo admite todavía. El estado se ha inicializado con una nota para que podamos comprobar que la aplicación lo renderiza correctamente.
Funciones puras y objetos inmutables
El primer intento de acción que añade una nota es el siguiente:
note => set(
state => {
state.notes.push(note)
return state
}
)La función recibe una nota como parámetro y devuelve un estado en el que se ha agregado una nueva nota al estado anterior state.
Sin embargo, nuestro intento infringe las reglas. La documentación de Zustand indica que, al igual que con useState de React, debemos actualizar el estado de forma inmutable. Como sabemos, state.notes.push muta el objeto de estado, así que debemos modificar la solución.
La forma correcta es usar, por ejemplo, la función Array.concat, que no modifica el estado existente sino que crea una nueva copia del mismo con la nueva nota agregada:
note => set(
state => {
return { notes: state.notes.concat(note) }
}
)La definición de store ahora tiene el siguiente aspecto:
import { create } from 'zustand'
const useNoteStore = create(set => ({
notes: [],
actions: {
add: note => set(
state => ({ notes: state.notes.concat(note) })
)
}
}))
export const useNotes = () => useNoteStore(state => state.notes)
export const useNoteActions = () => useNoteStore(state => state.actions)Sintaxis de extensión de array
Otra forma comúnmente vista de hacer lo mismo es usar la sintaxis de array spread:
state => ({ notes: [...state.notes, note] })Aquí se forma un array expandiendo los elementos del array state.notes mediante la sintaxis spread y añadiendo después la nueva nota al final. Elegir entre spread y concat es una cuestión de preferencia.
Técnicamente hablando, el estado creado con Zustand es inmutable, y las funciones de acción que modifican el estado deben ser funciones puras.
Las funciones puras son aquellas que no producen efectos secundarios y siempre devuelven el mismo resultado cuando se llaman con los mismos parámetros.
Formulario no controlado
Agreguemos la capacidad de crear nuevas notas a la aplicación:
import { useNotes, useNoteActions } from './store'
const App = () => {
const notes = useNotes()
const { add } = useNoteActions()
const generateId = () => Number((Math.random() * 1000000).toFixed(0))
const addNote = (e) => { e.preventDefault() const content = e.target.note.value add({ id: generateId(), content, important: false }) e.target.reset() }
return (
<div>
<form onSubmit={addNote}> <input name="note" /> <button type="submit">add</button> </form> <ul>
{notes.map(note => (
<li key={note.id}>
{note.important ? <strong>{note.content}</strong> : note.content}
</li>
))}
</ul>
</div>
)
}La implementación es bastante sencilla. Lo que es digno de mención acerca de agregar una nueva nota es que, a diferencia de los formularios anteriores implementados con React, tenemos not vinculado el valor del campo del formulario al estado del componente App. React llama a dichas formas incontroladas.
Las formas no controladas tienen ciertas limitaciones. No permiten, por ejemplo, proporcionar mensajes de validación sobre la marcha, desactivar el botón de envío según el contenido, etc. Sin embargo, esta vez son adecuados para nuestro caso de uso. Si lo deseas, puedes leer más sobre el tema aquí.
El formulario es muy sencillo:
<form onSubmit={addNote}>
<input name="note" />
<button type="submit">add</button>
</form>Lo que llama la atención del formulario es que el campo de entrada tiene un nombre. Esto permite que la función del controlador acceda al valor del campo.
El controlador de sumas también es sencillo:
const addNote = (e) => {
e.preventDefault()
const content = e.target.note.value
add({ id: generateId(), content, important: false })
e.target.reset()
}El contenido se recupera del campo de texto del formulario usando e.target.note.value en una variable, que se usa como parámetro en la llamada a la función de agregar notas add.
La última línea, e.target.reset(), borra el formulario.
El código actual de la aplicación está disponible en su totalidad en GitHub, en la rama part6-1.
Más componentes y funcionalidades
Dividamos la aplicación en más componentes. Separaremos la creación de una nueva nota, la lista de notas y la visualización de una sola nota en sus propios componentes.
El componente App después del cambio es simple:
const App = () => (
<div>
<NoteForm />
<NoteList />
</div>
)La creación de notas, es decir, NoteForm, no contiene nada dramático, por lo que el código no se muestra aquí.
El componente responsable de enumerar las notas, NoteList, tiene el siguiente aspecto:
import { useNotes } from './store'
import Note from './Note'
const NoteList = () => {
const notes = useNotes()
return (
<ul>
{notes.map(note => (
<Note key={note.id} note={note} />
))}
</ul>
)
}El componente recupera la lista de notas del store y crea un componente Note correspondiente para cada una, pasando los datos de la nota como props:
const Note = ({ note }) => (
<li>
{note.important ? <strong>{note.content}</strong> : note.content}
</li>
)Agreguemos también la capacidad de alternar la importancia de una nota. El componente después del cambio es el siguiente:
import { useNoteActions } from './store'
const Note = ({ note }) => {
const { toggleImportance } = useNoteActions()
return (
<li>
{note.important ? <strong>{note.content}</strong> : note.content}
<button onClick={() => toggleImportance(note.id)}> {note.important ? 'make not important' : 'make important'} </button> </li>
)
}El componente desestructura la función de alternancia de importancia a partir del valor de retorno de useNoteActions y la llama cuando se hace clic en el botón de alternancia.
La implementación de la función de cambio de importancia se parece a la siguiente:
import { create } from 'zustand'
const useNoteStore = create(set => ({
notes: [],
actions: {
add: note => set(
state => ({ notes: state.notes.concat(note) })
),
toggleImportance: id => set( state => ({ notes: state.notes.map(note => note.id === id ? { ...note, important: !note.important } : note ) }) ) }
}))La función recibe como parámetro el id de la nota a modificar. El nuevo estado se forma a partir del estado anterior utilizando la función map de modo que se incluyan todas las notas antiguas, excepto la nota que se va a modificar, para la cual se crea una versión donde se alterna su importancia:
{ ...note, important: !note.important } El código actual de la aplicación está disponible en su totalidad en GitHub, en la rama part6-2.

