¡Esta es una revisión vieja del documento!
Tabla de Contenidos
Comunicación e interacción con el servidor
Notas del curso Desarrollo de aplicaciones web con React 18
Una interfaz bonita y altamente interactiva no sirve de nada si no puede cargar y guardar datos en un lugar más permanente. En el caso de una aplicación web, ese lugar sería un servidor o, más concretamente, el backend de la aplicación web.
En el caso de una aplicación web totalmente independiente y sin conexión a ningún servidor, podríamos utilizar el localStorage del navegador como almacenamiento persistente. Su uso es muy simple y la metodología para integrar la carga y actualización de los datos en React/Redux sería muy similar a como lo haremos con un servidor. Aunque, claro, solo se mantendrían los datos en ese mismo navegador y sería conveniente facilitar una manera de exportarlos e importarlos con facilidad.
A lo largo de este módulo, aprenderás cómo establecer una comunicación entre la aplicación y un servidor que presente una API REST. Esto lo haremos tanto con React, para aplicaciones simples, como utilizando Redux Toolkit, para aplicaciones más complejas como nuestro gestor de tareas kanban. Además, nos familiarizaremos con otras técnicas que harán la experiencia de usuario de nuestra aplicación más amigable y completa.
Nuevamente, muchos de los conceptos y técnicas que vas a aprender en este módulo pueden resultarte algo complejos. No te preocupes, es normal. Redux es complicado. Procura ir adelante y atrás para repasar conceptos, comparar el código antes y después de los cambios, probar a crearlo por tu cuenta, etc. En definitiva, invertir tiempo en practicar, que es la única forma de asentar conocimientos. Y para cualquier duda que te surja tras haber hecho todo esto, contacta con el tutor en el foro (para que otros puedan sacar partido a las respuestas y participar) o por mensajería interna (si es una duda que no aporta a los demás o si es sobre una práctica).
Comunicación directa con la API - Ejemplo microblog
Puesto que React es únicamente una biblioteca para la creación de interfaces de usuario interactivas, en principio no impone ninguna restricción acerca de la metodología que debemos usar para cargar y enviar datos a fuentes externas. El único conflicto que podemos llegar a tener es el hecho de que las funciones que escribimos al construir los componentes deben ser puras y no pueden tener efectos secundarios. En particular, realizar peticiones a servicios externos es un efecto secundario.
En esta lección aprenderemos a indicar mediante el hook useEffect los efectos secundarios que queremos que ocurran en el contexto de un componente, de forma que React pueda controlar esos efectos y actualizar la interfaz convenientemente.
Con el objetivo de desarrollar un ejemplo autocontenido, vamos a construir un sistema de microblogging para un único usuario, con capacidad para cargar, editar y enviar publicaciones. Para ello, puedes crear un nuevo proyecto React con npm init vite y eliminar todos los archivos del directorio src salvo App.jsx, index.css y main.jsx.
Simulación de la API REST con json-server
Antes de comenzar a implementar esta aplicación, vamos a definir la fuente de datos que va a consumir. La biblioteca json-server nos aporta funcionalidad de sobra para este propósito, ya que es capaz de simular una API REST a partir de un archivo JSON de datos, e incluso permite algunas peticiones relativamente complejas para realizar diferentes búsquedas y consultas.
Para disponer de un servidor con esta biblioteca, puedes utilizar el archivo db.json que encontrarás en los archivos asociados a la lección, y del que se muestra un extracto a continuación:
{ "cuentas": [ { "nombre": "Miguel", "id": 1 } ], "posts": [ { "contenido": "En un lugar de la Mancha, [...] partes de su hacienda.", "autoria": 1, "id": 12, "fecha": "1605-01-01T12:00:00Z" }, { "contenido": "El resto della [...] tomaba la podadera.", "autoria": 1, "id": 13, "fecha": "1605-01-02T12:00:00Z" }, { "contenido": "Frisaba la edad [...] de la caza.", "autoria": 1, "id": 14, "fecha": "1605-01-03T12:00:00Z" } ] }
Es importante que entiendas qué entidades de datos vamos a manejar y cómo se relacionan entre ellas, así que antes de continuar, dedica un tiempo a analizar el archivo db.json y a comprender su estructura.
Para poder ejecutar el servidor, es necesario instalar json-server como dependencia e invocarlo desde la terminal con el comando json-server db.json. Es muy posible que el ejecutable no se encuentre en el PATH, así que lo más cómodo será añadir un script al archivo package.json de nuestra aplicación indicando:
"server": "json-server --watch db.json"
De esta forma, podremos ejecutar npm run server y el gestor de paquetes se encargará de buscar el ejecutable de json-server que necesita para arrancar el servidor. Normalmente, este escuchará por defecto en el puerto 3000, pero se puede modificar si fuera necesario. En cualquier caso, ese puerto habrá que tenerlo en cuenta para que la aplicación React pueda enviar peticiones.
Obtención y manipulación de los datos
Como ya se ha mencionado, React no incorpora herramientas que sirvan expresamente para la interacción con fuentes externas. Por tanto, podemos implementar una interfaz para la API libremente. En este caso, puesto que se trata de un caso sencillo, vamos a recurrir directamente a las utilidades integradas en JavaScript: esencialmente, la función fetch y la gestión de promesas con then.
Para simplificar el código posterior, vamos a crear una función que construya el esqueleto que van a tener todas nuestras peticiones al servidor. Recibirá como parámetros un endpoint (es decir, la ruta a consultar tras la URL base de la API), un método HTTP opcional (por defecto GET) y un cuerpo de la petición opcional. Todas las peticiones se envían y reciben en formato JSON, por lo que hacemos uso de la cabecera HTTP correspondiente y las funcionalidades de JavaScript para convertir objetos a JSON y respuestas JSON a objetos.
const API_BASEURL = "http://localhost:3000" const api = (endpoint, method = "GET", body) => { return fetch(API_BASEURL + endpoint, { method, headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : null }).then(r => r.json()) }
Hay que notar que esta implementación no estaría completa en el caso de una aplicación para producción, ya que estamos haciendo caso omiso de los posibles errores que puedan producirse en las peticiones, y estamos asumiendo que todas se van a completar correctamente. Lo hacemos en pos de simplificar este ejemplo para centrarnos en lo importante para la formación: integrar la carga y envío de datos en React. Lo otro son cuestiones que cualquier desarrollador competente de JavaScript debe saber hacer.
Habrás notado que la función api no solo envía la petición, sino que devuelve el resultado de llamar a la función fetch(). Este es una promesa, lo cual nos permite encadenar acciones que se mantendrán a la espera de que se complete la petición, y se ejecutarán cuando esta termine. Por ejemplo, si obtenemos las publicaciones del blog, será interesante ordenarlas de forma que las más recientes aparezcan primero:
const getBlog = () => api("/posts").then(ps => ps.sort((a, b) => Date.parse(b.fecha) - Date.parse(a.fecha)) )
De forma similar, definimos funciones que ejecuten llamadas concretas a la API: la consulta de una cuenta de autor, así como la edición, creación y borrado de una publicación:
const getAuthor = autorId => api(`/cuentas/${autorId}`) const editPost = (id, contenido) => api(`/posts/${id}`, "PATCH", { contenido }) const saveNewPost = contenido => api("/posts", "POST", { contenido, autoria: 1, fecha: (new Date()).toISOString() }) const deletePost = id => api(`/posts/${id}`, "DELETE")
Todas estas funciones nos permiten aislar los efectos secundarios que trataremos de que ocurran en la página web.
El hook useEffect
Dentro de la función que renderiza un componente, los efectos secundarios que queramos que tengan lugar independientemente de posibles eventos tendrán que hacerse a través de un hook denominado useEffect. Esto quiere decir que React ofrece dos ubicaciones para enviar y recibir datos externos:
- Las funciones manejadoras de eventos
- Las funciones englobadas en
useEffect
Esto es así porque dichas funciones se ejecutan en un contexto diferente y no afectan de forma directa a la renderización del componente, que es lo que debe ser una operación pura.
El hook useEffect no devuelve nada y recibe dos parámetros:
setup: debe ser una función que implemente los efectos que deben ocurrir cada vez que se ejecute el efecto. Opcionalmente, puede devolver una función de limpieza que se ejecutará antes de volver a ejecutar el efecto.dependencies: es un parámetro opcional que indica la lista de dependencias del efecto, es decir, qué datos son necesarios para computar dicho efecto de forma que cuando estos cambien, se vuelva a ejecutar de manera automática.
Un ejemplo simple podría ser una página de búsqueda en la que podemos filtrar las publicaciones por varios criterios, siendo uno de ellos el rango de fecha. El efecto principal sería cargar las publicaciones en ese rango y añadirlas a la interfaz. Cuando el intervalo de fechas elegido cambie, habría que limpiar la lista de publicaciones que se está mostrando y volver a cargarla otra vez con el nuevo rango:
useEffect(() => { getPublicacionesEntre(fechaInicio, fechaFin).then( res => agregarPublicaciones(res) ) return () => { limpiarPublicaciones() } }, [fechaInicio, fechaFin])
Utilizando el parámetro dependencies, los efectos se pueden configurar para que tengan lugar en tres casos posibles:
- Una única vez en la primera renderización del componente: si indicamos un array vacío
[]. - Solamente cuando cambien datos concretos: si especificamos esos datos como un array de elementos, por ejemplo:
[a, b]. Esto también ejecuta el efecto en la primera renderización. - En cada renderización del componente, si no indicamos el parámetro.
Los efectos que no deben ejecutarse en la primera renderización pero sí deben preceder a la siguiente invocación del efecto principal, irán en la función devuelta por la que pasemos como parámetro a useEffect.
El componente principal App
Una vez que conocemos las técnicas que tenemos a nuestra disposición para generar efectos secundarios, vamos a aplicarlos al caso de nuestra aplicación microblog para rellenar la interfaz con las publicaciones que alberga el servidor:
function App() { const [blog, setBlog] = useState([]) const [obsoleto, set, unset] = useFlag() useEffect(() => { getBlog().then(setBlog) return unset }, [obsoleto])
Con esas líneas estamos estableciendo blog como un dato con estado en el componente y la carga del blog al inicio de la aplicación y cada vez que la bandera obsoleto cambie de valor. Aquí, useFlag es un hook personalizado:
const useFlag = (valorDefecto = false) => { const [valor, setValor] = useState(valorDefecto) return [valor, () => setValor(true), () => setValor(false)] }
Como ves, utiliza por debajo useState() y nos permite activar o desactivar un indicador (también llamado “flag” o “bandera” habitualmente) para avisar de algún cambio en el estado.
En la línea 6 del primer fragmento, antes de ejecutar el efecto principal donde cargamos y mostramos el blog, se devuelve el método unset, de modo que se desactiva la bandera para que los siguientes avisos tengan efecto.
Recuerda que la función de setup que se le pasa a useEffect() puede devolver una función de limpieza, que se ejecutará antes de volver a ejecutar el efecto. Es justo lo que hacemos en este caso devolviendo set.
Además, vamos a permitir crear nuevas publicaciones, con lo que necesitaremos un formulario y una función para gestionarlo. Observa que, una vez que se crea la nueva publicación en el servidor, se activa la bandera para que el efecto que hemos creado antes se ejecute (con el then de la promesa):
const [postContent, setPostContent] = useState("") const addPost = event => { event.preventDefault() saveNewPost(postContent).then(set) setNewPost("") }
Por último, nos queda diseñar la interfaz web de la aplicación. Esta será sencilla, compuesta únicamente del formulario de creación de nuevas publicaciones y el recorrido por la lista de las actuales, creando una instanciación del componente Post para cada una:
return ( <div className="App"> <h1>Microblog personal</h1> <form onSubmit={handleSubmit} className='new'> <textarea value={postContent} placeholder="Nuevo post" onChange={(event) => setPostContent(event.target.value)} /> <button type="submit">Publicar</button> </form> {blog.map(p => <Post {...p} key={p.id} onChange={set} /> )} </div> ) }
Observa que hemos indicado una propiedad onChange que utilizaremos dentro del componente para invocarla cuando la publicación sufra cualquier cambio (edición o borrado). En esos casos, se activa la bandera (set()) y por tanto se vuelve a cargar la lista de publicaciones al entrar en acción el efecto, que depende de la variable obsoleto, establecida por set.
El componente Post
El componente Post se encarga de mostrar el contenido de una publicación y permite editarlo y borrarlo. Además, asociado a cada uno tenemos el identificador de la cuenta que lo ha publicado, así que conviene obtener el nombre para poder incluirlo en los metadatos. Esto lo conseguimos con un efecto en el que solicitamos al servidor los datos de la cuenta referenciada por la propiedad autoria (revisa el archivo db.json, que simula la base de datos con los datos de la aplicación de ejemplo):
function Post({ id, autoria, contenido, fecha, onChange }) { const [nombre, setNombre] = useState("Anónimo") const [postContent, setPostContent] = useState(contenido) const fechaObj = new Date(fecha) const handleSubmit = event => { event.preventDefault() editPost(id, postContent).then(onChange) } useEffect(() => { getAuthor(autoria).then(n => setNombre(n?.nombre)) }, [autoria]) return ( <div className="post"> <div className="aut">{nombre}</div> <div className="date">{fechaObj.getDate()}/{fechaObj.getMonth()+1}/{fechaObj.getFullYear()}</div> <div className="text">{contenido}</div> <div className="opt"> <button onClick={() => deletePost(id).then(onChange)}>Eliminar</button> <details> <summary>Editar...</summary> <form onSubmit={handleSubmit}> <textarea value={postContent} placeholder="Nuevo post" onChange={(event) => setPostContent(event.target.value)} /> <button type="submit">Publicar edición</button> </form> </details> </div> </div> ) }
El resultado del código anterior, unido a algo de estilo CSS, lo podremos ver accediendo a nuestra aplicación arrancada con Vite, como en la captura siguiente:
Como ves, podemos utilizar cualquier herramienta para comunicar una aplicación React con otros servicios. Lo único que debemos tener en cuenta es que cualquier cambio que afecte a la entrada y salida de datos de la aplicación habrá que efectuarlo en una función gestora de eventos o como un efecto con useEffect.
Vamos a verlo en la práctica para acabar de asentar los conceptos. Eso sí, procura repetir por tu cuenta los pasos que vamos a seguir en el vídeo, antes de seguir con el resto del módulo….
Peticiones en aplicaciones con Redux: el asunc thunk
Si hemos desarrollado nuestra aplicación con Redux y queremos integrar la carga y envío de datos a una API externa, tenemos dos vías para hacerlo:
- Crear una interfaz para comunicar con la API y establecer una serie de thunks que actúen de pasarela entre nuestro estado controlado por el almacén de Redux y el servidor. De esta forma, el almacén de Redux debe actuar como una caché de la información que hay almacenada en el servidor.
- Recurrir a una biblioteca que automatice la mayor parte de las tareas necesarias para mantener una caché del estado del servidor en Redux: la gestión de los reducers, la invalidación de cachés, la repetición de peticiones únicamente cuando sean necesarias…
Es importante que te familiarices con ambas formas ya que, incluso si la segunda resultara por lo general más cómoda, puede tener limitaciones que debamos resolver haciendo uso de técnicas aprendidas con la primera. Por ello, en esta lección implementaremos la interacción de nuestro gestor de tareas kanban manualmente con thunks asíncronos, y en la próxima lo haremos mediante RTK Query, que nos ayudará a simplificar gran parte del código.
Concepto de thunk asíncrono
Ya conoces el papel de un thunk en Redux: se trata de una función intermediaria que es capaz de lanzar diferentes acciones en función del estado actual y realizar otro tipo de tareas de entrada/salida que no se pueden llevar a cabo en reducers. En caso de que alguna de esas tareas conlleve realizar peticiones, lectura de ficheros u otras operaciones que en JavaScript se tratan de forma asíncrona, lo apropiado es crear un thunk asíncrono.
¿En qué se diferencian los thunks normales de los asíncronos? Principalmente, en que devuelven promesas, por lo que no podemos asumir que el resultado de la ejecución de uno de ellos estará disponible inmediatamente, sino cuando la promesa se complete.
Adicionalmente, Redux Toolkit ofrece una ayuda para escribir thunks asíncronos que se llama createAsyncThunk y que automáticamente lanza tres acciones en momentos distintos de la resolución de la operación asíncrona:
pending: se despacha inmediatamente cuando se comienza a ejecutar.fulfilled: se activa si se completa la tarea con éxito y se obtiene la respuesta enaction.payload.rejected: en caso de que no se pueda completar la tarea o esta falle, incluyendo el error enaction.error.
Así, un posible ejemplo de thunk asíncrono creado con Redux Toolkit sería el siguiente, que obtiene los datos públicos acerca de una obra de arte del Museo Metropolitano de Arte (MET) de Nueva York utilizando su API pública:
const datosObra = createAsyncThunk( "obras/datosCargados", async (objectId, { dispatch, getState }) => { const response = await fetch(`https://collectionapi.metmuseum.org/public/collection/v1/objects/${objectId}`) .then(r => r.json()) return response } )
A la hora de invocarlo, trabajamos como con cualquier otro creador de acciones, llamando a la función datosObra(). Debemos tener en cuenta que este tipo de thunks solo admite un parámetro, por lo que en el caso de que necesitemos pasar varios valores lo haremos por medio de un objeto:
<button onClick="() => dispatch(datosObra(idObra))"> Mostrar datos </button>
Por sí solo, el thunk obtiene los datos de la obra de arte pero no modifica el estado de Redux para que se pueda acceder a ellos. Esto lo podemos conseguir escribiendo un reducer para la acción dada por datosObra.fulfilled (que tendrá tipo “obras/datosCargados/fulfilled”). Suponiendo que tengamos una slice para gestionar las obras de arte, el código podría tener el siguiente aspecto:
const obrasSlice = createSlice({ // ... extraReducers: builder => { builder.addCase(datosObra.fulfilled, (state, action) => { state.obra = action.payload } } })
A continuación vamos a implementar este patrón en nuestra aplicación de gestión de tareas Kanban.
Implementación de thunks asíncronos en nuestro gestor de tareas
En los archivos asociados a esta lección encontrarás una base de datos db.json que podrás servir con JSON Server. Para ello, comprueba que lo tienes instalado en el proyecto del gestor de tareas y añade el script "server": "json-server --watch db.json" al archivo package.json. Esto bastará para que puedas lanzar el servidor con el comando npm run server y realizar peticiones contra él desde la aplicación.
Observa que la estructura de la base de datos no es idéntica a la del estado global que veníamos utilizando en Redux. En este caso, cada tarea tiene una propiedad “lista” que contiene el identificador de la lista a la que pertenece, en lugar de que las listas contengan un array de tareas. O sea, le hemos dado la vuelta a la relación de modo que una tarea solo puede estar en una lista al mismo tiempo.
Preliminares: definición de la API
Para facilitar que escribamos código simple cuando vayamos a interactuar con Redux, conviene definir aparte los métodos que trabajen “a bajo nivel” con la API REST. En nuestro caso, como en la base de datos de JSON Server hemos creado dos rutas, tablero y tareas, vamos a implementar las operaciones CRUD (crear, leer, modificar y borrar) sobre ellas.
Esencialmente, vamos a disponer de un objeto cliente con dos propiedades, tareas y tablero, que a su vez darán acceso a cuatro funciones cada uno: get(), post(), patch() y delete(). Puesto que el esqueleto de la petición es siempre el mismo y solamente cambia la ruta, el método y el posible cuerpo, abstraemos todo este trabajo en una función req() que representa una petición genérica. La función api() crea las cuatro funciones mencionadas para una ruta (o endpoint) dada:
const API_BASEURL = "http://localhost:3000" const api = endpoint => { const req = (id, method = "GET", body) => fetch(API_BASEURL + endpoint + (id ? `/${id}` : ""), { method, headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : null }).then(r => r.json()) return { get: (id) => req(id), post: content => req(null, "POST", content), patch: (id, content) => req(id, "PATCH", content), delete: id => req(id, "DELETE") } } const cliente = { tareas: api("/tareas"), tablero: api("/tablero") } export default cliente
Este código simplifica mucho el uso de la API, segmentando las tareas en dos grupos y permitiendo que cada uno de ellos tenga sus propias funciones para interactuar con el servidor.
Thunks asíncronos para el gestor de tareas
Una vez que tenemos definida nuestra API que se comunica con el servidor, podemos importar cliente en los archivos dedicados a las slices del tablero y las tareas, y definir las operaciones que podemos realizar sobre el servidor en forma de thunks. Estas van a sustituir a la mayoría de acciones que declaramos en su momento en las opciones de createSlice y, en particular, denominaremos igual a las que vayan a reemplazar su función (para evitar modificar el código que lanza estas acciones en los componentes).
Primero, para cargar el tablero completo y permitir crear nuevas listas, tendremos los siguientes:
// tableroSlice.js export const cargarTablero = createAsyncThunk( 'tablero/cargarTablero', async () => await cliente.tablero.get() ) export const listaCreada = createAsyncThunk( 'tablero/listaCreada', async nombre => await cliente.tablero.post({ nombre }) )
Como ves, al haber separado la creación de las peticiones HTTP en un módulo aparte, aquí basta con hacer uso de las funciones que hemos creado en el objeto cliente, lo que simplifica notablemente el código de estos thunks.
Aunque los thunks siempre reciben por defecto dos parámetros (el objeto que pasamos al crearlos y un objeto que contiene dispatch y getState), los que no vayamos a utilizar podemos omitirlos en la signatura de la función, como se ha hecho en los del listado anterior.
Asimismo, las tareas se podrán crear, cargar, eliminar, mover y renombrar. Estas dos últimas operaciones son de tipo patch cambiando la lista y el título de la tarea, respectivamente:
// tareasSlice.js export const tareasCargadas = createAsyncThunk( 'tareas/tareasCargadas', async () => await cliente.tareas.get() ) export const tareaCreada = createAsyncThunk( 'tareas/creada', async ({titulo, lista}) => await cliente.tareas.post({ titulo, lista }) ) export const tareaEliminada = createAsyncThunk( 'tareas/eliminada', async id => await cliente.tareas.delete(id) ) export const tareaMovida = createAsyncThunk( 'tareas/movida', async ({ id, lista }) => await cliente.tareas.patch(id, { lista }) ) export const tareaRenombrada = createAsyncThunk( 'tareas/renombrada', async ({ id, titulo }) => await cliente.tareas.patch(id, { titulo }) )
Al igual que con el ejemplo del museo, para que los resultados de los thunks tengan efecto en la interfaz de la aplicación, tendremos que aplicar los cambios convenientes en función de los resultados de cada acción.
Un ejemplo completo de las actualizaciones que tienen lugar en el estado del tablero cuando se carga el tablero completo sería el siguiente: añadiremos una propiedad status al estado del tablero para indicar si se está cargando, ha tenido éxito o ha fallado. Los reducers asociados a las acciones pending, fulfilled y rejected actualizarán esta propiedad, rellenando también el objeto de listas de tareas cuando la petición se complete correctamente.
const tableroSlice = createSlice({ name: "tablero", initialState: { status: 'LOADING', listas: {} } extraReducers: builder => { builder .addCase(cargarTablero.pending, state => { state.status = "LOADING" }) .addCase(cargarTablero.fulfilled, (state, action) => { for (let lista of action.payload) { state.listas[lista.id] = lista } state.status = "SUCCESS" }) .addCase(cargarTablero.rejected, state => { state.status = "FAILED" }) } })
De forma similar, habremos implementado las actualizaciones del estado convenientes al menos para la acción fulfilled de todos los thunks asíncronos, para que el estado de Redux esté sincronizado con la base de datos siempre que sea posible. En los archivos asociados a la lección dispones de la implementación de todos los reducers.
Esto nos permite ahora, en primer lugar, lanzar la carga de datos desde el componente Tablero, mediante dispatch(cargarTablero()):
const Tablero = () => { const dispatch = useDispatch() useEffect(() => { dispatch(cargarTablero()) .then(() => dispatch(tareasCargadas())) }, [])
Como ves, tenemos que englobar esta acción en un hook useEffect() (o en la función gestora de un evento) ya que provoca efectos secundarios y, en particular, modifica el estado de Redux.
A continuación, consultamos el status actual seleccionándolo desde el estado:
const { status, listas } = useSelector( state => state.tablero )
Los gestores de eventos que antes trabajaban con acciones de la slice deberían seguir funcionando con los nuevos thunks que se denominan igual:
const [nuevaLista, setNuevaLista] = useState("") const crearLista = (event) => { event.preventDefault() dispatch(listaCreada(nuevaLista)) setNuevaLista("") }
Por último, podemos mostrar una interfaz u otra en función del momento en que se encuentre la solicitud haciendo uso del renderizado condicional:
if (status == "LOADING") return <p>Cargando tablero...</p> if (status == "FAILED") return <p>Error al cargar el tablero.</p> return ( <div className="tablero"> {Object.keys(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> ) }
Trabajaremos de la misma forma con los componentes que sirven para mostrar cada lista de tareas. Estos únicamente requerirán pequeños cambios con respecto a los que estábamos utilizando hasta ahora. Por ejemplo, el componente ListaTareas renderizará un mensaje de carga mientras las tareas estén pendientes de cargar:
const ListaTareas = ({ id: listaId }) => { const { nombre, lista: tareas } = useSelector(state => state.tablero.listas[listaId] ?? { nombre: "", lista: [] }) const status = useSelector(state => state.tareas.status) const tema = useContext(ContextoTema) return ( <div className="lista" style={{background: tema.fondo, color: tema.texto}}> <h2>{nombre}</h2> {status == "LOADING" && <p>Cargando tareas...</p>} {status == "FAILED" && <p>Ocurrió un error</p>} {status == "SUCCESS" && ( <ul> {tareas.map(id => <Tarea key={id} id={id} />)} </ul> )} <FormularioNueva listaId={listaId} /> </div> ) }
Dicho mensaje puedes verlo en acción a continuación:
Comunicación mediante RTK Query - Aplicación al gestor Kanban
RTK Query es una parte de Redux Toolkit que facilita la comunicación con servicios externos en forma de APIs.
RTK Query automatiza toda la gestión de los reducers de Redux, implementando multitud de atajos para las tareas comunes y repetitivas, pero dando acceso a las operaciones internas para poder manipularlas al mismo nivel de detalle que si las implementáramos a mano.
En las siguientes lecciones vamos a verlo con detalle, aplicándolo de paso a la aplicación del gestor Kanban que hemos estado utilizando a lo largo de la formación. Para ello, vamos a partir de la aplicación que ya teníamos, pero sin la comunicación con el servidor.
Una vez más, es un tema algo denso, así que vamos a ir paso a paso y tendrás que pararte bastante en los ejemplos, repitiéndolos, para asegurarte de que entiendes bien lo que está pasando.
¡Ánimo, ya queda menos!
Definición de la API con RTK Query
La principal función de RTK Query la encontraremos en el módulo “@reduxjs/toolkit/query/react” y se llama createApi(). Es similar a la funcionalidad createSlice() en el sentido de que definiremos una serie de operaciones y nos generará funcionalidad que se pueda lanzar desde los componentes. Sin embargo, a diferencia de las slices, esta función generará hooks, evitándonos la necesidad de usar dispatch() y funciones selectoras.
Partiendo de la aplicación del gestor de tareas con el estado gestionado con Redux pero sin comunicación con el servidor, vamos a crear un fichero api/apiSlice.js. Aquí, importaremos dos funciones de RTK Query y definiremos nuestra API:
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react" const apiSlice = createApi({ reducerPath: 'api', baseQuery: fetchBaseQuery({ baseUrl: 'http://localhost:3000/' }) })
Presta atención a la fuente desde la que importas createApi, ya que también existe en el módulo “@reduxjs/toolkit/query” pero este es independiente de React, por lo que no nos generaría los hooks correspondientes.
Por ahora, hemos especificado dos opciones como parámetros de createApi():
reducerPath: es opcional y sirve para indicar un nombre para la propiedad del almacén de Redux donde queremos albergar la información transmitida por el servidor.baseQuery: nos permite establecer una plantilla que seguirán todas las peticiones de esta API; en nuestro caso, que el URL base seráhttp://localhost:3000.
Lo siguiente será proporcionar la opción endpoints, que debe ser una función que toma como parámetro un builder, y que devolverá un objeto en el que iremos definiendo cada una de las operaciones que se pueden realizar en el servidor. Existen dos tipos de peticiones: consultas (query) y mutaciones (mutation). Las consultas son aquellas con las que vamos a leer información, y las mutaciones nos servirán para modificarla o añadir nuevos datos.
Crear consultas es muy sencillo, basta con utilizar el método query() del objeto builder y proporcionarle la opción query, que a su vez será una función que devuelva el resto de la ruta que se concatenará al URL base:
endpoints: builder => ({ getTablero: builder.query({ query: () => "/tablero" }), })
Observa que los paréntesis que engloban el objeto devuelto por la función endpoints son obligatorios ya que, de lo contrario, el parser entendería que las llaves corresponden con el cuerpo de la función, no con la definición de un literal de objeto.
De la misma forma, crearemos consultas para todas las tareas y para obtener únicamente las tareas cuya lista coincida con un identificador dado:
getTareas: builder.query({ query: () => "/tareas" }), getListaTareas: builder.query({ query: (id) => `/tareas/?lista=${id}` }),
En cuanto a las mutaciones, se crearán con el método mutation() y será necesario que incluyamos más información en la opción query, además de la ruta: el método HTTP y, opcionalmente, un cuerpo de la petición. Por ejemplo, para crear una nueva lista de tareas, necesitaremos su nombre e invocaremos el método POST sobre la ruta /tablero:
createLista: builder.mutation({ query: (nombre) => ({ url: "/tablero", method: "POST", body: { nombre } }) }),
Procediendo de forma similar, podremos crear, modificar y borrar tareas con los métodos HTTP apropiados:
createTarea: builder.mutation({ query: ({ titulo, lista }) => ({ url: "/tareas", method: "POST", body: { titulo, lista } }), }), updateTarea: builder.mutation({ query: ({ id, ...patch }) => ({ url: `/tareas/${id}`, method: "PATCH", body: patch }), }), deleteTarea: builder.mutation({ query: (id) => ({ url: `/tareas/${id}`, method: "DELETE" }), })
La ejecución de createApi generará un hook por cada consulta o mutación que definamos. El esqueleto del nombre del hook será: use + el nombre de la operación comenzando por mayúscula + Query o Mutation al final en función del tipo. Así, los elementos exportados por nuestra API serán:
export const { useGetTableroQuery, useGetTareasQuery, useGetListaTareasQuery, useCreateListaMutation, useCreateTareaMutation, useUpdateTareaMutation, useDeleteTareaMutation } = apiSlice export default apiSlice
Observa que, además de exportar los hooks generados por la slice, también hemos exportado la propia apiSlice. Esto se debe a que toda la funcionalidad para consultar datos e iniciar peticiones está también contenida en dicho objeto, y nos permite utilizarla en lugares donde no podamos llamar a los hooks, es decir, desde cualquier función que no sea un componente.
Preparación del almacén
Antes de lanzar ninguna petición, es fundamental que el almacén sea capaz de almacenar los datos de la API. Para poner al almacén en conocimiento de esta información, createApi genera además un reducer principal que podemos incluir en el objeto reducer. Además de esto, será muy conveniente incluir el middleware de RTK Query, que habilitará algunos mecanismos útiles como la gestión de cachés o el sondeo (polling):
// app/store.js import api from "../features/api/apiSlice"; export const store = configureStore({ reducer: { [api.reducerPath]: api.reducer }, middleware: getDefaultMiddleware => getDefaultMiddleware().concat(api.middleware) })
Abre el código de ambas y compara esto con la versión anterior de la aplicación. Puedes observar que hemos eliminado los reducers de tareas y tablero. Esto es porque, al obtener esos datos mediante la API, utilizar las slices sería duplicar la información en el almacén. Por tanto, las slices de tareas y tablero dejan de tener sentido, y podemos eliminar los ficheros tableroSlice.js y tareasSlice.js. A continuación, sustituiremos todas las interacciones con las acciones y selectores del almacén por hooks para ejecución de consultas y mutaciones, simplificando así el código de los componentes.
Uso de las consultas en la aplicación
Hasta ahora, en nuestra API hemos definido dos tipos de operaciones: consultas y mutaciones. El propósito es que podamos utilizarlas de formas apropiadas en los componentes. Por un lado, las consultas se encargarán de recuperar los datos, bien del servidor o bien de la caché en el almacén de Redux, al invocar el hook correspondiente. Por otro, las mutaciones nos proporcionarán una función que podremos llamar cuando se deba alterar alguna información en el servidor.
Veamos primero un ejemplo de cómo utilizaremos el hook asociado a la consulta getTablero:
// Tablero.jsx const Tablero = () => { const { data: listas, isLoading, isSuccess, isError, error, refetch } = useGetTableroQuery() // ...
El valor de retorno de useGetTableroQuery() es un objeto con la respuesta de la petición en el campo data, que en este caso se le asigna a la constante listas mediante desestructuración. Y además obtenemos varias propiedades que nos indicarán en qué momento de la petición nos encontramos:
isLoadinges un valor booleano que será verdadero cuando la aplicación está esperando una respuesta por primera vez.isFetchingserá también verdadero cada vez que se repita la petición y se esté esperando una respuesta.isSuccessserá verdadero cuando se complete la petición con éxitoisErrorse establecerá a true cuando la petición termine con un error. La información de dicho error la encontraremos en ese caso en el campoerror.refetchserá una función que podremos invocar para forzar una repetición de la consulta.
Un cambio en el estado de la petición conllevará una renderización del componente, por lo que utilizaremos los valores de los datos que hemos obtenido para renderizarlo en cada momento, y no necesitamos hacer nada más para que la interfaz siempre se muestre con la información más reciente.
La interfaz en JSX del tablero de listas podría quedar como sigue, utilizando todas estas propiedades:
return ( <div className="tablero"> {isSuccess && <> {listas.map((val) => <ListaTareas key={val.id} id={val.id} nombre={val.nombre} /> )} <FormNuevaLista/> </> } {isLoading && <p>Cargando tu tablero...</p>} {isError && <p>Ocurrió un error al cargar el tablero: {error.error} <button onClick={refetch}>¿Intentar de nuevo?</button></p>} </div> ) }
En este caso, hemos utilizado la consulta sin pasar ningún parámetro, pero cada uno de los hooks asociados a consultas acepta dos parámetros: queryArg y queryOptions.
El argumento queryArg se proporcionará, tal cual, a la función query que hayamos definido. Por ejemplo, la consulta getListaTareas acepta un identificador de lista de forma que solo recupera las tareas cuya lista coincida con este, y que le pasaremos en este primer parámetro. Así, cada instanciación del componente ListaTareas solo se volverá a renderizar si alguna de sus tareas se ve modificada:
const ListaTareas = ({ id: listaId, nombre }) => { const { data: tareas, isLoading, isSuccess, isError, error } = useGetListaTareasQuery(listaId) // ...
El objeto de opciones queryOptions admite varias propiedades para controlar el comportamiento de las solicitudes.
Una de ellas, especialmente útil, es selectFromResult, que acepta una función para extraer datos de la respuesta de la API, optimizando el renderizado del componente para que solo ocurra si dicho subconjunto de datos cambia. Esto, por ejemplo, nos será útil en el componente Cabecera, que únicamente necesita el número de tareas y no la lista completa:
const Cabecera = () => { const { tareas } = useGetTareasQuery(undefined, { // Es importante usar el operador de acceso "?." en este caso // porque si los datos aún no están cargados provocará un error selectFromResult: ({ data }) => ({ tareas: data?.length }) }) // El resto del componente no cambia
Observa que, como en este caso no necesitamos pasar ningún parámetro a la consulta pero queremos pasar el segundo argumento con las opciones, hemos utilizado undefined como primer argumento de useGetTareasQuery().
Activación de mutaciones
Si hasta ahora hemos limpiado el código de selectores y acciones para sustituirlos por consultas y mutaciones, utilizando las primeras adecuadamente ya deberíamos conseguir que la aplicación muestre la información obtenida desde el servidor en la interfaz. Sin embargo, ningún botón ni formulario que interaccione con el estado será funcional aún. Para lograrlo, vamos a incorporar los hooks generados por las mutaciones que hemos definido en la API.
Cada uno de estos hooks devuelve una tupla con dos elementos:
- Una función que realiza la petición al llamarla
- Un objeto similar al que devuelve el hook de una consulta, que contiene información acerca del momento en el que se encuentra una solicitud (
isLoading,isSuccess,isError, etc.).
El caso más simple de mutación en nuestra aplicación es el de la creación de una nueva lista: basta con proporcionar el nombre de la lista y la API hará lo demás. En el nuevo componente FormNuevaLista, controlamos de la manera convencional un campo con el dato nuevaLista y gestionamos el envío del formulario, llamando aquí a la función crearLista asociada a la mutación, obtenida de la tupla devuelta por el hook useCreateListaMutation():
const FormNuevaLista = () => { const [nuevaLista, setNuevaLista] = useState("") const [crearLista, resultado] = useCreateListaMutation() const handleSubmit = event => { event.preventDefault() crearLista(nuevaLista) } // ...
El resto de operaciones con las tareas se harán de forma muy similar, por ejemplo, la eliminación y la modificación del título de una tarea:
const Tarea = ({ id, titulo: initialTitulo }) => { const [titulo, setTitulo] = useState(initialTitulo) const [renombrarTarea, resultado] = useUpdateTareaMutation() const [eliminarTarea, resultado2] = useDeleteTareaMutation() const handleChange = event => { setTitulo(event.target.value) renombrarTarea({id, titulo: event.target.value}) } return ( <li> <input type="text" value={titulo} onChange={handleChange} /> <Boton onClick={() => eliminarTarea(id)}>×</Boton> </li> ) }
Fíjate en que, al renombrar la tarea, enviamos el titulo indicado por event.target.value y no titulo, ya que titulo tendrá el contenido previo al disparo del evento onChange.
Cancelación de peticiones
Un detalle que puede pasar inadvertido mientras desarrollamos, pero que es muy importante, es que en un entorno de producción seguramente no sería conveniente actualizar el nombre de la tarea en el servidor cada vez que cambie el valor del input (como estamos haciendo). Si se introducen muchos caracteres de manera rápida, se lanzarían múltiples actualizaciones asíncronas.
Para no saturar el servidor enviando demasiadas peticiones seguidas, podemos cancelar todas las peticiones en curso salvo la última. La función que nos proporciona la mutación devuelve una promesa, que a su vez tiene un método abort() que podemos utilizar cuando sepamos que hay un nuevo cambio del título y que, por tanto, se puede desechar la petición actual.
Para ello, sin embargo, hay que ajustar un poco el código utilizando la mutación en useEffect() para poder acceder a la promesa justo antes de que se vuelva a redefinir la constante:
useEffect(() => { const peticionRenombrado = renombrarTarea({id, titulo}) return () => { peticionRenombrado?.abort() } }, [titulo]) const handleChange = event => { setTitulo(event.target.value) }
Hemos movido la llamada a renombrarTarea() a un efecto que se ejecuta cada vez que cambia el título (el efecto tiene una dependencia en titulo). Como resultado del efecto hemos devuelto una función que, como recordarás, se ejecutará cuando el componente se desmonte. Esta función cancela la petición en curso, si la hay, utilizando el método abort() de la promesa devuelta por renombrarTarea(). Gracias a esto conseguimos el efecto deseado.
Interacción de thunks con la API
Las APIs definidas con RTK Query ofrecen dos formas de interactuar con las consultas y las mutaciones que hemos definido. Por un lado, están los hooks que acabamos de utilizar. Por otro, tenemos las propiedades de apiSlice.endpoints, que nos permiten tanto acceder a los datos almacenados para cada operación como lanzar nuevas peticiones. El objeto endpoints que incluye la API tiene la siguiente estructura:
endpointsgetTablero: un objeto asociado a la consultagetTableroque definimos antesselect(): un método que devuelve una función, que sirve para obtener del estado global de Redux los datos resultantes de una petición previa degetTablero. Si no se ha realizado ninguna petición previa, el métodoselect()devolveráundefinedinitiate(): un creador de thunks para enviar una consulta de tipogetTablero.- Otros métodos…
updateTarea: un objeto asociado a la mutaciónupdateTareaselect(): permite recuperar los datos resultantes de una petición previainitiate(): permite enviar una nueva petición para modificar una tarea- Otros métodos…
- Otras consultas y mutaciones siguendo el mismo esquema…
Esta posibilidad existe para permitir el uso de la API en lugares del código donde no se pueden utilizar hooks, como pueden ser, entre otros, los thunks.
Cuando la operación que queremos ofrecer en la interfaz de usuario no se corresponde exactamente con las que podemos pedir al servidor, es muy posible que tengamos que escribir un thunk que utilice la API de manera directa.
Un ejemplo de esto son los botones para mover tareas a la izquierda y derecha, ya que en el servidor solo podemos ajustar el identificador de la lista de una tarea. Previamente, este cambio lo podíamos conseguir directamente despachando acciones sobre el estado global desde el thunk, pero ahora el estado se debe guardar de forma persistente en el servidor.
Por tanto, ahora implementaremos un thunk de tipo asíncrono y recurriremos directamente a la interfaz de la apiSlice. Esta nos permite obtener el estado actual del tablero de listas llamando al método select() del endpoint getTablero, y efectuar una modificación de la tarea llamando a initiate() sobre el endpoint updateTarea:
// api/apiSlice.js export const tareaMovidaIzquierda = createAsyncThunk( 'tareas/movidaIzq', async ({ id, lista }, { dispatch, getState }) => { // Consultar manualmente el tablero cargado por la API en el estado actual const { data: tablero } = apiSlice.endpoints.getTablero.select()(getState()) // Averiguar la lista anterior a la actual const fromIdx = tablero.findIndex(v => v.id === lista) const toIdx = fromIdx - 1 if (toIdx >= 0) { // Lanzar manualmente una actualización de la tarea para modificar su lista await dispatch(apiSlice.endpoints.updateTarea.initiate({ id, lista: tablero[toIdx].id })) } } )
Fíjate en que, como primer argumento del thunk asíncrono, recibimos un objeto con los parámetros que necesitamos para realizar la operación (en este caso, el identificador de la tarea y la lista de procedencia). El segundo argumento es un objeto con dos propiedades: dispatch y getState. La primera es la función dispatch de Redux, que nos permite lanzar acciones y thunks, y la utilizamos para iniciar una petición de modificación de la tarea. La segunda es una función que nos permite obtener el estado actual de la aplicación, que en este caso nos interesa para extraer el tablero de listas cargado por la API mediante el método select() del endpoint getTablero.
No te olvides de que Redux es, en esencia, una caché global de datos. Si utilizas frecuentemente esta forma de lanzar peticiones con el método initiate(), debes tener en cuenta que RTK Query contará cada petición como una suscripción a los datos obtenidos, de forma que no liberará cachés innecesarias incluso cuando ya no estemos accediendo a ellos. Para prevenir esto, podemos llamar al método unsubscribe() sobre la acción despachada. Por ejemplo, para deshacer la suscripción a la petición de modificar la tarea que acabamos de escribir, ejecutaríamos lo siguiente:
const result = dispatch(apiSlice.endpoints.updateTarea.initiate({ id, lista: tablero[toIdx].id })) result.unsubscribe() await result
Hablaremos más de cachés y de su invalidación en la siguiente lección.
