¡Esta es una revisión vieja del documento!
Tabla de Contenidos
Cuestiones adicionales y avanzadas
Notas del curso Desarrollo de aplicaciones web con React 18
A estas alturas ya conoces todos los conceptos de React que te permiten desarrollar prácticamente cualquier aplicación de Front-End de tipo SPA (Singe Page Application, aplicaciones de una sola página) con esta biblioteca. También has aprendido los conceptos relacionados con la reactividad, así como a implementarlos de manera más sencilla y profesional utilizando Redux. Sabes conectarte con una API para consultar datos y realizar acciones, así como hacer caché de esos datos y actualizarlos en tiempo real.
Sin embargo, React sigue siendo una biblioteca enfocada únicamente en crear componentes de UI reutilizables, por lo que carece de otras funcionalidades que son necesarias para desarrollar aplicaciones completas. En este módulo veremos cómo suplir algunas de estas limitaciones propias de React mediante otras bibliotecas y herramientas complementarias.
En concreto aprenderás a:
- Gestionar el enrutamiento de una aplicación React mediante React Router, de modo que puedas crear una aplicación de varias páginas sin perder la naturaleza de SPA.
- Gestión de sesiones de usuario, realizando autenticación y autorización de usuarios, de modo que puedas añadir seguridad en el lado cliente para tu aplicación, mostrando a cada uno lo que le corresponda.
Navegación por páginas mediante React Router
Las aplicaciones que desarrollamos con React suelen seguir un paradigma de una única página (o SPA, single page application). Esto puede presentar limitaciones cuando hay distintas partes de la aplicación que se deben mostrar en diferentes momentos. Una opción sería cargar la interfaz al completo de todas las posibles pantallas y mostrar u ocultar parte de ella con variables booleanas. Sin embargo, esto implica que el navegador potencialmente tendrá que descargar mucha más información de la que se va a usar, ralentizando la carga de la página web.
La solución a esta problemática consiste en utilizar un sistema de enrutamiento (routing) que preserve la naturaleza de SPA de nuestra aplicación React pero que al mismo tiempo nos siga proporcionando rutas únicas para cada parte de la aplicación. El más utilizado y completo viene en forma de librería y se denomina React Router. A lo largo de esta lección aprenderás a manejarla mediante un ejemplo sencillo, y a continuación lo integraremos en nuestra aplicación gestora de tareas.
La idea de esta lección es que vayas creando paso a paso un proyecto desde cero, siguiendo las indicaciones que se van dando en cada apartado, hasta llegar a construir la aplicación completa. Deberás hacerlo así si quieres conocer bien todos los detalles del funcionamiento, ya que es un tema bastante complejo.
Mapeo de rutas
Para comenzar, crea un nuevo proyecto de tipo React con Vite y añade las dependencias de React Router y LocalForage mediante el siguiente comando:
npm install react-router-dom localforage
LocalForage es una biblioteca que nos permite almacenar datos en el navegador de forma persistente y asíncrona, de forma que no se pierdan al recargar la página. Al contrario que usando tan solo localstorage, es capaz de almacenar otros tipos de datos que no sean cadenas de texto. Se utiliza en el archivo gallery.js para guardar alguna información sobre las fotos del ejemplo.
Además, puedes eliminar todos los archivos que se incluyen por defecto en el directorio src/ salvo main.jsx e index.css, que los mantendremos para modificarlos. Añade los siguientes imports al archivo main.jsx:
import { createBrowserRouter, createRoutesFromElements, Route, RouterProvider } from "react-router-dom"
Encontrarás en los archivos asociados a esta lección un index.css que puedes copiar directamente, ya que no incluye nada relevante a lo que nos ocupa, pero mejorará la estética de la aplicación 😉
El funcionamiento de React Router consiste en asociar diferentes elementos de React a rutas en nuestra aplicación. Cada ruta se distingue por un camino diferente desde la raíz (/).
Por ejemplo, como demostración de esta funcionalidad, vamos a construir una sencilla aplicación para mostrar una galería de fotografías donde los usuarios podrán votar por sus fotos favoritas. Las rutas que definiremos, por tanto, tendrán el siguiente esquema:
/: raíz de la aplicación, que mostrará el índice de fotos/photos/:id: página individual para cada fotografía/photos/:id/voted: página que se mostrará cuando se haya votado por una foto
Si traducimos la lista de caminos a un grafo, podría tener el siguiente aspecto. Se han añadido los nombres de los componentes que vamos a implementar en cada ruta y se observa mejor que unas rutas parten de otras previas:
Para definir cada una de estas rutas utilizaremos elementos de tipo <Route>. Las rutas recibirán como propiedades el camino que representan y el elemento que tienen que renderizar. La ventaja de enrutar de esta forma, en lugar de generar y enviar páginas completas, es que, en cada ruta, podemos definir únicamente las partes de la aplicación que deben cambiar. Para ello, simplemente anidaremos unas rutas dentro de otras y se renderizarán los elementos en el lugar apropiado que marcaremos más adelante.
Tal y como hemos definido el esquema de rutas previamente, escribimos tantos elementos <Route> como rutas incluidas en el índice (que se marcan con la propiedad index) como en el listado que veremos en breve.
Además, especificaremos el parámetro :imageId en la ruta photos/:imageId de forma que, al ir precedido de dos puntos, capturará todas las rutas que empiecen por photos/ y lo que siga se incluirá como parámetro.
Estas rutas las pasaremos por la función createRoutesFromElements() para que se conviertan en un parámetro apto para crear el objeto enrutador mediante createBrowserRouter():
const router = createBrowserRouter(createRoutesFromElements( <Route path="/" element={<Root />}> <Route index element={<Index />} /> <Route path="photos/:imageId" element={<Photo />}> <Route index element={<Vote />} /> <Route path="voted" element={<Voted />} /> </Route> </Route> ))
Los caminos de rutas anidadas se anidarán también: por ejemplo, si la ruta de camino voted está anidada bajo la de camino photos/:imageId, entonces llegaremos a la primera cuando la URL tenga el formato /photos/:imageId/voted.
El browser router que hemos instanciado es un enrutador que utiliza la URL del navegador para controlar el contenido de la página, pero también podríamos utilizar createMemoryRouter() si queremos que dicha URL no se modifique (o estamos desarrollando en un entorno en el que no existe tal URL, por ejemplo, una aplicación de escritorio), o createHashRouter() si fuera necesario prefijar una almohadilla # a la ruta en la URL, por ejemplo, cuando no permitamos en el servidor peticiones a rutas distintas de la raíz de la aplicación.
Para utilizar el router que acabamos de crear, sustituiremos el elemento <App> que se renderiza en la raíz de la aplicación por el <RouterProvider> asociado a nuestro objeto:
ReactDOM.createRoot(document.getElementById('root')).render( <React.StrictMode> <RouterProvider router={router} /> </React.StrictMode> )
Estructura de los componentes
Ahora que tenemos las rutas definidas, podemos construir la interfaz de la página a partir de diferentes componentes que se renderizarán o no en función de la URL en la que se encuentre la persona visitante en cada momento. Estos se compondrán de sintaxis JSX y los elementos que acostumbramos a usar hasta ahora, con la adición de un nuevo elemento especial: <Outlet>. Este servirá de ranura o slot donde renderizar los elementos correspondientes a las rutas anidadas, en función de en qué ruta nos encontremos.
Primero, el componente Root describirá la estructura general de la página mediante una cabecera y un pie, y establecerá un espacio para el contenido:
// routes/root.jsx export default function Root() { return ( <> <header> <h1><Link to="/">Concurso de fotografía</Link></h1> </header> <main><Outlet /></main> <footer> Todas las fotografías © sus respectivos autores. Utilizadas bajo la licencia Unsplash. </footer> </> ); }
Habrás notado que también hemos utilizado un elemento <Link> en lugar de un enlace normal con <a>. Esto se debe a que Link es un componente de React Router que realiza la misma función pero sin necesidad de recargar la página. En lugar del atributo href usaremos la propiedad to para indicar el destino del enlace.
Si nos encontramos en la URL raíz /, en ese espacio se mostrará el componente Index que listará las fotografías (del array images que supondremos que está definido) que se pueden visitar:
// routes/index.jsx export default function Index() { // datos de ejemplo const images = ["a", "b", "c"] return ( <> <p className="content">Haz clic en cada imagen para ver más detalles y votar por ella.</p> <div id="gallery"> {images.map((id) => <Link key={id} to={`photos/${id}`}> <img src="{`/images/${id}.jpg`}/"> </Link> )} </div> </> ); }
Una vez que se accede a la página individual de una fotografía, mostraremos un contenido diferente dando detalles acerca de la misma. Fíjate en que tenemos un nuevo <Outlet> para mostrar las rutas anidadas a la actual (mira el diagrama del principio), que son, o bien el formulario de votación, o bien el mensaje tras haber votado:
// routes/photo.jsx export default function Photo() { // datos de ejemplo const title = "Foto de ejemplo" const author = "Alice Robertson" const votes = 10 const url = "https://source.unsplash.com/400x400/?cat" return ( <figure> <figcaption> <p><em>{title}</em> por <strong>{author}</strong></p> <p className="pill"><span>Votos</span><span>{votes}</span></p> </figcaption> <img src="{url} /"> <Outlet /> </figure> ) }
El formulario de votación es tan sencillo como un formulario <Form> (un componente idéntico a <form> pero que no requiere refrescar la página) con un único botón que sirve para enviar el voto. No es necesario enviar como parámetro la imagen a la que vamos a votar, puesto que ya estará su identificador como parte de la URL de la página cuando se muestre este formulario:
// routes/vote.jsx export default function Vote() { const boton = useRef() return <div className="cta"> <p>¿Es tu favorita? Vótala:</p> <Form method="post" onSubmit={() => boton.current.disabled="disabled"}> <button ref={boton} type="submit"> Votar </button> </Form> </div> }
Puedes ver en el componente anterior un ejemplo de uso del hook useRef, que consiste en capturar el nodo DOM correspondiente al botón de “Votar” (marcado para ello con un atributo ref), para desactivarlo en el momento en que se envíe el voto (en el evento submit). De esta forma evitaremos enviar más de un voto seguido por error.
Por último, la interfaz tras haber votado simplemente muestra un mensaje de confirmación y agradecimiento:
// routes/voted.jsx export default function Voted() { return <div className="cta"> <p>¡Gracias por votar!<br />Tu voto ha quedado registrado</p> </div> }
Si tenemos la aplicación arrancada con npm run dev y accedemos en el navegador a http://localhost:5173/photos/a, veremos una pantalla como la siguiente:
Captura de errores
Por ahora, si tratas de votar una fotografía utilizando el formulario que hemos implementado, verás que se muestra un mensaje de error bastante poco atractivo:
Lo ideal sería poder capturar el error y mostrar un mensaje más amigable dentro de la interfaz de nuestra aplicación. Para ello, utilizaremos una propiedad denominada errorElement donde las rutas aceptan un elemento que se mostrará en caso de que no se pueda mostrar el contenido solicitado u ocurra algún otro error.
Esto nos permite, además, capturar diferentes errores en diferentes partes de la interfaz, y no romper totalmente la estructura de la página en caso de que solamente una sección haya provocado el error.
Para implementarlo, añade un archivo error-page.jsx con el siguiente código para construir un componente que muestre un mensaje de error:
import { useRouteError } from "react-router-dom"; export default function ErrorPage() { const error = useRouteError(); console.error(error); return ( <div id="error-page"> <h1>¡Oh, no!</h1> <p>Lo sentimos, ha ocurrido un error inesperado.</p> <p>Mensaje de error: <i>{error.statusText || error.message}</i></p> </div> ); }
Ahora, en las rutas de main.jsx, podemos importar dicho componente para mostrarlo en la ruta raíz si esta no se puede mostrar, o como el contenido de la ruta en caso de que las rutas hijas sean las que causen el error:
<Route path="/" element={<Root />} errorElement={<ErrorPage />}> <Route errorElement={<ErrorPage />}> { /* resto de rutas */ } </Route> </Route>
Podrás comprobar cómo, al provocar un error, el mensaje que aparece no rompe completamente con la interfaz de la aplicación ya que se sigue renderizando dentro del componente Root:
Carga de datos
Aunque tenemos implementada la interfaz de todas las páginas, y podemos incluso movernos por ellas, aún tenemos pendiente conectar los parámetros de la URL como :imageId con el contenido de la página, a través de la carga de los datos pertinentes. Esto lo conseguiremos mediante funciones llamadas loader que podemos asociar a cualquier ruta que deba cargar datos.
Ahora es el momento de que copies el archivo gallery.js de los ficheros asociados a esta lección y lo coloques en el directorio src/ de tu aplicación. Si echas un vistazo, verás que simplemente simula una API donde se almacenan las imágenes del concurso de fotografía y permite enviar los votos (que realmente permanecen almacenados en el almacenamiento local del navegador). Copia también la carpeta public/ a tu aplicación para poder ver las fotografías de ejemplo.
En el archivo routes/index.jsx, debes importar la función useLoaderData() de React Router, y añadir una función loader desde donde llamaremos a la API para conseguir los identificadores de las fotografías. En el componente Index invocaremos el hook useLoaderData para recuperar esos datos:
import { Link, useLoaderData } from "react-router-dom" export async function loader() { const images = await getImageIds(); return images } export default function Index() { const images = useLoaderData() // resto del componente }
La función loader se tiene que ejecutar antes de mostrar el contenido del componente Index. Para indicar en la ruta que deben cargarse estos datos previamente, debemos importar la función en el archivo donde hemos definido las rutas:
import Index, { loader as indexLoader } from './routes'
A continuación, hay que añadir la propiedad loader asociándole la función de carga:
// ... rutas anteriores <Route index element={<Index />} loader={indexLoader} /> // ... rutas posteriores
En resumen: definimos una función loader en el componente asociado a la ruta, que nos permite obtener los datos antes de renderizarlo. Al cargar la ruta, React Router ejecuta la función loader para cargar los datos y, cuando esta se resuelve (los datos ya están disponibles), renderiza el componente con los datos obtenidos. Dentro del componente, el hook useLoaderData nos permite acceder a esos datos cargados previamente por <Route>.
Ahora, si visitas la raíz de la página verás una rejilla de imágenes, y cada una llevará a una página distinta. Para que en la página individual podamos consultar los detalles de la fotografía correspondiente, cargaremos sus datos siguiendo el mismo mecanismo:
// routes/photo.jsx export async function loader({ params }) { const data = await getImageData(params.imageId) return data } export default function Photo() { const { id, url, title, author, votes } = useLoaderData() // resto del componente }
En este caso, la función loader recibe automáticamente un objeto con el valor de los parámetros definidos el URL (en este caso :imageId que se convierte en la propiedad imageId del objeto params), y devuelve los datos de la fotografía correspondiente.
Si ahora importamos en main.jsx esta función loader (como photoLoader, por ejemplo) y la añadimos con la propiedad loader a la ruta correspondiente al elemento <Photo>, la aplicación será ya capaz de cargar la información adecuada y renderizar a continuación el componente Photo dentro de la página.
Detección de estado de carga
Cuando se requiere cargar datos remotamente antes de cambiar el contenido de la página, es posible que se produzca un retraso que provoque que la aplicación parezca reaccionar demasiado lentamente. Para solucionarlo, podemos aportar una respuesta instantánea para mostrar que la página está cargando, de forma que se vea un estado intermedio entre la página anterior y la nueva.
Para distinguir si la página está en un estado de carga o no, React Router proporciona el hook useNavigation que nos devuelve un objeto cuya propiedad state puede tomar los siguientes valores:
idle: para indicar que la aplicación está inactiva.“submitting”: que está pendiente de enviar una petición por medio de un formulario.“loading”: se encuentra cargando la siguiente página.
Aprovecharemos esta información añadiendo la clase CSS loading al elemento div#gallery del componente Index:
const navigation = useNavigation() return (<> <div id="gallery" className={navigation.state === "loading" ? "loading" : ""}>
De forma similar, detectaremos cuando estamos saliendo de la página individual de una fotografía para cambiar el estado del elemento figure del componente Photo. Consultaremos, para ello, si el pathname de la ubicación en el objeto devuelto por el hook sigue conteniendo el identificador de la foto que se está mostrando:
// photo.jsx const navigation = useNavigation() // Si estamos yendo a una página fuera de esta fotografía, mostraremos el filtro de carga const goingBack = navigation.state === "loading" && !navigation.location.pathname.includes(id) return ( <figure className={goingBack ? "loading" : ""}> <!-- resto del código del componente.... -->
Ahora podrás ver que las fotografías se atenúan cuando otra página está en proceso de carga.
Por último, podemos añadir un indicador visual de que se está llevando a cabo una acción al enviar un formulario, si utilizamos un recurso análogo en el botón del componente Vote:
const navigation = useNavigation() // ... <button ref={boton} type="submit" className={navigation.state !== "idle" ? "sending" : ""}> Votar </button>
Todavía no podrás ver el resultado en el botón ya que no disponemos de una respuesta a la petición del formulario, que es lo siguiente que vamos a implementar.
Envío de datos
La filosofía de React Router es seguir el paradigma estándar de HTML en cuanto a envío de datos. Por ello, en lugar de tener que construir peticiones asíncronas y enviarlas por medio de más código JavaScript, utilizaremos formularios como el del componente Vote, que esencialmente se reduce a lo siguiente:
<Form method="post"> <button type="submit">Votar</button> </Form>
Al igual que los formularios HTML, el componente Form tiene dos atributos relevantes: action y method. El primero, que por defecto apunta a la ruta actual, indica el camino sobre el que realizar la petición a la aplicación. El segundo especifica qué tipo de petición realizamos (get, post, patch, put y delete son los verbos HTTP más comunes).
Para que la aplicación sea capaz de responder a una petición de un formulario, es necesario que la ruta a la que llegue dicha petición disponga de una función action, que será la que procese los datos y genere una respuesta adecuada. Por ejemplo, en el caso de la aplicación de la galería fotográfica, tenemos definida una API simulada con una función para añadir un voto a una fotografía:
// routes/vote.jsx export async function action({ params }) { await sendVote(params.imageId) return redirect(`/photos/${params.imageId}/voted`) }
El valor de retorno de la acción puede ser cualquier respuesta HTTP (podemos crearlas con new Response() "estándar"), pero en este caso hemos recurrido a la función redirect de React Router, que es un atajo para crear respuestas de redirección. Si ocurren errores, también se pueden lanzar respuestas con throw para indicar que la petición no se ha resuelto correctamente.
Solamente queda asociar la función action que acabamos de definir a la ruta correspondiente:
import Vote, { action as voteAction } from './routes/vote' // ... <Route index element={<Vote />} action={voteAction} />
A partir de este momento, la aplicación será capaz de llevar un conteo de los votos a cada fotografía y sumar los que vayamos enviando mediante los botones.
Actualizaciones optimistas
Puesto que la aplicación web únicamente muestra los datos que recibe de la API, la interfaz tarda un cierto intervalo de tiempo desde que se pulsa el botón para votar hasta que se muestra el valor actualizado en el contador de votos. De igual forma, en cualquier aplicación tendremos información mostrada que quedará obsoleta cuando se interaccione con los formularios y controles pero la información nueva puede tardar en llegar desde el servidor.
Para que la aplicación reaccione instantáneamente a la información enviada por el usuario, podemos utilizar un mecanismo de “actualización optimista”. Se denomina de esta forma a todo cambio en la interfaz que se realiza antes de comprobar si realmente se corresponde con el dato actualizado, y una vez que se obtiene dicho dato, se puede pasar a mostrar en su lugar. Por ejemplo, sería una actualización optimista sumar una unidad al contador de votos de una fotografía en cuanto se pulse el botón de votar, aunque la API no haya devuelto una respuesta afirmativa aún.
El objeto navigation que nos devuelve useNavigation() incluye una propiedad formData que, mientras está pendiente el envío de un formulario, incorpora los datos que se están enviando. Esto nos permite consultar si esos datos existen, en cuyo caso mostraremos un voto más que los que haya indicado la API. Cuando la petición se complete, formData no estará definido y por tanto volveremos a mostrar el número de votos que recuperemos desde la API:
// Si estamos enviando el formulario de votación, podemos mostrar directamente el voto añadido const showVotes = navigation.formData ? votes + 1 : votes return ( <figure className={goingBack ? "loading" : ""}> <figcaption> <p><em>{title}</em> por <strong>{author}</strong></p> <p className="pill"><span>Votos</span><span>{showVotes}</span></p> </figcaption> <img src="{url} /"> <Outlet /> </figure> )
Puedes ver a continuación una captura del momento en que se está enviando el voto a una fotografía con 0 votos previos, el botón se encuentra en estado de carga pero el voto ya se ha sumado de forma optimista al contador:
Con esto quedaría terminada nuestra aplicación de galería fotográfica con votaciones, que nos ha servido para estudiar React Router (un tema complejo per se) de manera aislada.






