¡Esta es una revisión vieja del documento!
Tabla de Contenidos
El contenedor de estado Redux
Notas del curso Desarrollo de aplicaciones web con React 18
Redux es un sistema que proporciona un almacén centralizado para el estado de una aplicación. A su vez, provee una interfaz denominada React-Redux que es la encargada de establecer la comunicación entre la aplicación de React y el almacén.
Aunque es muy común verlo utilizado junto a React, Redux es una biblioteca independiente que puede trabajar con otros mecanismos de desarrollo de interfaces, como Vue, Angular o Ember.
La idea principal de Redux es centralizar la información que deba ser global a toda la aplicación. En nuestro ejemplo principal del curso, dicha información podrían ser las diferentes listas de tareas que tenga el usuario que haya iniciado sesión, sus estados de completitud y datos asociados.
Evidentemente, no todo el estado de la aplicación debe almacenarse forzosamente en Redux. Es lógico que si hay partes de la aplicación con información local, como puede ser el estado de un formulario, esta se almacene localmente mediante useState, como ya conoces.
Elementos de una aplicación con Redux
Para programar utilizando Redux, es necesario familiarizarse con algunos términos que estudiaremos en detalle más adelante y que es importante que tengas claros:
- Acciones: una acción consiste en un objeto JavaScript que describe algún evento que ha ocurrido en la aplicación. Por ejemplo, al añadir una tarea a la lista de tareas se podría despachar una acción de tipo “
tareas/agregada”. Ojo, a pesar de su nombre, una acción no hace nada (no es una función), sino que describe una acción que se quiere realizar. Es como un mensaje que se transmite al almacén de Redux para que este sepa qué hacer. - Creador de acciones: se trata de una función que configura estos objetos que definen acciones, con su tipo y sus datos adicionales. Se utilizan para facilitar la creación de acciones.
- Reducers: llamaremos reducer a una función que tome el estado actual de la aplicación y una acción, y sea capaz de devolver un nuevo estado para la aplicación. Es decir, en jerga Redux, los reducer son realmente los que llevan las acciones a cabo. Al igual que en React, los estados no se pueden modificar sino que son inmutables, así que se debe devolver una copia con los valores convenientes actualizados.
- Almacén (store): se trata del objeto que almacena todo el estado de Redux.
- Selectores: son funciones que extraen los datos que necesitamos del almacén. Facilitan acceder a partes concretas del estado cuando este aumenta de tamaño y complejidad.
- Slices: son secciones que implementan el comportamiento y la gestión del estado de una parte de la aplicación, cada una aislada del resto.
Flujo de datos en Redux
Redux funciona fundamentalmente de la misma forma que React de cara a la gestión del estado y actualización de la interfaz.
Cuando se inicializa la aplicación, se crea el almacén de Redux y se le proporciona un reducer inicial. Se llama por primera vez al reducer y se obtiene un estado inicial, que se aplica a la primera renderización de la aplicación. A partir de este momento, cada vez que ocurra un evento en la aplicación:
- El gestor del evento despacha una acción al almacén de Redux
- El almacén invoca a la función de reducción para que combine el estado actual con la acción, obteniendo un nuevo estado
- El almacén notifica a todos los componentes que estén conectados de que el estado se ha actualizado
- Cada componente recupera los datos necesarios del almacén y lanza una renderización si su interfaz ha cambiado
Este es sin duda el módulo más complejo del curso. Dedícale tiempo a entender bien todo lo que se explica y vuelve hacia atrás para repasar conceptos e ideas si no los tienes claros. Abre el código que se entrega con el módulo para verlo en contexto mientras se explica. Por supuesto, deberías invertir tiempo en replicar en tu máquina los ejemplos que se muestran para realmente entenderlos. Es crucial que comprendas bien cómo funciona Redux para poder utilizarlo en tus propias aplicaciones. No te preocupes si no lo entiendes todo a la primera, ya que iremos viendo cada uno de los elementos de forma individual, tanto en la teoría como en los vídeos de demostración.
Construcción de un ejemplo con Redux desde cero
Antes de integrar totalmente Redux en nuestra aplicación, es conveniente practicar los conceptos fundamentales en una página aislada donde podamos aplicar buenas prácticas para usarlas más adelante. En este caso, vamos a simular un panel de usuario donde se muestra información acerca de la persona que inicie sesión.
Prototipo inicial
Este ejemplo se ha desarrollado de forma autocontenida, es decir, en un solo archivo HTML sin depender de ficheros JavaScript, dependencias con npm, etc. Lo encontrarás, ya finalizado, en el directorio panel-usuario-redux-standalone de la carpeta de código asociado a la lección. Por ello, utilizamos variables globales para llamar a los métodos de React/Redux, como en React.useState(""), en lugar de importar las funciones convenientes desde las bibliotecas. Deberías tratar de replicarlo desde cero por tu cuenta para asentar los conceptos bien.
Por un lado, tendremos un componente FormLogin que hará las veces de formulario para iniciar sesión. Como aún no vamos a integrar la aplicación con un servidor que compruebe usuarios y contraseñas, simplemente supondremos que ese trabajo está hecho y nos centraremos en implementar los componentes de React que almacenarán y consultarán el estado en Redux:
const FormLogin = () => { const [usuarioIniciar, setUsuarioIniciar] = React.useState("") return ( <form> <input type="text" value={usuarioIniciar} onChange={e => setUsuarioIniciar(e.target.value)} placeholder="alice" /><br/> {usuarioIniciar.length>0 && <button> Iniciar sesión como {usuarioIniciar}</button>} </form> ) }
Asimismo, tendremos un componente DatosUsuario que mostrará la información personal del usuario. Incluirá el número de veces que ha iniciado sesión, y una biografía editable. Por ahora asignaremos valores estáticos a estos datos, que pronto aprenderemos a obtener desde el almacén de Redux:
const DatosUsuario = ({ nombre }) => { const veces = 3 const initialBio = "" const [bio, setBio] = React.useState(initialBio) return ( <div> <p>Has iniciado sesión {veces === 1 ? "1 vez" : `${veces} veces`}.</p> <textarea value={bio} onChange={e => setBio(e.target.value)} placeholder="Biografía" /> <p><button>Guardar biografía</button> <button>Cerrar sesión</button></p> </div> ) }
El componente PanelUsuario se encargará de gestionar la sesión activa en cada momento, y mostrar el contenido adecuado en función de la situación. Supondremos que si no hay ninguna cuenta con sesión iniciada, tendremos el dato nombre a null.
const PanelUsuario = () => { const nombre = null return ( <div className="panel"> <h1>{nombre === null ? "Inicia sesión" : `¡Hola de nuevo, ${nombre}!`}</h1> {nombre === null ? <FormLogin /> : <DatosUsuario nombre={nombre} />} </div> ) }
Por último, el componente App nos servirá para instanciar el proveedor de Redux y englobar en él los componentes de nuestra aplicación:
const App = () => { return ( <PanelUsuario /> ) }
Con un poco de estilo CSS adicional, el ejemplo tendría un aspecto similar al siguiente y la interactividad que le vamos a añadir permitiría iniciar con varios usuarios, recordando siempre el número de veces que han accedido previamente y la biografía que hayan guardado:
Creación del almacén
Para crear el almacén de Redux necesitamos dos ingredientes principales: el estado inicial del almacén y un reducer raíz, que será la función que se invoque cada vez que se despache una acción desde la interfaz de usuario de la aplicación.
Estado inicial
El estado de un almacén de Redux suele ser, por lo general, un objeto JavaScript. Puede tratarse de un dato simple como un número o una cadena de caracteres, pero no está permitido usar objetos complejos como una función o una instancia de una clase.
En nuestro caso, tendremos una propiedad usuarioActual que almacenará el nombre de la cuenta de usuario que tiene iniciada sesión en el sistema, y una propiedad usuarios que contendrá los datos de todas las cuentas (el número de veces que han iniciado sesión y sus biografías):
const initialState = { usuarioActual: null, usuarios: {} }
Definición de los reducers
Cada vez que trabajemos con Redux, siempre tendremos que escribir al menos un reducer que se encargue de procesar las acciones y actualizar el estado acordemente.
Es muy común, además, que tengamos más de una función reducer para poder separar responsabilidades, especialmente cuando organicemos el código en diferentes ficheros en función de la característica que estemos implementando.
En nuestro caso, vamos a desarrollar dos funciones que reciban las acciones:
sesionReducer, que se encargará de los cambios relacionados con el inicio y el cierre de sesiónusuarioReducer, que realizará cambios sobre los datos personales de los usuarios.
La primera de ellas, por tanto, aceptará acciones de tipo “panel/sesionIniciada” y “panel/sesionCerrada”, mientras que la segunda admitirá acciones “datos/bioCambiada”.
La notación dominio/accionRealizada es una convención de Redux pero no es obligatoria, simplemente una recomendación para organizar las acciones.
Como verás en las siguientes implementaciones, una función reducer siempre toma dos parámetros, estado y acción (aunque este último se puede omitir en el caso más simple si solo hay una acción posible), y devuelve un nuevo estado en función del tipo de la acción:
function sesionReducer(state = initialState, action) { // Decidir el próximo estado en función del tipo de acción switch (action.type) { case "panel/sesionIniciada": const nombre = action.payload.usuario // Incrementar el número de veces que este usuario ha iniciado const nuevasVeces = state.usuarios[nombre]?.veces ? state.usuarios[nombre].veces + 1 : 1 // Producir un nuevo estado, COPIA DEL ANTERIOR, con los valores actuales return { ...state, usuarioActual: nombre, usuarios: { ...state.usuarios, [nombre]: { ...state.usuarios[nombre], veces: nuevasVeces } } } case "panel/sesionCerrada": // Restablecer el usuario actual return { ...state, usuarioActual: null } default: // Si la acción es de un tipo que este reducer no maneja, // devolver el estado tal cual return state } } function datosReducer(state = initialState, action) { if (action.type === "datos/bioCambiada") { // desestructuramos la acción para obtener únicamente los valores que necesitamos const { payload: { bio, usuario } } = action // no podemos mutar el estado: // state.usuarios[action.payload.usuario].bio = action.payload.bio return { ...state, usuarios: { ...state.usuarios, [usuario]: { ...state.usuarios[usuario], bio } } } } return state }
Observa que, en la séptima línea del listado anterior, se ha usado una sintaxis moderna de JavaScript para indicar un “acceso opcional” a la propiedad veces en state.usuarios[nombre] mediante el operador de encadenamiento opcional de ECMAScript (?.), que devuelve undefined si la clave dada por nombre no está presente en el objeto de usuarios. Si hubiéramos utilizado el operador de acceso habitual (.), lanzaría un error al tratar de acceder a una propiedad de un objeto que no existe.
Construcción del reducer raíz y del almacén
Puesto que el almacén de Redux únicamente toma como parámetro una función reducer, tenemos que componer las funciones que hemos definido antes para tener como resultado una sola función. Es convención llamar rootReducer al reducer principal:
const rootReducer = (state = initialState, action) => datosReducer(sesionReducer(state, action), action)
Esto sería equivalente a simplemente encadenar el código de una tras la otra, pero nos permite separar la gestión de las acciones en diferentes lugares.
Este mecanismo de composición permitiría, además, que varios reducers modifiquen el estado en base a la misma acción, si es algo necesario en nuestra aplicación.
Por lo general, sería recomendable que un determinado tipo de acción se gestione solamente en un único reducer.
Además, Redux incluye una funcionalidad denominada combineReducers (que estudiaremos más adelante), que efectúa otro tipo de combinación de las funciones, restringiendo el efecto que pueden tener sobre el estado.
Existe una biblioteca reduce-reducers que proporciona una función reduceReducers(), que resuelve la tarea de pasar el estado a través de los diferentes reducers exactamente con el mismo mecanismo que hemos dado aquí, simplificando el proceso cuando son más de dos funciones las que tenemos que aplicar.
Finalmente, para crear el almacén simplemente llamaríamos a la función createStore() de Redux, pasando como parámetro el reducer principal:
const store = Redux.createStore(rootReducer)
La constante store es la que utilizaremos para indicar el almacén de estado de Redux a la hora de integrarlo en la aplicación.
React-Redux: incorporando el estado a la interfaz
Hasta ahora hemos hecho uso independientemente de React para la creación de la interfaz y de Redux para la gestión del estado. A la hora de combinar ambos aspectos, necesitamos una biblioteca adicional conocida como React-Redux. Esta incluye un componente Provider y dos hooks importantes, useDispatch() y useSelector(). Vamos a verlos en acción:
const App = () => { return ( <ReactRedux.Provider store={store}> <PanelUsuario /> </ReactRedux.Provider> ) }
El componente Provider recibe la propiedad store para establecer el almacén de Redux, de forma que todos los componentes hijos puedan acceder a los datos simplemente invocando los métodos de React-Redux:
const PanelUsuario = () => { const nombre = ReactRedux.useSelector(state => state.usuarioActual) const dispatch = ReactRedux.useDispatch() const iniciarSesion = (usuario = "david") => { return {type: "panel/sesionIniciada", payload: { usuario }} } return ( <div className="panel"> <h1>{nombre === null ? "Inicia sesión" : `¡Hola de nuevo, ${nombre}!`}</h1> {nombre === null ? <FormLogin onInicioSesion={u => dispatch(iniciarSesion(u))} /> : <DatosUsuario nombre={nombre} onCierreSesion={() => dispatch({type: "panel/sesionCerrada"})} />} </div> ) }
Al llamar a useSelector() hemos de proporcionar un selector, es decir, una función que toma como parámetro el estado y devuelve la propiedad que nos interese consultar. En este caso, puesto que es fácil obtener el usuario actual, se ha definido como función flecha directamente en el parámetro de la llamada.
Por otro lado, useDispatch() nos devuelve la función dispatch a la que podremos llamar cuando sea necesario realizar un cambio en el estado. Por ejemplo, los componentes FormLogin y DatosUsuario tienen nuevos eventos onInicioSesion y onCierreSesion respectivamente, que se encargan de despachar las acciones convenientes para iniciar y cerrar la sesión. Los objetos que definen cada acción se pueden generar mediante funciones auxiliares, como en el caso de iniciarSesion(), o directamente se indican en la llamada a dispatch(), como en el segundo caso.
De forma similar, el componente DatosUsuario realiza la consulta acerca de las veces que se ha iniciado sesión en una cuenta dada y de la biografía, que además se puede modificar y guardar:
<code javascript>
const DatosUsuario = ({ nombre, onCierreSesion }) ⇒ {
const veces = ReactRedux.useSelector(state ⇒ state.usuarios[nombre]?.veces)
const initialBio = ReactRedux.useSelector(state ⇒ state.usuarios[nombre]?.bio) ?? “”
const dispatch = ReactRedux.useDispatch()
const [bio, setBio] = React.useState(initialBio)
const guardarBio = () ⇒ {
dispatch({
type: “datos/bioCambiada”,
payload: {
usuario: nombre,
bio
}
})
}
return (
<p>Has iniciado sesión {veces === 1 ? "1 vez" : `${veces} veces`}.</p>
<textarea value={bio} onChange={e => setBio(e.target.value)} placeholder="Biografía" />
<p><button onClick={guardarBio}>Guardar biografía</button>
<button onClick={onCierreSesion}>Cerrar sesión</button></p>
</div>
)
} </code>
Fíjate que en la línea 3 del fragmento anterior hemos utilizado el operador de "unión nulosa" o "nullish coalescing" de ECMAScript (??) para devolver la bio del usuario o una cadena vacía si no existe. Te dejo este enlace por si no tienes claro cómo funciona este operador.
Conclusión
Nuestro ejemplo de formulario que gestiona el inicio y cierre de sesiones y muestra los datos personales de las cuentas de usuario estaría completo.
Esta lección te ha servido para conocer los elementos fundamentales en los que se basa Redux para almacenar y comunicar el estado global de la aplicación. Es algo “duro” pero es importante conocer estos mecanismos primordiales de Redux. Sin embargo, en las próximas lecciones estudiaremos la forma moderna y recomendada de utilizar Redux en aplicaciones React, y entraremos más en detalle sobre cómo aprovechar los hooks y otros métodos que ofrece, incluyéndolos en el proyecto del gestor de tareas.
Si quieres utilizar el patrón de reducers y acciones para gestionar el estado de tu aplicación, pero no es lo suficientemente compleja como para utilizar Redux, tal vez puedas sustituirlo por el hook useReducer que viene ya integrado en React. Tienes un ejemplo de uso disponible en el archivo 010-contador.html asociado a esta lección. La sentencia clave es:
const [contador, dispatch] = useReducer(reducer, initialState)
donde asociamos el reducer con el estado inicial, y obtenemos de vuelta el estado actual y la función para despachar acciones.
