Herramientas de usuario

Herramientas del sitio


informatica:programacion:cursos:desarrollo_web_react_18:contenedor_estado_redux

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ón
  • usuarioReducer, 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:

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 (
    <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 onClick={guardarBio}>Guardar biografía</button>
      <button onClick={onCierreSesion}>Cerrar sesión</button></p>
    </div>
  )
}

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.

Integración de Redux en un proyecto existente

Migrar del uso de hooks como useState o useContext a Redux puede ser necesario cuando el estado de la aplicación se vuelve complejo y es de carácter global, en el sentido de que es necesario consultarlo o modificarlo desde varios componentes en diferentes lugares de la aplicación. En esta lección comenzaremos la transformación de la aplicación gestora de tareas con el objetivo de que todos los datos acerca de las tareas estén almacenados mediante Redux. Conseguiremos esto siguiendo las prácticas recomendadas actualmente para Redux, como el uso de Redux Toolkit para agrupar y automatizar algunos elementos.

Dependencias

El primer paso será añadir react-redux y @reduxjs/toolkit a las dependencias del proyecto, simplemente recurriendo al gestor de paquetes npm:

npm install react-redux @reduxjs/toolkit

Creación del almacén

El almacén es una parte de la aplicación que requiere poco código pero que seguramente vamos a necesitar importar en diferentes lugares. Por tanto, crearemos un nuevo directorio app y, dentro de este, un fichero store.js con el siguiente contenido:

import { configureStore } from "@reduxjs/toolkit";
 
export const store = configureStore({
    reducer: (state, action) => state
})

Como puedes ver, en lugar de crear el almacén mediante la función createStore() de Redux, recurrimos al método específico configureStore() del Toolkit. El único parámetro obligatorio en ambas funciones es el reducer, pero configureStore() también activa por defecto las herramientas de desarrollo y añade algunos middlewares útiles. Por ahora proporcionamos un reducer por defecto al almacén (la función flecha: (state, action) ⇒ state), que simplemente ignora la acción y no modifica el estado.

Utilización del Provider

En el fichero main.jsx importaremos el componente Provider de React-Redux y el almacén que acabamos de definir. Para utilizarlos, como sabemos, únicamente hay que instanciar el componente Provider en la interfaz y pasar nuestro store como valor de la propiedad del mismo nombre:

// ...
import { Provider } from 'react-redux'
import { store } from './app/store'
 
ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>
)

Ahora nuestra aplicación está lista para desarrollar tanto las acciones como los reducers que nos van a permitir gestionar y actualizar todo el estado.

Creación de slices

Las slices o “rebanadas” no son un concepto propio de Redux, sino que son un atajo que nos proporciona Redux Toolkit para organizar mejor el código que se encargue de cada funcionalidad de la aplicación. También automatiza algunas buenas prácticas que se recomiendan a la hora de trabajar con Redux.

Como vimos al principio del módulo, las slices son secciones que implementan el comportamiento y la gestión del estado de una sola parte de la aplicación, cada una aislada del resto.

Fundamentos de una slice

En una aplicación React/Redux, cada slice contiene:

  • Un nombre que la identifica respecto al resto. El nombre debe ser indicativo de la característica o aspecto de la aplicación que se está definiendo.
  • Un estado inicial propio, que define la estructura de la sección del estado de la que se encarga la slice.
  • Una serie de funciones reducer, cada una para un tipo de acción particular. Estos reducers solo tienen acceso al estado de la slice y no al global.

Gracias a la automatización que proporciona Redux Toolkit, nos evita tener que definir a mano funciones creadoras de acciones y un reducer global, lo cual facilita la escritura de código que se ajuste a las buenas prácticas y previene posibles errores.

La slice "tareas"

A lo largo de esta lección definiremos la slice que se encargará de mantener y actualizar las listas de tareas. Para ello, vamos a crear un nuevo fichero features/tareas/tareasSlice.js e importaremos las siguientes funciones:

import { createSlice, nanoid } from "@reduxjs/toolkit"

A continuación, vamos a definir un estado inicial muy similar al que teníamos cuando usábamos useState. La principal diferencia es que vamos a incluir un identificador para cada tarea, facilitando así el uso de la propiedad key de React para identificar los elementos:

const initialState = {
  lista: {
    1: {
      titulo: "Aprender componentes de React",
      completada: false
    },
    2: {
      titulo: "Completar las prácticas del módulo 1",
      completada: true
    },
    3: {
      titulo: "Realizar la autoevaluación",
      completada: false
    }
  }
}

Por último, creamos la slice llamando a la función createSlice(), que acepta un objeto de opciones. Las opciones que admite son:

  • name: una cadena de caracteres indicando el nombre de la slice. Se utilizará como prefijo para las acciones y la sección de estado de la que se encarga.
  • initialState: un valor para el estado inicial o una función que lo genere la primera vez que se llame, en caso de que queramos evaluarlo de forma “perezosa”.
  • reducers: un objeto de funciones reducer con nombres. Cada nombre se corresponderá con la acción que ese reducer resuelve. Alternativamente, para una acción dada se puede proporcionar un objeto con una función reducer que la procese y una función prepare que reciba los parámetros de la acción y genere el objeto de acción completo.
  • extraReducers: una función que toma como parámetro un builder y permite añadir reducers adicionales que se correspondan con acciones de otros tipos, por ejemplo, acciones de carácter global que también tengan efectos en esta sección del estado.

Inicialmente, por tanto, nuestra slice tendrá el siguiente aspecto:

const tareasSlice = createSlice({
  name: "tareas",
  initialState,
  reducers: {}
})

Mientras no indiquemos ningún reducer, no se definirán acciones y el reducer principal de la slice no tendrá ningún efecto sobre el estado.

Definición de reducers

Al crear una slice, en lugar de implementar un único reducer que tome decisiones en función del tipo de acción, hemos de definir varios reducers más atómicos. Cada reducer se corresponderá con un único tipo de acción y modificará el estado acordemente.

Una ventaja adicional de crear estas funciones con Redux Toolkit es que incluye una capa de abstracción gracias a la biblioteca Immer, que nos permite escribir código aparentemente “mutante” sobre el estado, sin que realmente estemos realizando esas modificaciones sobre el propio estado, sino que este continúa inmutable.

Veamos un primer ejemplo: el reducer que se encargará de borrar una tarea. Supondremos que el único parámetro de la acción correspondiente será el identificador de la tarea a borrar, por lo que será el único contenido de la propiedad payload:

reducers: {
  eliminada(state, action) {
    delete state.lista[action.payload]
  },
}

En este contexto utilizamos un azúcar sintáctico de JavaScript moderno que nos permite omitir los dos puntos y la palabra reservada function al definir funciones en objetos. Esto sería equivalente a escribir: eliminada: function(state, action) {/*…*/}. Es como lo verás hecho más a menudo en React, pero si te resulta más cómodo o claro puedes utilizar la sintaxis completa.

Como ves, hemos utilizado el operador delete para (aparentemente) borrar la tarea dada por el identificador in situ, cuando la función pura equivalente habría sido la siguiente:

eliminada(state, action) {
  const {[action.payload]: _, ...lista} = state.lista
  return { ...state, lista }
}

Fíjate en la definición del estado (objeto state) que hemos hecho, cada tarea es una propiedad del objeto lista, nombrada con su identificador: 1, 2, 3… Es decir, cada tarea es una propiedad del objeto lista. Por tanto, para borrar una tarea, lo que hacemos es eliminar la propiedad correspondiente del objeto lista con delete.

Una vez que definimos este reducer, automáticamente se crea una acción de tipo “tareas/eliminada” y una función creadora de acciones con el nombre eliminada en tareaSlice.actions. Si la exportamos, podríamos aprovecharla desde un componente React para despachar acciones de ese tipo simplemente llamando a dispatch(eliminada(id)) donde id sería un identificador.

Los siguientes reducers que vamos a añadir, siguiendo el mismo esquema, son para permitir alternar el estado de completitud de una tarea y para modificar su título. Se corresponderán con las acciones “tareas/alternada” y “tareas/modificada”, respectivamente:

alternada(state, action) {
  state.lista[action.payload].completada = !state.lista[action.payload].completada
},
modificada(state, action) {
  state.lista[action.payload.id].titulo = action.payload.titulo
},

En el primer caso, el contenido o payload de la acción únicamente debe indicar el identificador, mientras que en el segundo, debe tratarse de un objeto donde la propiedad id contenga el identificador y titulo el nuevo título de la tarea.

Además, también podemos incluir reducers que no requieran una acción, en el caso de que al lanzar una acción del tipo correspondiente siempre se obtenga el mismo resultado. En nuestro ejemplo, vamos a permitir marcar todas las tareas como completadas, de esta manera:

todasCompletadas(state) {
  for (let id in state.lista) {
    state.lista[id].completada = true
  }
},

Al crear una función que acepte únicamente el parámetro state, la herramienta createSlice asociará adecuadamente un creador de acciones sin parámetros, es decir, nos dará una función todasCompletadas() que simplemente devolverá un objeto indicando el tipo de la acción.

Si no tienes claro qué hace un bucle for-in, lee esto.

Un reducer con función de preparación

En ocasiones, los creadores de acciones que define automáticamente Redux se pueden quedar cortos. Por ejemplo cuando se hace necesario añadir información a los datos de una acción, o modificarlos antes de pasarla a un reducer para que la procese. Aquí es donde entran en acción las funciones de preparación, que pueden aceptar varios parámetros y deben devolver objetos con el contenido en la propiedad payload, metadatos opcionales en la propiedad meta y un indicador opcional de error, en caso de que ocurra, en la propiedad error.

En nuestra aplicación de ejemplo, cuando creamos una nueva tarea nos basta con darle un título, pero el reducer necesita adicionalmente un identificador para almacenar correctamente la tarea. La función de preparación se encargará de añadirlo al contenido de la acción, mientras que el reducer únicamente actualizará el estado agregando la tarea al objeto de tareas:

creada: {
  prepare(titulo) {
    return { payload: { id: nanoid(), titulo } }
  },
  reducer(state, action) {
    state.lista[action.payload.id] = {
      titulo: action.payload.titulo,
      completada: false
    }
  }
}

Fíjate que, para poder indicar la función de preparación, en este caso creada no es una función sino un objeto con dos campos que son, a su vez, funciones: la función prepare que toma un título de tarea y devuelve el contenido de la acción incluyendo el identificador aleatorio generado mediante nanoid(), y el reducer que funciona como los anteriores, asumiendo que ese id ya está presente.

Ahora, cuando llamemos al creador de acciones creada que se habrá generado en tareasSlice.actions, podremos pasar únicamente un título y obtendremos como resultado una acción de tipo “tareas/creada” que incluye el id aleatorio.

Exportación de funciones

Finalmente, solo queda exportar las funciones creadoras de acciones y el reducer principal de la slice para poder utilizarlas en otros ficheros:

export const {
  creada,
  eliminada,
  alternada,
  modificada,
  todasCompletadas
} = tareasSlice.actions
 
export default tareasSlice.reducer

Esto nos permite rescribir el fichero store.js, añadiendo el import correspondiente y especificando el reducer que hemos obtenido de la slice como uno de los disponibles en el objeto que proporcionamos a la opción reducer:

import tareas from "../features/tareas/tareasSlice"
 
export const store = configureStore({
  reducer: { tareas }
})

Recuerda que aquí la sintaxis {tareas} es equivalente a {tareas: tareas}.

Uso de selectores para consultar el estado

Una ventaja clara de migrar una aplicación a Redux es que el estado se puede consultar globalmente, siempre que los componentes sean descendientes del nodo Provider. Esto evita un fenómeno conocido como prop drilling o “taladrado de propiedades”, que consiste en ir pasando la misma información sucesivamente de nodos padre a nodos hijo cuando el nodo que debe mostrarla está abajo en la jerarquía pero, por cualquier motivo, el estado lo debe gestionar un componente muy superior.

En esta lección aprenderás a consultar la información que necesites del almacén de Redux para mostrarla en los componentes.

Acceso a la lista de tareas

Volvamos al archivo features/tareas/ListaTareas.jsx donde se encuentran los componentes que muestran la lista de tareas. Hasta ahora, el componente ListaTareas recibía los datos acerca de las tareas desde el componente App a través de una propiedad. Ahora que disponemos de esta información almacenada en Redux, podemos consultarla directamente del almacén utilizando una función de selección.

La función de selección o “selector” que escribamos debe tomar como parámetro el estado completo y devolver únicamente el dato que estemos buscando. En este caso, puesto que estamos trabajando con slices y cada slice tiene un campo correspondiente en el almacén (lo indicamos al pasar el objeto de reducers en la función configureStore()), este tendrá la siguiente estructura:

{
  "tareas": {
    "lista": {
      id: { "titulo": "", "completada": false },
      // ...
    }
  }
}

Si queremos que nuestro selector devuelva el objeto correspondiente a la lista de tareas, deberá tener este aspecto state ⇒ state.tareas.lista. Basta con aplicarlo en el hook useSelector para disponer del objeto:

const ListaTareas = () => {
  const tema = useContext(ContextoTema)
  const tareas = useSelector(state => state.tareas.lista)
 
  if (tareas.length == 0) {
    return null;
  }
 
  return (
    <>
      <Boton>Marcar como completadas</Boton>
      <ul style={{background: tema.fondo, color: tema.texto}}>
        {Object.entries(tareas).map(([id, tarea]) => <Tarea {...tarea} key={id} id={id} />)}
      </ul>
      <FormularioNueva />
    </>
  )
}

Previamente la lista de tareas era un array y utilizábamos el índice como valor para la propiedad key, pero ahora disponemos de un identificador en la clave de cada tarea puesto que la lista es un objeto, así que podemos utilizarlo para este propósito y continuará siendo útil más adelante. Siempre es conveniente que los datos tengan un identificador único y permanente.

De la misma forma, podremos actualizar el componente Cabecera que muestra el conteo de tareas para obtener los datos desde el almacén. En este caso, no nos interesa el objeto completo con los identificadores asociados, así que extraeremos únicamente la lista de valores mediante Object.values(). El resto del componente puede quedar de la misma forma:

const Cabecera = () => {
  const tareas = useSelector(state => Object.values(state.tareas.lista))
  if (tareas.length == 0) {
    return <p>¡Enhorabuena! No quedan tareas.</p>
  }
 
  const pendientes = tareas.reduce((cuenta, tarea) => cuenta + tarea.completada, 0)
 
  return <p>
    {tareas.length} tarea{(tareas.length > 1) && "s"},
    {pendientes} pendiente{(pendientes > 1) && "s"}
  </p>
}

Ahora mismo podrías estar preguntándote si no hay otra forma más directa de consultar el estado, o si no sería más cómodo obtener el objeto completo con un selector state ⇒ state. Esto no es muy conveniente, ya que el selector registra el dato que el componente necesita y únicamente se lanza una renderización del componente si dicho dato cambia. Es decir, si seleccionamos todo el estado, ¡cualquier cambio en cualquier lugar del almacén provocará que React vuelva a renderizar el componente! Por esto es esencial que seleccionemos únicamente lo imprescindible para mostrar en la interfaz.

Simplificando el componente App

Una vez que los componentes pueden seleccionar y consultar únicamente la parte del estado que les hace falta, ya no es necesario que el componente App se encargue de mantener y actualizar el estado global, con lo que lo limitaremos a establecer el tema de color y estructurar la interfaz de la aplicación:

const App = () => {
  const [tema, setTema] = useState("claro")
 
  return (
    <ContextoTema.Provider value={temas[tema]}>
      <div className="App">
        <h1>Kanpus</h1>
        <details><summary>Crear cuenta</summary><FormularioSignup /></details>
        <Cabecera />
        <ListaTareas />
        <p>{tema == "claro"
        ? <Boton onClick={() => setTema("oscuro")}>Activar tema oscuro</Boton>
        : <Boton onClick={() => setTema("claro")}>Activar tema claro</Boton>
        }</p>
      </div>
    </ContextoTema.Provider>
  )
}

Si todo ha ido bien, la aplicación será capaz de mostrar la información que hay en el estado inicial de la slice “tareas”. No se trata aún de una aplicación interactiva, pero, como veremos pronto, eso lo conseguiremos utilizando dispatch para enviar acciones al almacén, que las procesará por medio de los reducers.

En esta lección hemos escrito un par de selectores para consultar el estado acerca de la lista de tareas. Por lo general, si hay valores que se consultan en muchos puntos de la aplicación, es habitual definir la función selectora en el propio archivo de slice para así disponer de ella y no tener que escribirla cada vez. Por ejemplo, podríamos haber incluido la siguiente línea en tareasSlice.js:

export const selectTareas = state => state.tareas.lista

Uso de dispatch para lanzar acciones

Una vez que nuestra aplicación ya es capaz de mostrar la información actualizada del estado, es el momento de añadir la interactividad con el almacén, es decir, enlazar los campos y botones con el despacho de las acciones que hemos definido.

Importar dependencias

Antes de nada, asegúrate de que has importado las funciones creadoras de acciones, que nos ahorrarán escribir a mano los objetos del estilo de {type: "tareas/eliminada", payload: 1}. También necesitaremos el hook useDispatch que nos proporcionará la función dispatch:

// archivo ListaTareas.jsx
import { eliminada, alternada, creada, modificada, todasCompletadas } from "./tareasSlice"
import { useDispatch } from "react-redux"

Ahora ya está todo preparado para lanzar acciones al interactuar con la interfaz.

Componente Tarea

Las funciones que despachan acciones son muy sencillas de escribir, ya que hemos programado adecuadamente los reducers y las funciones preparadoras. Únicamente necesitamos capturar la función dispatch desde su hook y ya podremos usarla para, en el caso de un evento, lanzar acciones como eliminada(id) o alternada(id). Esto nos permite escribir algunas funciones de una sola línea, o incluso podríamos escribirlas en el propio evento onClick u onChange:

const Tarea = ({ id, titulo: initialTitulo, completada }) => {
  const dispatch = useDispatch()
  const [titulo, setTitulo] = useState(initialTitulo)
  const eliminarTarea = () => dispatch(eliminada(id))
  const alternarTarea = () => dispatch(alternada(id))
  const editarTarea = (event) => {
    setTitulo(event.target.value)
    dispatch(modificada({ id, titulo }))
  }
 
  return (
    <li className={completada ? "done" : "todo"}>
      <label>
        <input type="checkbox" checked={completada} onChange={alternarTarea} />
        {completada ? "DONE" : "TODO"}
      </label>
      <input type="text" value={titulo} onChange={editarTarea} disabled={completada} />
      <Boton onClick={eliminarTarea}>Eliminar</Boton>
    </li>
  )
}

Vamos a analizar paso a paso cómo se despacha una acción. Primero, capturamos la función dispatch mediante el hook asociado:

const dispatch = useDispatch()

Opcionalmente, definimos la función que se encargará de configurar la acción llamando a la creadora de acciones que hemos importado y enviarla:

const eliminarTarea = () => dispatch(eliminada(id))

Asociamos está función al evento que convenga:

<Boton onClick={eliminarTarea}>Eliminar</Boton>

De esta forma, cuando se pulse sobre el botón “Eliminar”, se ejecutará una llamada a dispatch() sobre el resultado de configurar la acción, que podremos consultar en las herramientas de desarrollo de Redux. Esto invocará al reducer raíz del almacén (en nuestro caso generado automáticamente gracias a configureStore), el cual decidirá cuál de los reducers individuales debe ejecutarse en función del tipo de la acción. Como este será tareas/eliminada, entrará en los reducers de la slice tareas y ejecutará el denominado eliminada, pasándole el estado actual y los datos de la acción, en este caso, el identificador de la tarea a eliminar. El reducer devolverá un nuevo estado y el almacén procederá a actualizarlo internamente, lo que causará una actualización de la interfaz.

Componente FormularioNueva

De forma análoga, para crear una nueva tarea simplemente despacharemos la acción creada por la función creada(), que en este caso recibe como parámetro el título y prepara un objeto de acción completo incluyendo un nuevo identificador para la tarea:

const FormularioNueva = () => {
  const [nuevaTitulo, setNuevaTitulo] = useState("")
  const dispatch = useDispatch()
 
  const manejarSubmit = (event) => {
    event.preventDefault()
    dispatch(creada(nuevaTitulo))
    setNuevaTitulo("")
  }
 
  return (
    <form onSubmit={manejarSubmit}>
      <input type="text" name="titulo" placeholder="Nueva tarea"
        onChange={event => setNuevaTitulo(event.target.value)}
        value={nuevaTitulo} />
    </form>
  )
}

Componente ListaTareas

Por último, de la misma forma podemos hacer interactivo el botón “Marcar como completadas”, despachando la acción todasCompletadas() que no requiere ningún parámetro:

const ListaTareas = () => {
  const tema = useContext(ContextoTema)
  const dispatch = useDispatch()
  const tareas = useSelector(state => state.tareas.lista)
 
  if (tareas.length == 0) {
    return null;
  }
 
  return (
    <>
      <Boton onClick={()=>dispatch(todasCompletadas())}>
        Marcar como completadas
      </Boton>
      <ul style={{background: tema.fondo, color: tema.texto}}>
        {Object.entries(tareas).map(([id, tarea]) => <Tarea {...tarea} key={id} id={id} />)}
      </ul>
      <FormularioNueva />
    </>
  )
}

Cuando hayamos completado el archivo ListaTareas.jsx, nuestra aplicación será completamente funcional, pudiendo añadir, modificar y eliminar tareas a nuestro gusto.

Vamos a verlo en la práctica a continuación…

Interacciones entre slices

En esta lección vamos a añadir complejidad a la aplicación de gestión de tareas, convirtiéndola en un tablero de tipo kanban. Para ello, crearemos una nueva slice que se encargará de gestionar el tablero en sí, es decir, las diferentes listas de tareas y qué tareas tiene cada una.

La slice Tablero

Si la slice Tareas ha sido diseñada para gestionar todo lo que tenga que ver con las tareas individuales, la slice Tablero manipulará la información sobre las diferentes listas de tareas.

Al igual que antes, definiremos un estado inicial y una serie de reducers cuyos nombres darán lugar a las acciones que podremos realizar sobre esta sección del estado.

En un archivo src/features/tablero/tableroSlice.js, escribimos este código que nos servirá de estado inicial:

const initialState = {
  todo: {
    nombre: "Pendiente",
    lista: [2,3]
  },
  doing: {
    nombre: "En proceso",
    lista: [1]
  },
  done: {
    nombre: "Completado",
    lista: []
  }
}

Como ves, cada lista de tareas se compondrá simplemente de un nombre y una lista de identificadores que se corresponden con tareas. Como las tareas iniciales tienen identificadores 1, 2 y 3, las hemos repartido en las listas iniciales.

Acciones y reducers estándares

Vamos ahora con las acciones que se pueden realizar sobre este estado y los reducers correspondientes. Inicialmente, vamos a definir los siguientes:

  • listaCreada: permite crear una nueva lista en el tablero. Tendrá asociada una función de preparación que reciba el nombre y le añada un id en el contenido de la acción. El reducer creará un nuevo objeto en el estado del tablero con el nombre dado y una lista de tareas vacía.
  • tareaQuitada: recibirá una acción cuyo contenido debe tener las claves tarea_id y from_id. Se encarga de quitar una tarea de la lista dada por from_id.
  • tareaAgregada: recibirá una acción cuyo contenido debe tener las claves tarea_id, to_id y, opcionalmente, orden. Se encarga de añadir una tarea a la lista que corresponda con to_id. Si se especifica el orden, entonces se colocará en la posición indicada, de lo contrario se añadirá al final.
const tableroSlice = createSlice({
  name: "tablero",
  initialState,
  reducers: {
    listaCreada: {
      reducer(state, action) {
        state[action.payload.id] = {
          nombre: action.payload.nombre,
          lista: []
        }
      },
      prepare(nombre) {
        return { payload: { id: nanoid(), nombre } }
      }
    },
    tareaQuitada(state, action) {
      state[action.payload.from_id].lista.splice(
        state[action.payload.from_id].lista.indexOf(action.payload.tarea_id),
        1
      )
    },
    tareaAgregada(state, action) {
      const orden = action.payload.orden ?? state[action.payload.to_id].lista.length
      state[action.payload.to_id].lista.splice(orden, 0, action.payload.tarea_id)
    }
  }
})
 
export const { listaCreada, tareaQuitada, tareaAgregada } = tableroSlice.actions
export default tableroSlice.reducer

Lógicamente, el reducer principal de esta nueva slice, lo debemos importar en el archivo app/store.js para añadirlo al objeto de reducers del almacén:

import tablero from '../features/tablero/tableroSlice'
export const store = configureStore({
    reducer: { tareas, tablero }
})

Reducers adicionales para responder a otras acciones

Cuando definimos una slice, principalmente asociamos acciones y reducers a una sección concreta del estado, por ejemplo, state.tablero en este caso. Sin embargo, no estamos limitados a responder solamente a las acciones que hayamos definido en la slice, sino que podemos escribir reducers para otros tipos de acciones, incluso si ya tienen otros reducers asociados. Esto es un mecanismo recomendado para los casos en los que una acción debe tener efectos en más de un lugar del estado.

Un ejemplo de acción externa que tiene consecuencias en la slice tablero es la creación de una tarea: además de añadir la nueva tarea a la lista en la slice Tareas, debemos agregarla a alguna de las listas disponibles en el tablero. Para ello, la función createSlice() tiene una opción adicional que se llama extraReducers. Ahí, en lugar de especificar un objeto de reducers como en la opción reducer, tendremos que escribir una función que tome como parámetro builder, que es una herramienta interna para facilitar la creación de reducers por casos.

Para añadir un caso al builder, utilizaremos su método addCase proporcionándole la acción a la que queremos responder (en este caso, importamos creada desde la slice de tareas) y el reducer en sí, que funciona como cualquier otro, alterando el estado en respuesta al contenido de la acción.

En nuestro ejemplo, para añadir una nueva tarea a una lista concreta, supondremos que disponemos del identificador de la lista en action.payload.listaId y simplemente añadiremos el identificador de la tarea a esa lista. Más adelante nos encargaremos de proporcionar el identificador de la lista desde el formulario de creación de nuevas tareas:

import { creada } from "../tareas/tareasSlice"
const tableroSlice = createSlice({
  // ...
  extraReducers: builder => {
    builder.addCase(creada, (state, action) => {
      state[action.payload.listaId].lista.push(action.payload.id)
    })
  }
})

Análogamente, necesitaremos un reducer para actualizar el tablero cuando una tarea sea eliminada, ya que no deben quedar identificadores que se refieran a tareas que no existen. Este reducer es más sencillo en concepto, ya que lo único que debe hacer es encontrar el tablero donde esté listada la tarea a eliminar, y borrar en el array su identificador:

builder.addCase(eliminada, (state, action) => {
  for (let t in state) {
    const index = state[t].lista.indexOf(action.payload)
    if (index > -1) {
      state[t].lista.splice(index, 1)
    }
  }
})

Componente Tablero

Para construir el componente correspondiente al tablero, nos basta con aprovechar los que ya tenemos, con pocas modificaciones. En particular, vamos a utilizar una instancia de ListaTareas para cada lista de las que esté compuesto el tablero. Le proporcionamos una propiedad para el identificador de cada lista, con lo que tendrá suficiente información para mostrar las tareas correspondientes. También incluimos un formulario que añade una nueva lista al tablero:

const Tablero = () => {
  const listas = useSelector(state => Object.keys(state.tablero))
  const [nuevaLista, setNuevaLista] = useState("")
  const dispatch = useDispatch()
  const crearLista = (event) => {
    event.preventDefault()
    dispatch(listaCreada(nuevaLista))
    setNuevaLista("")
  }
  return (
    <div className="tablero">
      {listas.map(id => <ListaTareas key={id} id={id} />)}
      <div className="lista">
        <form onSubmit={crearLista}>
          <input type="text" placeholder="Nueva lista" value={nuevaLista} onChange={e => setNuevaLista(e.target.value)} />
          <p><Boton type="submit">Crear lista</Boton></p>
        </form>
      </div>
    </div>
  )
}

Por su parte, el componente ListaTareas sufre pocos cambios. Se integra el nombre de la lista en el marcado y se engloba todo en un contenedor con fondo. Cada tarea recibe su identificador mediante una propiedad, y el formulario para crear una nueva tarea recibe el identificador de la lista:

const ListaTareas = ({ id: listaId }) => {
  const { nombre, lista: tareas } = useSelector(state => state.tablero[listaId])
  const tema = useContext(ContextoTema)
 
  return (
    <div className="lista" style={{background: tema.fondo, color: tema.texto}}>
      <h2>{nombre}</h2>
      {tareas.length > 0 && (
        <ul>
          {tareas.map(id => <Tarea key={id} id={id} />)}
        </ul>
      )}
      <FormularioNueva listaId={listaId} />
 
    </div>
  )
}

Observa que hemos eliminado las opciones de marcar las tareas como completadas, una operación que ya no tiene sentido alguno si disponemos de diferentes listas donde organizarlas. De la misma forma, todo lo referente a completar tareas se ha eliminado de la aplicación.

Si actualizamos el componente Tarea para que extraiga sus datos del almacén utilizando el selector state => state.tareas.lista[id] y realizamos los ajustes de estilo necesarios para que se muestren las diferentes listas en paralelo, tendremos una aplicación similar a la siguiente:

¡La aplicación ya empieza a tomar forma!

Thunks para manejar situaciones complejas

Supongamos ahora que, en nuestra aplicación Kanban de ejemplo, queremos hacer uso de la acción “tablero/tareaMovida” para mover una tarea de un tablero a otro. Esta requiere parámetros (from_id, to_id con los ids de los tableros de origen y destino) que a priori no tendríamos por qué conocer en la instancia del componente Tarea desde donde se despachará.

Lo ideal sería que pudiéramos facilitar las acciones más comunes, que serán desplazar una tarea a la izquierda y a la derecha, mediante algún tipo de intermediario que averigüe esos parámetros sin añadir complejidad al componente.

Esta es justamente la situación en la que entra en juego un patrón conocido como thunk. Los thunks, en concepto, son mecanismos para retrasar una evaluación posiblemente costosa hasta que se necesite su resultado o bien para ejecutarlos justamente antes o después de otra operación.

El nombre “thunk” (se pronuncia parecido a “zank”) es una forma irregular inventada del verbo think. Podríamos hablar de que se trata de operaciones “pensadas” pero no calculadas aún. Hay quien dice que es el ruido que hace tu cabeza cuando tratas de entender los thunks de Redux la primera vez, pero vamos a evitar que eso pase.

El concepto de thunk

En esencia, un thunk no es más que una función que viene devuelta por parte de otra. Por ejemplo, en su expresión más sencilla:

function exterior(nombre) {
    return function thunk() {
        console.log(`¡Hola ${nombre}, soy un thunk!`);
    };
}

Observa que, si llamamos a exterior pasando algún nombre como parámetro, en principio no ocurrirá nada, sino que obtendremos como resultado una nueva función. Para llamar al thunk de una sola vez tendrías que llamar a la función exterior y luego al thunk, o sea, una llamada doble:

exterior("Jose")();
// => ¡Hola Jose, soy un thunk!

En este ejemplo tan simple el thunk no recibe parámetros, pero podría recibirlos. De hecho en Redux, como veremos, necesita recibirlos.

Es un concepto muy sencillo, pero a la vez muy potente ya que nos permite espaciar temporalmente el paso de parámetros a una función y el cálculo de su resultado. Por ejemplo, podríamos utilizarlo para retrasar la obtención de un dato hasta el momento el que se necesite, o para ejecutar una acción justo después de otra.

El concepto de thunk en Redux

Como ya bien sabes a estas alturas, en Redux, a pesar de su nombre, las acciones realmente no hacen nada con los datos: son objetos señalizadores que indican el tipo de acción (en su propiedad type) y opcionalmente un payload que proporciona información sobre lo que se quiere hacer. Los que realmente llevan a cabo las acciones sobre los datos almacenados en Redux son los reducers.

El problema es que los reducers no pueden hacer nada más que devolver un nuevo estado y deberían ser funciones puras (sin efectos secundarios). No deberían, por tanto, realizar operaciones asíncronas (por ejemplo, llamar a una API) ni despachar otras acciones.

Redux dispone de un middleware llamado redux-thunk que permite trabajar con thunks en las acciones. Lo único que hace es examinar cada acción que se despacha y, si es un thunk, lo ejecuta, Si no lo es, se limita a pasar la acción al siguiente middleware para seguir procesándola. De hecho, es tan sencillo que este es su código fuente:

action => {
  // The thunk middleware looks for any functions that were passed to `store.dispatch`.
  // If this "action" is really a function, call it and return the result.
  if (typeof action === 'function') {
    // Inject the store's `dispatch` and `getState` methods, as well as any "extra arg"
    return action(dispatch, getState, extraArgument)
  }
 
  // Otherwise, pass the action down the middleware chain as usual
  return next(action)
}

3 líneas que lo que hacen es comprobar si la acción es una función, y si es así, la ejecuta, o que, si no lo es, la pasa al siguiente middleware.

Gracias a esto podemos definir acciones que en lugar de señalizar lo que van a hacer, de hecho, lo hagan a través de un thunk y que, como no manipulan el estado directamente, pueden realizar operaciones asíncronas y despachar otras acciones.

Configuración del middleware para thunks

Redux Toolkit incorpora automáticamente el middleware para trabajar con thunks. Así que, si sigues las recomendaciones de este curso, no tienes que hacer nada, ya que al crear el almacén mediante configureStore() de Redux Toolkit ya tenemos el middleware de thunks incorporado automáticamente.

Si, por el motivo que sea, no estuvieses utilizando Redux ToolKit sino solamente Redux, tendrías que instalar redux-thunk:

npm install --save redux-thunk

y aplicarlo manualmente con una llamada a applyMiddleware() similar a la siguiente:

import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
const store = createStore(rootReducer, applyMiddleware(thunk))

Definición de thunks en Redux

En el contexto de Redux, un thunk consistirá en una función creadora de acciones que, en lugar de devolver, como hasta ahora, un objeto con type y payload, devuelve una función (el thunk) que toma dos parámetros dispatch (para despachar acciones) y getState (para obtener el estado actual), y opcionalmente un argumento extra pero que no se suele utilizar.

En Redux, utilizar thunks es muy común para casos donde necesitamos que ocurran efectos secundarios a raíz de una acción, o bien que las consecuencias de la acción dependan del estado actual, o que se despachen varias acciones a la vez. Además, cuando las acciones tienen efectos asíncronos (como peticiones al servidor o el uso de promesas) estas se gestionan normalmente en un thunk.

Por ejemplo, si en nuestro tablero Kanban queremos implementar un botón para avanzar tareas a la derecha, será necesario consultar el estado para averiguar la lista de origen y la de destino, así como lanzar acciones para quitar la tarea de la primera y agregarla a la segunda. Por tanto, no nos basta con un reducer sino que necesitamos un thunk, y podríamos escribirlo así:

// thunk que se encarga de mover una tarea a la lista de la derecha
export const tareaMovidaDerecha = tarea_id => (dispatch, getState) => {
  // cálculos para mover la tarea
}

Al igual que una función creadora de acciones, se puede invocar con la función dispatch() en caso de un evento, como una pulsación en un botón:

<Boton onClick={() => dispatch(tareaMovidaDerecha(id))}>&gt;</Boton>

El &gt; simplemente muestra un símbolo de mayor para indicar la acción de mover una tarea a la lista de la derecha.

El cuerpo de nuestro thunk consistirá en la obtención del tablero actual mediante getState(), para encontrar cuál es la lista a la derecha de aquella a la que pertenece la tarea en cuestión, y a continuación se lanzan las acciones tareaQuitada y tareaAgregada con el identificador de la tarea y los identificadores de las listas donde queremos quitarla y añadirla, respectivamente:

export const tareaMovidaDerecha = (tarea_id) => (dispatch, getState) => {
  // Consultar el tablero actual
  const tablero = getState().tablero
  // Encontrar la lista a la que pertenece la tarea
  const from_index = Object.values(tablero).findIndex(v => v.lista.includes(tarea_id))
  // Calcular la siguiente lista
  const to_index = from_index + 1
  // Solo movemos si existe una lista más a la derecha
  if (to_index < Object.keys(tablero).length) {
    const [from_id, to_id] = Object.keys(tablero).slice(from_index, to_index + 1)
    dispatch(tareaQuitada({ tarea_id, from_id }))
    dispatch(tareaAgregada({ tarea_id, to_id }))
  }
}

Siguiendo el mismo patrón, podemos escribir el thunk que se encargue de mover tareas a la lista inmediatamente anterior:

export const tareaMovidaIzquierda = tarea_id => (dispatch, getState) => {
  const tablero = getState().tablero
  const from_index = Object.values(tablero).findIndex(v => v.lista.includes(tarea_id))
  const to_index = from_index - 1
  if (to_index >= 0) {
    const [to_id, from_id] = Object.keys(tablero).slice(to_index, from_index + 1)
    dispatch(tareaQuitada({ tarea_id, from_id }))
    dispatch(tareaAgregada({ tarea_id, to_id }))
  }
}

Ya solamente nos queda comprobar que las tareas se pueden mover a izquierda y derecha utilizando los botones que hemos creado para estos propósitos, y que no pueden moverse más allá de las listas de tareas que existen:

Aplicaciones prácticas más comunes de los thunks

En general usaremos thunks en Redux para:

  • Obtener o enviar datos a APIs
  • Acciones que despachan otras acciones. Por ejemplo, para realizar operaciones que requieren de varias acciones para completarse.
  • Acciones que dependen del estado actual. Por ejemplo, para realizar operaciones (síncronas o asíncronas) que dependen de la existencia de ciertos datos en el estado, o para realizar operaciones que dependen de la existencia de ciertas propiedades en el estado.
  • Acciones asíncronas. Por ejemplo, para realizar operaciones que requieren de un tiempo de espera, como animaciones.
  • Acciones que se ejecutan con retraso. Por ejemplo, lanzándolas con un setTimeout() de JavaScript porque requieren un tiempo de espera antes de ejecutarse.

En general, aunque existen otras formas de conseguir lo mismo, gracias al uso de thunks y el middleware correspondiente, podemos simplificar el proceso y separar la lógica de la interfaz de usuario de la lógica de negocio. Así en lugar de tener que lidiar con componentes que deben conocer cómo funciona la capa de almacenamiento centralizado de datos, tenemos un almacén que se encarga de gestionar los datos y un conjunto de componentes que se encargan de mostrarlos y de reaccionar a los eventos de usuario, desacoplados entre sí.

Prácticas propuestas para el módulo

En este módulo has aprendido a gestionar el estado global de la aplicación con la biblioteca Redux y a transformar una aplicación sin Redux de forma que lo utilice. Para que afiances tu dominio sobre estas técnicas, te proponemos estos ejercicios:

  • Añadir el reducer correspondiente a la acción “tablero/tableroRenombrado
  • Añadir una opción para eliminar una lista de tareas al completo, incluidas todas sus tareas, mediante un thunk.

Recuerda: este módulo es muy complejo por lo que es especialmente importante que realices estas prácticas propuestas, aparte de haber repetido lo explicado en las lecciones. Es la forma de que te surjan dudas y afiances el conocimiento.

Recursos

informatica/programacion/cursos/desarrollo_web_react_18/contenedor_estado_redux.txt · Última modificación: por tempwin