¡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.
Aplicación práctica en app Kanpus
En esta lección aplicaremos lo aprendido sobre React Router al proyecto del gestor de tareas kanban. Hasta ahora hemos estado trabajando sobre la pantalla de creación y modificación de tareas en un tablero en particular, pero nuestra aplicación también necesita una pantalla de inicio de sesión, una vista de los tableros disponibles y, además del panel de edición de tareas, otro para editar las listas de un tablero.
Nuestro objetivo será implementar, al menos, las rutas necesarias para cubrir el siguiente diagrama, donde una flecha gris continua denota anidación de rutas, y cada flecha discontinua indica navegación entre rutas:
Como ya conoces las reglas básicas del desarrollo con React Router, en esta lección nos centraremos en los aspectos que vamos a implementar de forma diferente y detalles más avanzados.
Como siempre, para el correcto aprovechamiento de este módulo, partimos de la base de que vas a implementar el código necesario por tu cuenta, siguiendo estas directrices, en la aplicación de ejemplo. Aunque te entregamos la solución, no te limites a verla, o no aprovecharás de verdad el curso.
Mapa de rutas
Siguiendo el diagrama descrito anteriormente, en main.jsx tendríamos este objeto enrutador:
const router = createBrowserRouter(createRoutesFromElements( <Route path='/' element={<App />} > <Route index element={<Navigate to="/login" />} /> <Route path='login' element={<PantallaLogin />} action={loginAction} /> <Route path='registro' action={registroAction} /> <Route path='tableros' element={<MisTableros />} loader={misTablerosLoader} /> <Route path='tableros/:tableroId' element={<Tablero />} loader={tableroLoader}> <Route index element={<PanelListas />} loader={tableroLoader} /> <Route path='edit' element={<EdicionListas />} loader={tableroLoader} /> </Route> </Route> ))
Puedes observar algunos componentes que aún no habíamos implementado, como PantallaLogin o MisTableros. Asimismo, en la ruta inicial / hemos utilizado un elemento <Navigate> de React Router para redirigir por defecto a la ruta de inicio de sesión. También hemos añadido directamente las funciones loader y action que vamos a implementar.
Esta vez, en el elemento raíz de React hay que renderizar el router dentro del Provider de Redux, para que toda la información del almacén esté disponible en todas las páginas:
ReactDOM.createRoot(document.getElementById('root')).render( <React.StrictMode> <Provider store={store}> <RouterProvider router={router} /> </Provider> </React.StrictMode> )
Para completar los componentes que aparecen en las rutas, tendríamos que implementar:
- Un componente
PantallaLogincon un formulario de inicio de sesión
- Un nuevo componente
MisTableroscon una lista de enlaces a los tableros individuales
- El componente
PanelListasseparado de Tablero, para mostrar las diferentes columnas de tareas
- Un nuevo componente
EdicionListascon una solaTarjetaque enumera las listas de tareas y permite modificarlas
Funciones de carga y acción
La mayor parte de las interacciones con los datos de nuestra aplicación ya están gestionadas con RTK Query y no es necesario modificarlas. Las funciones loader y action que incluyamos en las rutas se limitarán a gestionar otros aspectos como la sesión de usuario. Puesto que aún no hemos implementado esta parte de la aplicación, vamos a utilizar datos de ejemplo. Tendremos un loader para la pantalla de selección de tablero, y otro para la pantalla de tablero individual donde capturaremos el identificador del tablero:
// MisTableros.jsx export const loader = async ({ params }) => { return { user: "alice" } } // Tablero.jsx export const loader = async ({ params }) => { const id = params.tableroId const user = "alice" return { id, user } }
En la pantalla de inicio de sesión, invocaremos una función login que por ahora únicamente simula una petición con cierto retraso y, a continuación, redirigirá a la pantalla de selección de tablero.
// PantallaLogin.jsx export const action = async ({ params }) => { // Comprobar el usuario y la contraseña await login() return redirect("/tableros") }
La diferencia más importante entre cargar datos en la función loader y hacerlo en el propio componente usando la API de RTK Query consiste en que los datos de la función loader se cargarán antes de que se muestre la nueva pantalla que se está cargando, mientras que los datos de RTK Query se cargarán tras cargar la pantalla nueva y mostrarán progresivamente conforme se vayan resolviendo las peticiones. Por esto, es preferible que carguemos la mayoría de datos con RTK Query para evitar que la interfaz parezca demasiado lenta.
Ajuste de la interfaz según la ubicación actual
Por lo general, dado que React Router coloca el contenido correspondiente a la ruta actual en el elemento <Outlet>, no nos tenemos que preocupar de modificar la interfaz en función de la ubicación en que nos encontremos. Sin embargo, puede haber detalles de componentes generales que deban cambiar para, por ejemplo, dar una indicación de dónde se encuentra la persona visitante en cada momento.
Una herramienta útil en estos casos, específicamente cuando necesitemos mostrar un enlace con estado activo o inactivo en función de la ubicación, es el componente NavLink. Se trata esencialmente de un enlace similar a Link, pero con la particularidad de que su propiedad className puede recibir una función. Esta tomará dos parámetros, el primero indica si el enlace está activo (nos encontramos en la ubicación a la que apunta el propio enlace) y el segundo si esa ubicación está pendiente de carga. De esta forma, no tenemos que escribir demasiado código para disponer de enlaces de navegación que reaccionen a la ubicación actual:
// App.jsx <BarraMenu izq={ <nav> Kanpus <NavLink to="/tableros" end className={({ isActive, isPending }) => `${ui.opcion} ${isActive ? ui.active : isPending ? ui.pending : ""}`}> Mis tableros</NavLink> </nav>} dcha={ <nav> <NavLink to="/login" className={({ isActive, isPending }) => `${ui.opcion} ${isActive ? "hidden" : isPending ? ui.pending : ""}`}>Cerrar sesión</NavLink> </nav>} />
Si necesitamos realizar otro tipo de cambios en la interfaz, por ejemplo, incluir o no un botón en función de si es necesario, podemos recurrir al hook useMatch, que permite comprobar si la ruta coincide con la que se pase como parámetro path:
const match = useMatch({ path: "tableros/:id/edit", end: false })
Es importante resaltar que el patrón que proporcionemos a useMatch deberá coincidir completamente con la ubicación, salvo que indiquemos el parámetro end: false, en cuyo caso el camino deberá ser un prefijo de la ubicación. Por ejemplo, para la llamada anterior, si el navegador está en tableros/1001/edit/saved tendríamos una coincidencia, pero no la tendríamos si estuviéramos en user/tableros/1001/edit.
En la constante match tendremos un objeto que nos dará detalles acerca de la coincidencia de nuestra consulta con la ubicación, pero podemos utilizarlo directamente como si se tratase de un valor booleano:
{ match ? <OpcionLink to={`/tableros/${id}`}>Volver al tablero</OpcionLink> : <OpcionLink to="edit">Editar listas</OpcionLink> }
Forzar la navegación
En ocasiones necesitaremos redirigir al navegador a una ubicación distinta de la que tiene en el momento. Ya hemos aprendido una forma de hacerlo, utilizando el componente Navigate, que viene incluido en React Router, dentro de cualquier otro componente (se redirigirá la ubicación en cuanto se renderice este elemento). A continuación vamos a estudiar cómo conseguirlo en otras circunstancias.
En el contexto de un componente, cuando respondemos a un evento o manejamos efectos secundarios con useEffect, podemos disponer de una función que nos permita navegar a otra ubicación mediante el hook useNavigate. Un ejemplo de su uso sería redirigir a la pantalla general de elección de tablero al borrar el tablero actual:
// Tablero.jsx const navigate = useNavigate() const [peticionBorrarTablero, _] = useDeleteTableroMutation() const borrarTablero = () => { peticionBorrarTablero(id).then(() => { navigate("/tableros") }) }
Otra situación donde será muy común que redirijamos al navegador es cuando se produzca la carga de una ruta protegida (es decir, que no se pueda visitar sin haber obtenido antes una autenticación) o bien tras el envío de un formulario. En React Router, ambas circunstancias implican el uso de funciones loader y action, y el enfoque es idéntico: utilizar directamente la función redirect indicando el camino requerido. Por ejemplo, tras el registro de una nueva cuenta de usuario, se iniciará su sesión y se le redirigirá a la pantalla de tableros:
// registro.js import { redirect } from "react-router-dom" import { login } from '../utils/session' export const action = async ({ params }) => { await login() return redirect("/tableros") }
Seguridad: Gestión de cuentas de usuario y sesiones
Uno de los aspectos cruciales en prácticamente cualquier aplicación web es la gestión de sesión, es decir, que se pueda autenticar la persona que vaya a utilizarla y que dicha autenticación persista durante un tiempo en el navegador para poder autorizar accesos a partes de la aplicación y datos durante la navegación.. De esta forma, la persona visitante puede consultar y gestionar su información habiendo iniciado sesión una única vez.
Puesto que la verificación de las credenciales y la emisión de un token de sesión es una tarea propia del backend, en la interfaz de la aplicación no vamos a realizar este trabajo. Simplemente, simularemos que realizamos una petición al servidor y que este acepta o rechaza el inicio de sesión. Nos centraremos en la gestión de la información de sesión por parte de React, en el frontend, y en la protección de las páginas que requieran que la persona usuaria haya iniciado sesión previamente.
Con lo que respecta a la implementación de la gestión de sesiones en React, hay algunas soluciones que nos permitirían delegarla en un módulo que importemos, como react-session-api o redux-react-session. Aún así, como es una tarea sencilla y disponemos de React Router para controlar la información que se solicita y se carga cuando se efectúa navegación entre rutas, no será muy complicado construir una solución propia, que es lo que vamos a hacer.
Las principales funcionalidades que debemos implementar son:
- Registro de nuevas cuentas de usuario
- Inicio de sesión
- Cierre de sesión
- Consultar la cuenta que tiene iniciada sesión
Definición de la API
El primer paso consistirá en definir una serie de funciones que permitan a la aplicación interactuar con el servidor, posibilitando así la operación con las cuentas de usuario y las sesiones. Puesto que el backend de nuestra aplicación está simulado con json-server, podemos añadir una lista de “cuentas” donde se almacenen los datos de cada cuenta de usuario:
"cuentas": [ { "id": "alice", "email": "alice@example.org", "pass": "fauxpass" }, ]
Evidentemente, en una aplicación real estos datos se almacenarían de forma segura en una base de datos y la contraseña nunca se almacenaría tal cual, sino procesada mediante algoritmos criptográficos de hash y sal. Para nuestro caso, podemos asumir que el backend está realizando un trabajo adecuado aunque solo lo estemos simulando.
En el frontend no es necesario preocuparse de realizar hash a la contraseña ya que se asume que la comunicación será segura gracias a HTTPS y, precisamente, el objetivo del hash será impedir que la información que envía el cliente esté directamente almacenada en la base de datos, por si esta se viera comprometida.
Nuestra sencilla API constará de cuatro métodos:
login, que recibe un nombre de usuario y una contraseña y los consulta en el servidor, activando la sesión en caso de que exista una coincidencia.signup, para dar de alta una nueva cuenta, salvo en el caso de que ya exista otra cuenta con el mismo nombre, en cuyo caso el servidor dará error.logout, que limpiará la sesióngetUser, que consultará la cuenta de usuario actual, si es que existe una sesión activa.
La implementación sería la siguiente:
// features/sesion/api.js export default { login: async (user, password) => { const coincidencias = await fetch(`http://localhost:3000/cuentas/?id=${user}&pass=${password}`).then(r => r.json()) // simular latencia de la petición await new Promise(callback => setTimeout(callback, 500)) if (coincidencias.length > 0) { const userData = coincidencias[0] localStorage.setItem('sesion', JSON.stringify({ sesionIniciada: true, cuentaUsuario: user })) return true } return false }, signup: async (user, password, email) => { const result = await fetch("http://localhost:3000/cuentas/", { method: "POST", headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ id: user, pass: password, email }) }).then(r => true).catch(r => false) return result }, logout: () => new Promise(callback => { localStorage.setItem('sesion', JSON.stringify({ sesionIniciada: false })) // simular latencia de la petición setTimeout(callback, 500) }), getUser: () => JSON.parse(localStorage.getItem("sesion"))?.cuentaUsuario }
Puedes observar que la información de sesión se ha almacenado en el localStorage del navegador. Las opciones alternativas serían sessionStorage, que es menos viable porque no es persistente entre diferentes pestañas o ventanas, y el almacenamiento en cookies, que es también válido.
Protección de páginas
Además de disponer de una forma de gestionar las sesiones, necesitamos indicar qué páginas deben estar protegidas, a las que tan solo se puede acceder tras la autenticación.
Para ello, crearemos un nuevo archivo features/sesion/PaginaProtegida.jsx que incluirá:
- Un componente
PaginaProtegidaque puede contener a cualquier otro componente - Una función de carga, de forma que si este componente se renderiza, se comprobará previamente que existe una sesión activa.
La implementación es muy sencilla, y se basa en la función loader que hemos estudiado antes. En este caso, si no existe una sesión activa, se redirigirá a la página de inicio de sesión:
// features/sesion/PaginaProtegida.jsx import { Outlet, redirect } from "react-router-dom" import Sesion from './api' export const loader = (params) => { const user = Sesion.getUser() if (user) { return { user } } else { return redirect("/cuenta/acceder") } } export const PaginaProtegida = () => <Outlet />
Para aplicar este comportamiento a las páginas que deseemos proteger, debemos recordar el mecanismo para anidar rutas de React Router. En este caso, no queremos que la URL en sí se vea modificada, por lo que no indicaremos un path en el elemento <Route> correspondiente. Lo que haremos será especificar:
- Mediante
element, el elementoPaginaProtegidaque acabamos de definir. - Con el parámetro
loader, la función de carga definida en el componente anterior. - Un identificador de la ruta, mediante el parámetro
id, que nos servirá más adelante para recuperar el nombre de usuario devuelto por la función de carga:
// main.jsx <Route element={<PaginaProtegida />} loader={sesionLoader} id="sesion"> <Route path='tableros' element={<MisTableros />} /> <Route path='tableros/:tableroId' element={<Tablero />} loader={tableroLoader}> <Route index element={<PanelListas />} loader={tableroLoader} /> <Route path='edit' element={<EdicionListas />} loader={tableroLoader} /> </Route> </Route>
Fíjate en que únicamente hemos protegido las páginas que mostrarían datos del usuario, y no así rutas que deben estar abiertas como / o /login.
La ruta a la que apunta la redirección, /cuenta/acceder, no la hemos definido todavía, así que podrás comprobar que tratar de acceder a cualquier ruta de tableros te lleva a un error 404. Enseguida implementaremos las rutas necesarias para que esto deje de ocurrir.
Inicio de sesión
Vamos a separar las rutas de inicio de sesión y de registro para trabajar mejor con React Router, así como para mostrar una interfaz menos compleja. Para ello, tendremos una ruta /cuenta donde se renderizará el componente PantallaAcceso que tienes a continuación:
// features/sesion/PantallaAcceso.jsx const PantallaAcceso = () => { return ( <div className="p-6 m-auto max-w-2xl"> <p className="text-xl text-center pb-6">Con una cuenta de usuario podrás guardar tus tableros de tareas y consultarlos en cualquier dispositivo</p> <div className="flex md:flex-row flex-col gap-6"> <div className="grow shrink"> <Outlet /> </div> </div> </div> ) }
A su vez, dentro de la ruta /cuenta anidaremos todas las rutas que tengan que ver con operaciones sobre cuentas de usuario. En particular, movemos el componente FormularioLogin a un nuevo fichero features/sesion/Login.jsx y le asociamos la acción correspondiente. Esta consistirá en recurrir a la API para comprobar si el nombre de usuario y la contraseña son correctos, en cuyo caso redirigirá la navegación a la pantalla inicial de tableros. Si no se ha podido iniciar la sesión, se devolverá un objeto indicándolo:
// features/sesion/Login.jsx export const action = async ({ request }) => { // Extraer los datos del formulario enviado const data = await request.formData() // Comprobar el usuario y la contraseña const success = await Sesion.login(data.get("usuarioLogin"), data.get("passLogin")) if (success) { return redirect("/tableros") } else { return { inicioFallido: true } } }
Si no te resulta familiar el método formData(), puedes consultar la documentación de MDN.
Los datos que se devuelven en caso de que la acción no redirija a otra ruta son accesibles desde los componentes mediante el hook useActionData, como puedes ver en el listado siguiente. En este, el componente FormularioLogin muestra un error en caso de que no haya tenido éxito el inicio de sesión:
const FormularioLogin = () => { const inicioFallido = useActionData()?.inicioFallido // ... <Tarjeta top="Ya tengo una cuenta" bottom={<> <BotonPrimario type="submit" className="w-full">Iniciar sesión</BotonPrimario> {inicioFallido && <p className="text-center text-red-500 pt-3">El usuario o la contraseña son incorrectos.</p>} <p className="pt-3 text-center">¿No tienes una cuenta? <Link to="../crear" className="text-cyan-800 dark:text-cyan-300">Crea una nueva</Link></p> </>}>
Para poder acceder a la pantalla de inicio de sesión, ya solo nos queda definir las rutas adecuadas en el fichero main.jsx, sustituyendo la ruta previa de /login:
// main.jsx import PantallaAcceso from './features/sesion/PantallaAcceso' import FormularioLogin, { action as loginAction } from './features/sesion/Login' const router = createBrowserRouter(createRoutesFromElements( <Route path='/' element={<App />} > <Route index element={<Navigate to="cuenta" />} /> <Route path='cuenta' element={<PantallaAcceso />}> <Route index element={<Navigate to="acceder" />} /> <Route path='acceder' element={<FormularioLogin />} action={loginAction} /> </Route> {/* resto de rutas */} </Route> ))
A partir de ahora, siempre que naveguemos a la raíz de la aplicación se redirigirá automáticamente a /cuenta/acceder, gracias a los elementos <Navigate> que hemos declarado en las rutas index.












