El propósito de este artículo es explicar cómo estructurar una aplicación básica de React en conjunto con Redux, explicando también algunos conceptos teóricos elementales.
Cabe destacar que este artículo no incorpora la herramienta Redux Toolkit. Esto será explicado en un artículo siguiente pero vamos a adelantar que Readux Toolkit representa una forma mejorada y simplificada de trabajar. De cualquier modo, creemos importante conocer primero cómo es la forma clásica.
Para facilitar la comprensión, este artículo utiliza nombres en español en variables e identificadores en general creados por nosotros. Hace un tiempo encontré que esta diferenciación ayuda bastante a comprender y separar lo que es parte del lenguaje o framework de algo que estamos creando como desarrolladores.
Principios de Redux
Empezaremos mencionando que Redux es una librería independiente de React. Se puede utilizar en forma totalmente separada. En este caso vamos a ver la forma de utilizarla en una aplicación React.
Redux es un framework de JavaScript cuya función es permitir compartir variables de estado de una forma global a nuestra aplicación.
Por el contrario, con React se mantienen estados independientes en cada componente y, si es necesario pasar información de estado de un componente a otro, tenemos que duplicarla.
La forma de funcionar de Redux está basada en tres elementos básicos: actions, stores y reducers. Voy a explicar brevemente cada uno a continuación:
Estado inicial
Antes que nada, nuestra aplicación debe definir su estado inicial. Supongamos que nuestra aplicación representa a una manzana. El estado inicial podría ser algo así:
const manzanaInicial = {
color: 'roja',
sucia: true,
bocadosRestantes: 5
};
Actions
Las acciones son la forma que tiene nuestra aplicación de comunicarse con Redux. Básicamente, las acciones son objetos con una propiedad type requerida, la cual indica el nombre de la acción a realizar.
Si se requieren enviar datos adicionales, normalmente se envían en un objeto payload, aunque puede tener cualquier nombre o bien, se pueden enviar como propiedades adicionales del objeto action.
El envío de las acciones a los reducers se realiza mediante la función dispatch del store. El dispatch recibe un único argumente, que es la action. En función del type de la acción, el reducer realizará los cambios al estado que sean necesarios.
Más adelante veremos también el concepto de “action creators”. Estos no son otra cosa que “wrappers” para las llamadas al dispatch cuando se necesitan realizar operaciones un poco más complejas en las que interviene otro elemento llamado middleware.
De esta forma, en lugar de hacer un dispatch({ type: “cargarDatos”}) tal vez estemos llamando a una función cargarDatos() que dentro tendrá una llamada a una función asincrónica y luego llamará al dispatch con los datos obtenidos: dispatch({ type: “cargarDatos”, payload: datos}).
Siguiendo con nuestro ejemplo de la manzana, las posibles acciones que podríamos realizar sobre ella podrían ser:
const LAVAR = { type: 'LAVAR' };
const COMER = { type: 'COMER', bocados: 2 };
const PODRIR = { type: 'PODRIR' };
Reducers
Son funciones que realizan cambios en el estado de acuerdo a la acción recibida. Por consiguiente, son funciones que reciben un estado inicial y un objeto action. El objeto action tiene siempre una propiedad type y luego puede contener datos adicionales. El reducer es el único que puede cambiar el estado, ya que no se puede acceder al store de otra forma.
Un ejemplo de función reducer para nuestra manzana podría ser el siguiente:
function manzanaReducer(state = manzanaInicial, action) {
switch(action.type) {
case 'LAVAR':
// mantiene todos los estados y cambia el campo sucia a falso
return { ...state, sucia: false };
case 'COMER':
// decrementa el número de bocados
return {
...state,
bocadosRestantes: Math.max(0, state.bocadosRestantes - action.bocados)
};
case 'PODRIR':
// cambia el color a marrón
return { ...state, color: 'marrón' };
// no sabemos cómo tratar otras acciones así que solo devolvemos el estado actual
default:
return state;
}
}
En el argumento del switch también podríamos utilizar simplemente action y utilizar las constantes que definimos en lugar de los strings. De esta forma evitaríamos errores al tipear mal los strings.
Store
El store es el lugar donde nuestra aplicación guardará el estado. Lo más recomendable es tener un único store por aplicación. La creación se realiza mediante la función createStore la cual recibe como primer argumento los reducers que vamos a utilizar y como segundo argumento, el estado inicial. Opcionalmente se puede definir un middleware y enviarlo como tercer argumento (luego explicaremos la función del middleware).
En nuestro ejemplo, podemos crear el store de la siguiente forma:
const miStore = Redux.createStore(manzanaReducer, manzanaInicial);
Una vez que tenemos esta configuración básica, podemos hacer el dispatch de acciones e ir cambiando por los diferentes estados.
Supongamos que lavamos la manzana. Esto equivale a hacer:
miStore.dispatch(LAVAR);
Si hacemos un console.log(store.getState()) antes y después del dispatch obtendremos lo siguiente:
{ color: 'roja', sucia: true, bocadosRestantes: 5 } // antes
{ color: 'roja', sucia: false, bocadosRestantes: 5 } // después
El ejemplo completo
Para probar el ejemplo completo que planteamos aquí, pueden crear un nuevo archivo llamado index.js (o cualquier nombre) en Visual Studio Code y copiar el código completo a continuación:
index.js
const Redux = require('redux'); // Aquí utilizamos require porque no estamos trabajando con un módulo
// DEFINIMOS NUESTRA MANZANA INICIAL (ESTADO INICIAL):
const manzanaInicial = {
color: 'roja',
sucia: true,
bocadosRestantes: 5
};
// DEFINIMOS NUESTRAS POSIBLES ACCIONES:
const LAVAR = { type: 'LAVAR' };
const COMER = { type: 'COMER', bocados: 2 };
const PODRIR = { type: 'PODRIR' };
// CREAMOS LA FUNCIÓN QUE UTILIZAREMOS COMO REDUCER
function manzanaReducer(state = manzanaInicial, action) {
switch(action) {
case LAVAR:
// mantiene todos los estados y cambia el campo sucia a falso
return { ...state, sucia: false };
case COMER:
// decrementa el número de bocados
return {
...state,
bocadosRestantes: Math.max(0, state.bocadosRestantes - action.bocados)
};
case PODRIR:
// cambia el color a marrón
return { ...state, color: 'marrón', bocadosRestantes: 0 };
// no sabemos cómo tratar otras acciones así que solo devolvemos el estado actual
default:
return state;
}
}
// CREAMOS NUESTRO STORE
// Le indicamos qué reducer (o reducers) debe utilizar y cuál es el
// estado inicial de nuestra aplicación
const miStore = Redux.createStore(manzanaReducer, manzanaInicial);
// Mostramos el estado inicial antes de enviar algún dispatch
console.log(miStore.getState());
miStore.dispatch(LAVAR);
console.log(miStore.getState());
miStore.dispatch(COMER);
console.log(miStore.getState());
miStore.dispatch(PODRIR);
console.log(miStore.getState());
Luego, abrimos una terminal dentro del mismo Visual Studio Code yendo a Terminal/New Terminal.
En la terminal ejecutamos nuestro script con node de la siguiente forma:
node index.js
El resultado será el siguiente:
{ color: 'roja', sucia: true, bocadosRestantes: 5 }
{ color: 'roja', sucia: false, bocadosRestantes: 5 }
{ color: 'roja', sucia: false, bocadosRestantes: 3 }
{ color: 'marrón', sucia: false, bocadosRestantes: 0 }
Hasta aquí hemos visto solamente los conceptos básicos de Redux sin siquiera haber utilizado nada de React. A continuación vamos a ver cómo utilizarlo ya integrado en una React App.
Ejemplo inspirado en: https://dev.to/hemanth/explain-redux-like-im-five.
Agregando Redux a React
El objetivo de este artículo es explicar cómo incorporar Redux a una aplicación React de una forma más o menos básica y reutilizable.
Como vimos antes, podemos tener una aplicación totalmente funcional que usa Redux en un solo archivo. Del mismo modo también podríamos incorporar componentes de React varios en el mismo archivo.
Sin embargo, queremos presentar una forma de trabajar más organizada para que nuestras aplicaciones sean más fáciles de mantener.
Vamos a considerar el desarrollo de una app muy simple que mostrará una lista de usuarios, los cuales los vamos a obtener de https://jsonplaceholder.typicode.com/users.
Empecemos con la instalación de los paquetes redux y react-redux con npm:
npm install redux react-redux
Crearemos una aplicación simple en React:
npx create-react-app redux-blog
Por el momento vamos a crear un componente muy básico de React que listará los usuarios de la forma clásica y luego lo modificaremos.
Creemos entonces una carpeta components. Movamos el archivo App.js dentro de esta carpeta para mantener todos los componentes organizados ahí dentro. Si estamos trabajando con Visual Studio Code, nos preguntará si queremos actualizar los imports automáticamente. Si no, deberemos corregir el import a App en el archivo src/index.js.
Si ya habíamos ejecutado npm run build deberemos reiniciarlo luego de este cambio.
Creación del componente Usuarios
Seguidamente creamos la carpeta usuarios dentro de components y dentro, el archivo index.js que contendrá nuestro componente.
Para la llamada “clásica”, necesitaremos instalar axios. Esta es una herramienta que nos servirá para realizar llamadas a web apis:
npm install axios
Nuestro componente “clásico” quedaría así:
Archivo src/components/usuarios/index.js
import React, {Component} from 'react';
import axios from 'axios';
class Usuarios extends Component {
state = { usuarios: [] };
async componentDidMount() {
const respuesta = await axios.get('https://jsonplaceholder.typicode.com/users');
this.setState({usuarios: respuesta.data});
}
render() {
return (
<ul>
{this.state.usuarios.map((usuario) => (
<li>{usuario.name} ({usuario.email})</li>
))}
</ul>
);
}
};
export default Usuarios;
Con esto ya deberíamos poder visualizar la lista de usuarios y sus emails en el navegador:
Esta sería una aplicación React extremadamente sencilla. El único estado que tiene esta aplicación es el array de usuarios obtenidos. Tampoco es una aplicación que justifique el uso de Redux, pero lo vamos a aplicar para aprenderlo.
Action Creators
En primer lugar crearemos una carpeta src/actions. En esta carpeta crearemos los archivos de acuerdo a funcionalidades similares. Por ejemplo, AuthenticationActions.js, contendría signInAction() o logoutAction. Un archivo BlogActions.js contendría acciones tales como getBlogPostActionI(), deleteComment() o updateBlogPostAction().
Archivo src/actions/usuariosActions.js
import axios from 'axios';
export const traerTodos = () => async (dispatch) => {
const respuesta = await axios.get('https://jsonplaceholder.typicode.com/users');
dispatch({
type: 'traer_usuarios',
payload: respuesta.data
})
};
En este ejemplo estamos definiendo la acción traerTodos. Al igual que todas las acciones, esta es una función que devuelve otra función. En este caso, la función devuelta por traerTodos está marcada como asincrónica (async) porque dentro tenemos una llamada a otra función asincrónica (indicada con await), axios.get().
Es importante destacar que podemos utilizar traerTodos como una acción porque vamos a utilizar el middleware Redux Thunk. De lo contrario, las acciones deberían ser objetos planos como lo es el argumento del dispatch.
Una vez que tenemos el resultado del axios.get() en la constante respuesta, llamamos al dispatch y le enviamos el tipo de acción a llamar (traer_usuarios) y el payload, que es respuesta.data.
Hay que tener en cuenta que, según la teoría de Redux, la acción propiamente dicha es lo que estamos enviando como argumento del dispatch. Con esta función traer_todos que creamos estamos “wrappeando” la llamada al dispatch para agregar algo de funcionalidad adicional.
Reducers
Crearemos una carpeta src/reducers y dentro, nuestro primer reducer:
Archivo: src/reducers/usuariosReducer.js:
const INITIAL_STATE = {
usuarios: [] // el estado inicial es un array vacío llamado usuarios
};
export default function UsuariosReducer(state = INITIAL_STATE, action){
switch (action.type) {
case 'traer_usuarios':
return { ...state, usuarios: action.payload };
default: return state;
};
};
Como nuestra aplicación puede contener varios reducers, vamos a crear un “índice” y a combinarlos a todos en una única variable.
En la misma carpeta crearemos un archivo index.js. Este archivo nos servirá para la combinación que mencionamos. Para poder hacer esto primero tenemos que importar la función combineReducers.
Archivo: src/reducers/index.js
import { combineReducers } from 'redux';
import usuariosReducer from './usuariosReducer';
export default combineReducers({
usuariosReducer
});
En este caso estamos utilizando un único reducer llamado usuariosReducer.
Configuración del Store
Ahora que ya tenemos nuestros reducers, podemos crear nuestro Store en nuestro src/index.js de la siguiente manera:
import misReducers from './reducers';
const store = createStore(
misReducers, // Reducers
{}, // Estado inicial
);
Aquí estamos importando el módulo que definimos en reducers/index.js (from ‘./reducers’) y, como es un export de tipo default podemos darle el nombre que queremos. En este caso lo llamamos misReducers.
Seguidamente, creamos nuestro único store al que llamaremos miStore. Para ello hacemos uso de la función createStore que recibe los reducers que utilizaremos y el estado inicial (algunos desarrolladores prefieren utilizar el createStore en el index.js dentro de una carpeta src/store).
Otro cambio que nos falta es el agregado del Provider para indicarle a nuestro App cuál será el store que utilizará nuestra aplicación. Así es como nos queda todo el index.js:
Archivo src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './components/App';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import misReducers from './reducers';
const miStore = createStore(
misReducers, // Reducers
{}, // Estado inicial
);
ReactDOM.render(
<Provider store={ miStore }>
<App />
</Provider>,
document.getElementById('root')
);
Uso de Middleware
Un middleware, como Redux Thunk, nos permite interceptar un dispatch de Redux para devolver una función en lugar de un objeto action plano, que sería lo esperable. Esto es especialmente útil en el caso de llamadas asincrónicas. El middleware retrasa entonces el dispatch de la acción hasta que se completa una línea de código asincrónica. Más info: https://platzi.com/blog/como-funciona-redux-thunk/
Para comenzar, hay que realizar la instalación con:
npm install redux-thunk
En src/index.js falta añadir el middleware que nos permitirá realizar las llamadas async. Para esto hay que comenzar por importar reduxThunk desde ‘redux-thunk‘ y la función applyMiddleware de ‘redux‘:
import reduxThunk from 'redux-thunk';
import { createStore, applyMiddleware } from 'redux';
Vamos a aplicar el reduxThunk como middleware. Esto lo debemos especificar en la función createStore. Con estos cambios, nuestro index quedaría así:
Archivo src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './components/App';
import misReducers from './reducers';
import { createStore, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import reduxThunk from 'redux-thunk';
const miStore = createStore(
misReducers,
{},
applyMiddleware(reduxThunk)
);
ReactDOM.render(
<Provider store={miStore}>
<App />
</Provider>,
document.getElementById('root')
);
Conexión a un componente
Para poder conectar nuestro componente con el reducer necesitamos importar la función connect de React-Redux:
import { connect } from 'react-redux';
También tendremos que importar las actions (las crearemos a continuación):
import * as usuariosActions from '../../actions/usuariosActions';
Ya que tengo esto ya puedo conectar mi componente. Al final de nuestro componente primero debemos definir la función mapStateToProps, la cual recibirá como parámetro todos los reducers y puedo devolver los reducers que necesito utilizar.
Una vez que tenemos esta función definida, se le pasa como primer parámetro a la función connect y, como segundo parámetro, todos los actions
const mapStateToProps = (misReducers) => {
return misReducers.usuariosReducer;
};
export default connect(mapStateToProps, usuariosActions) (Usuarios);
Finalmente, nuestro componente ya podría llamar a la acción traerTodos en el componentDidMount. A continuación, el componente completo:
Archivo src/components/usuarios/index.js
import React, {Component} from 'react';
import { connect } from 'react-redux';
import * as usuariosActions from '../../actions/usuariosActions';
class Usuarios extends Component {
async componentDidMount() {
this.props.traerTodos();
}
render() {
console.log(this.props);
return (
<ul>
{this.props.usuarios.map((usuario) => (
<li>{usuario.name} ({usuario.email})</li>
))}
</ul>
);
}
};
const mapStateToProps = (reducersRecibidos) => {
return reducersRecibidos.usuariosReducer;
};
export default connect(mapStateToProps, usuariosActions)(Usuarios);
Si observan, acá hemos modificado el método componentDidMount. Ya no necesitamos hacer la petición aquí por medio de axios. Esto lo hacemos ahora llamando a la acción traerTodos().
Otras cosas que hemos modificado:
- Quitamos el import axios.
- Eliminamos la inicialización del state.
- Ya no trabajamos más con la variable this.state.usuarios sino con this.props.usuarios, que nos la proporciona Redux.
Con estos cambios hemos logrado la configuración y el funcionamiento básico de nuestra aplicación con React y Redux.
Por supuesto que hay muchos pasos más y muchas formas diferentes de realizar esta misma acción, pero esto es tan solo el principio.
Resumen
Vamos a repasar brevemente lo que hicimos:
- Creamos la app de React con npx create-react-app {nombre-app}
- Creamos nuestras acciones en la carpeta src/actions.
- Creamos nuestros reducers en la carpeta src/reducers y los combinamos en el index.js.
- Creamos el Store en nuestro archivo src/index.js.
- En el mismo archivo configuramos el <Provider> y le asignamos el store a utilizar.
- En el mismo archivo configuramos el middleware, Redux Thunk.
- Por último, conectamos nuestro componente Usuarios, en components/usuarios/index.js con el reducer que queríamos usar.
Código fuente completo: https://github.com/changomarcelo/redux-blog