React Router es una biblioteca fundamental en el ecosistema de React, diseñada para manejar el enrutamiento en aplicaciones de una sola página (Single Page Applications, o SPAs). En el contexto del desarrollo web moderno, las SPAs han ganado popularidad por su capacidad para ofrecer una experiencia de usuario fluida y dinámica, similar a una aplicación de escritorio.
El Problema Tradicional: Tradicionalmente, las páginas web recargan todo el contenido cada vez que el usuario navega a una sección diferente (haciendo clic en un link). Este proceso:
La Solución de las SPAs: Las Single Page Applications resuelven este problema ofreciendo una experiencia más fluida, donde el contenido se carga dinámicamente sin necesidad de refrescar toda la página. Esto se logra mediante JavaScript, que gestiona la carga de contenido y la actualización de la interfaz de usuario de manera selectiva.
El Desafío Específico que Resuelve React Router: Dentro de las SPAs, surge un nuevo desafío: cómo gestionar la navegación y el mantenimiento del historial del navegador de manera que la aplicación se comporte como una página web tradicional en términos de navegación.
Sin React Router, una SPA tendría estos problemas:
Beneficios de React Router:
Antes de comenzar, necesitas tener instalado Node.js en tu sistema, ya que utilizaremos npm (Node Package Manager) para gestionar las dependencias del proyecto.
Vite es un bundler (empaquetador) y servidor de desarrollo moderno que ofrece:
Comando para crear el proyecto:
npm create vite@latest name-of-your-project -- --template react
Este comando:
Seguir con la configuración:
cd <your new project directory>
npm install react-router-dom # Biblioteca principal de React Router
npm install localforage match-sorter sort-by # Dependencias solo para el tutorial
npm run dev # Inicia el servidor de desarrollo
Explicación de las dependencias del tutorial:
localforage: Biblioteca para almacenamiento local en el navegador (simula una base de datos)match-sorter: Utilidad para filtrar y ordenar listassort-by: Utilidad para ordenar arrays de objetosEstructura de carpetas resultante:
src/
├── contacts.js # Módulo de datos (simula una API)
├── index.css # Estilos globales pre-escritos
└── main.jsx # Punto de entrada de la aplicación
Archivos a eliminar:
App.js (lo reemplazaremos con nuestro sistema de rutas)assets (no necesaria para el tutorial)¿Por qué esta estructura? Mantenemos el proyecto simple y enfocado en React Router, eliminando elementos que no son necesarios para aprender los conceptos fundamentales de enrutamiento.
El Browser Router es el componente principal que habilita el enrutamiento del lado del cliente. Utiliza la API de History del navegador para mantener la UI sincronizada con la URL.
import * as React from "react";
import * as ReactDOM from "react-dom/client";
import {
createBrowserRouter,
RouterProvider,
} from "react-router-dom";
import "./index.css";
const router = createBrowserRouter([
{
path: "/",
element: <div>Hello world!</div>,
},
]);
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
);
Análisis línea por línea:
React y ReactDOM: Bibliotecas fundamentales de ReactcreateBrowserRouter: Función para crear la configuración de rutasRouterProvider: Componente que provee el router a toda la aplicaciónconst router = createBrowserRouter([...])
path y element{
path: "/",
element: <div>Hello world!</div>,
}
path: "/": Define que esta ruta responde a la URL raízelement: El componente React que se renderizará en esta ruta<RouterProvider router={router} />
Concepto: Root Route (Ruta Raíz) La ruta raíz es especial porque:
Un Layout Component es un componente que:
<Outlet /> para renderizar rutas hijasmkdir src/routes
touch src/routes/root.jsx
Esta organización:
export default function Root() {
return (
<>
<div id="sidebar">
<h1>React Router Contacts</h1>
<div>
{/* Formulario de búsqueda */}
<form id="search-form" role="search">
<input
id="q"
aria-label="Search contacts"
placeholder="Search"
type="search"
name="q"
/>
<div id="search-spinner" aria-hidden hidden={true} />
<div className="sr-only" aria-live="polite"></div>
</form>
{/* Botón para crear nuevo contacto */}
<form method="post">
<button type="submit">New</button>
</form>
</div>
{/* Navegación con lista de contactos */}
<nav>
<ul>
<li>
<a href={`/contacts/1`}>Your Name</a>
</li>
<li>
<a href={`/contacts/2`}>Your Friend</a>
</li>
</ul>
</nav>
</div>
{/* Área de detalle donde se mostrarán las rutas hijas */}
<div id="detail"></div>
</>
);
}
Análisis de elementos clave:
<>...</>):
role="search": Atributo de accesibilidadaria-label: Descripción para lectores de pantallamethod="post": Indica que enviará datos (no solo navegación)import Root from "./routes/root";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
},
]);
Resultado visual: Tu aplicación ahora muestra el layout completo con sidebar y área de detalle, aunque los links aún no funcionan correctamente con enrutamiento del lado del cliente.
El manejo proactivo de errores:
Si haces clic en los links del sidebar (/contacts/1), verás:
¿Por qué ocurre este error?
La ruta /contacts/1 no está definida en nuestra configuración de router, por lo que React Router lanza un error 404.
// src/error-page.jsx
import { useRouteError } from "react-router-dom";
export default function ErrorPage() {
const error = useRouteError();
console.error(error);
return (
<div id="error-page">
<h1>Oops!</h1>
<p>Sorry, an unexpected error has occurred.</p>
<p>
<i>{error.statusText || error.message}</i>
</p>
</div>
);
}
Análisis del código:
useRouteError() Hook:
statusText, message, statusconsole.error(error):
{error.statusText || error.message}
statusText: Texto descriptivo del error HTTP (ej: “Not Found”)message: Mensaje de error de JavaScript||: Muestra el primero que esté disponibleimport ErrorPage from "./error-page";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
},
]);
Cómo funciona errorElement:
errorElement más cercanoTipos de errores comunes que captura:
Una aplicación real necesita múltiples rutas para diferentes vistas. En nuestro caso:
/ - Lista de contactos (Root)/contacts/:contactId - Detalles de un contacto específico// src/routes/contact.jsx
import { Form } from "react-router-dom";
export default function Contact() {
const contact = {
first: "Your",
last: "Name",
avatar: "https://robohash.org/you.png?size=200x200",
twitter: "your_handle",
notes: "Some notes",
favorite: true,
};
return (
<div id="contact">
<div>
<img
key={contact.avatar}
src={
contact.avatar ||
`https://robohash.org/${contact.id}.png?size=200x200`
}
/>
</div>
<div>
<h1>
{contact.first || contact.last ? (
<>
{contact.first} {contact.last}
</>
) : (
<i>No Name</i>
)}{" "}
<Favorite contact={contact} />
</h1>
{contact.twitter && (
<p>
<a
target="_blank"
href={`https://twitter.com/${contact.twitter}`}
>
{contact.twitter}
</a>
</p>
)}
{contact.notes && <p>{contact.notes}</p>}
<div>
<Form action="edit">
<button type="submit">Edit</button>
</Form>
<Form
method="post"
action="destroy"
onSubmit={(event) => {
if (
!confirm(
"Please confirm you want to delete this record."
)
) {
event.preventDefault();
}
}}
>
<button type="submit">Delete</button>
</Form>
</div>
</div>
</div>
);
}
function Favorite({ contact }) {
const favorite = contact.favorite;
return (
<Form method="post">
<button
name="favorite"
value={favorite ? "false" : "true"}
aria-label={
favorite
? "Remove from favorites"
: "Add to favorites"
}
>
{favorite ? "★" : "☆"}
</button>
</Form>
);
}
Análisis detallado:
const contact = { ... }
contact.avatar || `https://robohash.org/${contact.id}.png?size=200x200`
{contact.first || contact.last ? (...) : (<i>No Name</i>)}
<Form> de React Router:
<form> HTML pero con características adicionalesaction="edit": Define una acción relativa a la ruta actualonSubmit={(event) => {
if (!confirm("Please confirm...")) {
event.preventDefault();
}
}}
event.preventDefault(): Cancela el envío si el usuario cancelaaria-label para accesibilidadimport Contact from "./routes/contact";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
},
{
path: "contacts/:contactId",
element: <Contact />,
},
]);
Análisis de la nueva ruta:
path: "contacts/:contactId"
:contactId: Segmento dinámico (puede ser cualquier valor)/contacts/1, /contacts/abc, /contacts/123Las rutas anidadas permiten:
Visualización conceptual:
Root Layout (siempre visible)
├── Sidebar
├── Header
└── <Outlet> ← Aquí se renderizan las rutas hijas
├── Home
├── Contact Details
└── Edit Contact
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
children: [ // ← Rutas hijas
{
path: "contacts/:contactId",
element: <Contact />,
},
],
},
]);
Cambios clave:
children al objeto de ruta raízchildren es un array de configuraciones de rutasJerarquía de paths resultante:
/contacts/:contactId/contacts/:contactId<Outlet>El <Outlet> es el punto donde se renderizarán las rutas hijas.
import { Outlet } from "react-router-dom";
export default function Root() {
return (
<>
<div id="sidebar">
{/* Contenido del sidebar */}
</div>
<div id="detail">
<Outlet /> {/* ← Las rutas hijas se renderizan aquí */}
</div>
</>
);
}
Cómo funciona <Outlet>:
children de la ruta actualelement correspondiente en la posición del <Outlet>/contacts/1<Root><Outlet> se reemplaza por <Contact>Concepto importante: Composición vs Inclusión
Actualmente, los links en el sidebar usan <a href>:
<a href={`/contacts/1`}>Your Name</a>
¿Qué ocurre al hacer clic?
Puedes verificarlo:
<Link>React Router proporciona el componente <Link> que:
import { Outlet, Link } from "react-router-dom";
export default function Root() {
return (
<>
<div id="sidebar">
<nav>
<ul>
<li>
<Link to={`contacts/1`}>Your Name</Link>
</li>
<li>
<Link to={`contacts/2`}>Your Friend</Link>
</li>
</ul>
</nav>
</div>
<div id="detail">
<Outlet />
</div>
</>
);
}
Diferencias clave entre <a> y <Link>:
| Característica | <a href> |
<Link to> |
|---|---|---|
| Recarga de página | Sí | No |
| Petición al servidor | Completa | Solo datos si es necesario |
| Mantiene estado | No | Sí |
| Velocidad | Lenta | Instantánea |
| History API | Básica | Avanzada |
| Previene default | Manual | Automático |
¿Cómo funciona internamente <Link>?
event.preventDefault() // Previene navegación default
window.history.pushState({}, '', newURL)
<Outlet> con el nuevo componenteVerificación en DevTools:
En la mayoría de aplicaciones web, existe una relación natural:
| Segmento de URL | Componente | Datos Necesarios |
|---|---|---|
/ |
<Root> |
Lista de todos los contactos |
/contacts/:id |
<Contact> |
Detalles del contacto específico |
React Router aprovecha este acoplamiento natural con el concepto de loaders.
Un loader es:
Ventajas sobre fetch tradicional en useEffect:
❌ Enfoque tradicional (useEffect):
function Component() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/data')
.then(res => res.json())
.then(data => {
setData(data);
setLoading(false);
});
}, []);
if (loading) return <Spinner />;
return <div>{data}</div>;
}
Problemas:
✅ Enfoque con loaders:
export async function loader() {
const data = await fetch('/api/data');
return data;
}
function Component() {
const data = useLoaderData();
return <div>{data}</div>; // Datos siempre disponibles
}
Ventajas:
Paso 1: Crear y exportar el loader
// src/routes/root.jsx
import { Outlet, Link } from "react-router-dom";
import { getContacts } from "../contacts";
export async function loader() {
const contacts = await getContacts();
return { contacts };
}
export default function Root() {
// ... código del componente
}
Análisis del loader:
export async function loader()
const contacts = await getContacts();
getContacts() simula una llamada a APIawait espera a que se resuelva la promesareturn { contacts };
Paso 2: Configurar el loader en la ruta
import Root, { loader as rootLoader } from "./routes/root";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
loader: rootLoader, // ← Loader configurado
children: [
{
path: "contacts/:contactId",
element: <Contact />,
},
],
},
]);
Nomenclatura importante:
import Root, { loader as rootLoader }
loader a rootLoader para evitar conflictos[ruteName]LoaderPaso 3: Acceder a los datos con useLoaderData
import {
Outlet,
Link,
useLoaderData,
} from "react-router-dom";
export default function Root() {
const { contacts } = useLoaderData();
return (
<>
<div id="sidebar">
<h1>React Router Contacts</h1>
<nav>
{contacts.length ? (
<ul>
{contacts.map((contact) => (
<li key={contact.id}>
<Link to={`contacts/${contact.id}`}>
{contact.first || contact.last ? (
<>
{contact.first} {contact.last}
</>
) : (
<i>No Name</i>
)}{" "}
{contact.favorite && <span>★</span>}
</Link>
</li>
))}
</ul>
) : (
<p>
<i>No contacts</i>
</p>
)}
</nav>
</div>
<div id="detail">
<Outlet />
</div>
</>
);
}
Análisis del renderizado:
const { contacts } = useLoaderData();
{contacts.length ? (...) : (...)}
{contacts.map((contact) => (...))}
<li> por cada contactokey={contact.id}: Clave única requerida por React{contact.favorite && <span>★</span>}
Aspecto revolucionario:
// NO necesitas hacer esto manualmente:
const [contacts, setContacts] = useState([]);
useEffect(() => {
fetch('/api/contacts')
.then(res => res.json())
.then(data => setContacts(data));
}, []);
React Router mantiene automáticamente los datos sincronizados con la UI:
React Router emula la navegación con formularios HTML como primitiva para mutación de datos, siguiendo el modelo web tradicional pero con las ventajas del renderizado del lado del cliente.
Comportamiento nativo del navegador:
<form method="get" action="/search">
<input name="q" value="react" />
<button type="submit">Search</button>
</form>
/search?q=react<form method="post" action="/contacts">
<input name="name" value="John" />
<button type="submit">Create</button>
</form>
/contactsLa innovación de React Router:
Paso 1: Exportar una action en root.jsx
import {
Outlet,
Link,
useLoaderData,
Form,
} from "react-router-dom";
import { getContacts, createContact } from "../contacts";
export async function action() {
const contact = await createContact();
return { contact };
}
export default function Root() {
const { contacts } = useLoaderData();
return (
<>
<div id="sidebar">
<h1>React Router Contacts</h1>
<div>
<Form method="post">
<button type="submit">New</button>
</Form>
</div>
{/* resto del código */}
</div>
</>
);
}
Cambios importantes:
<Form>:
import { Form } from "react-router-dom";
<form> HTML pero con client-side routingexport async function action() {
const contact = await createContact();
return { contact };
}
createContact() crea un contacto vacío<Form>:
<Form method="post">
<button type="submit">New</button>
</Form>
method="post": Indica mutación de datosaction prop: envía a la ruta actualPaso 2: Configurar la action en la ruta
import Root, {
loader as rootLoader,
action as rootAction,
} from "./routes/root";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
loader: rootLoader,
action: rootAction, // ← Action configurada
children: [
{
path: "contacts/:contactId",
element: <Contact />,
},
],
},
]);
Cuando haces clic en “New”:
<Form> detecta el submitevent.preventDefault() previene envío tradicional/)rootAction)rootLoaderuseLoaderData() recibe los datos actualizadosLa magia del modelo:
// NO necesitas escribir código como este:
const [contacts, setContacts] = useState([]);
async function handleCreateContact() {
const newContact = await createContact();
setContacts([...contacts, newContact]); // Actualización manual
}
React Router lo hace automáticamente:
Loaders vs Actions:
| Aspecto | Loaders | Actions |
|---|---|---|
| Propósito | Leer datos | Escribir/modificar datos |
| Cuándo se ejecutan | Al navegar a la ruta | Al enviar formularios |
| Método HTTP equivalente | GET | POST, PUT, DELETE, PATCH |
| Efecto secundario | Revalida datos después de actions | Ninguno |
Actualmente, al hacer clic en un contacto, vemos datos hardcoded. Necesitamos cargar datos reales basados en el ID de la URL.
Revisando la configuración de ruta:
{
path: "contacts/:contactId",
element: <Contact />,
}
El segmento :contactId:
: indica que es dinámico/contacts/1 → contactId = “1”/contacts/abc → contactId = “abc”/contacts/user-123 → contactId = “user-123”Los parámetros se pasan automáticamente al loader a través del objeto params.
Paso 1: Crear el loader en contact.jsx
// src/routes/contact.jsx
import { Form, useLoaderData } from "react-router-dom";
import { getContact } from "../contacts";
export async function loader({ params }) {
const contact = await getContact(params.contactId);
return { contact };
}
export default function Contact() {
const { contact } = useLoaderData();
return (
<div id="contact">
<div>
<img
key={contact.avatar}
src={
contact.avatar ||
`https://robohash.org/${contact.id}.png?size=200x200`
}
/>
</div>
<div>
<h1>
{contact.first || contact.last ? (
<>
{contact.first} {contact.last}
</>
) : (
<i>No Name</i>
)}{" "}
<Favorite contact={contact} />
</h1>
{contact.twitter && (
<p>
<a
target="_blank"
href={`https://twitter.com/${contact.twitter}`}
>
{contact.twitter}
</a>
</p>
)}
{contact.notes && <p>{contact.notes}</p>}
<div>
<Form action="edit">
<button type="submit">Edit</button>
</Form>
<Form
method="post"
action="destroy"
onSubmit={(event) => {
if (
!confirm(
"Please confirm you want to delete this record."
)
) {
event.preventDefault();
}
}}
>
<button type="submit">Delete</button>
</Form>
</div>
</div>
</div>
);
}
function Favorite({ contact }) {
const favorite = contact.favorite;
return (
<Form method="post">
<button
name="favorite"
value={favorite ? "false" : "true"}
aria-label={
favorite
? "Remove from favorites"
: "Add to favorites"
}
>
{favorite ? "★" : "☆"}
</button>
</Form>
);
}
Análisis del loader:
export async function loader({ params }) {
params contiene los parámetros de URLconst contact = await getContact(params.contactId);
params.contactId coincide con el nombre :contactId en la rutausers/:userId, sería params.userId/contacts/123params.contactId = “123”getContact("123") busca el contactoPaso 2: Configurar el loader en la ruta
import Contact, {
loader as contactLoader,
} from "./routes/contact";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
loader: rootLoader,
action: rootAction,
children: [
{
path: "contacts/:contactId",
element: <Contact />,
loader: contactLoader, // ← Loader configurado
},
],
},
]);
Además de params, los loaders reciben:
export async function loader({ params, request }) {
// params: { contactId: "123" }
// request: objeto Request de la Web API
const url = new URL(request.url);
const searchQuery = url.searchParams.get('q');
// Ejemplo: /contacts/123?q=john
// params.contactId = "123"
// searchQuery = "john"
}
Propiedades útiles del objeto request:
request.url: URL completa de la peticiónrequest.method: Método HTTP (GET, POST, etc.)request.headers: Headers de la peticiónrequest.signal: AbortSignal para cancelar peticionesAhora crearemos una ruta para editar contactos existentes: /contacts/:contactId/edit
Paso 1: Crear el componente edit.jsx
// src/routes/edit.jsx
import { Form, useLoaderData } from "react-router-dom";
export default function EditContact() {
const { contact } = useLoaderData();
return (
<Form method="post" id="contact-form">
<p>
<span>Name</span>
<input
placeholder="First"
aria-label="First name"
type="text"
name="first"
defaultValue={contact?.first}
/>
<input
placeholder="Last"
aria-label="Last name"
type="text"
name="last"
defaultValue={contact?.last}
/>
</p>
<label>
<span>Twitter</span>
<input
type="text"
name="twitter"
placeholder="@jack"
defaultValue={contact?.twitter}
/>
</label>
<label>
<span>Avatar URL</span>
<input
placeholder="https://example.com/avatar.jpg"
aria-label="Avatar URL"
type="text"
name="avatar"
defaultValue={contact?.avatar}
/>
</label>
<label>
<span>Notes</span>
<textarea
name="notes"
defaultValue={contact?.notes}
rows={6}
/>
</label>
<p>
<button type="submit">Save</button>
<button type="button">Cancel</button>
</p>
</Form>
);
}
Análisis de características importantes:
<input
name="first"
defaultValue={contact?.first}
/>
defaultValue: Valor inicial, pero el input puede cambiarvalue: Input controlado (requiere onChange)defaultValue?.):
defaultValue={contact?.first}
contact es null/undefinedcontact && contact.first<input name="first" />
<input name="last" />
<input name="twitter" />
Paso 2: Agregar la ruta al router
import EditContact from "./routes/edit";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
loader: rootLoader,
action: rootAction,
children: [
{
path: "contacts/:contactId",
element: <Contact />,
loader: contactLoader,
},
{
path: "contacts/:contactId/edit",
element: <EditContact />,
loader: contactLoader, // ← Reutiliza el mismo loader
},
],
},
]);
Nota sobre reutilización de loaders:
contact y edit) necesitan los mismos datosPaso 1: Crear la action en edit.jsx
import {
Form,
useLoaderData,
redirect,
} from "react-router-dom";
import { updateContact } from "../contacts";
export async function action({ request, params }) {
const formData = await request.formData();
const updates = Object.fromEntries(formData);
await updateContact(params.contactId, updates);
return redirect(`/contacts/${params.contactId}`);
}
export default function EditContact() {
// ... código del componente
}
Análisis detallado de la action:
const formData = await request.formData();
request: objeto Request de la Web APIformData(): método que retorna una PromiseformData.get("first") // Obtiene un valor
formData.getAll("tags") // Obtiene array de valores
formData.has("email") // Verifica existencia
formData.entries() // Itera sobre entradas
const updates = Object.fromEntries(formData);
Object.fromEntries(): Convierte iterables a objeto// FormData:
// first: "John"
// last: "Doe"
// twitter: "@johndoe"
// Resultado:
{
first: "John",
last: "Doe",
twitter: "@johndoe"
}
await updateContact(params.contactId, updates);
params.contactId: ID del contacto desde la URLupdates: Objeto con los campos actualizadosreturn redirect(`/contacts/${params.contactId}`);
Paso 2: Configurar la action
import EditContact, {
action as editAction,
} from "./routes/edit";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
loader: rootLoader,
action: rootAction,
children: [
{
path: "contacts/:contactId",
element: <Contact />,
loader: contactLoader,
},
{
path: "contacts/:contactId/edit",
element: <EditContact />,
loader: contactLoader,
action: editAction, // ← Action configurada
},
],
},
]);
Cuando el usuario guarda el formulario:
<Form method="post"> intercepta el submitnameeditActionrequest.formData() obtiene los datosObject.fromEntries() los convierte a objetoupdateContact() actualiza la base de datos/storageredirect() retorna Response de redirección/contacts/:contactIdrootLoader: Actualiza lista de contactoscontactLoader: Actualiza detalles del contactoWeb Tradicional (sin JavaScript):
1. Usuario envía formulario
2. Servidor procesa
3. Servidor responde con HTML completo
4. Página se recarga completamente
5. Se pierde scroll position y estado
React Router (con client-side routing):
1. Usuario envía formulario
2. Action procesa (cliente o servidor)
3. Revalidación de datos
4. Actualización selectiva de UI
5. Se mantiene scroll position y estado
Ventajas del enfoque de React Router:
Cuando creamos un nuevo contacto:
Sería mejor redirigir automáticamente al formulario de edición del nuevo contacto.
// src/routes/root.jsx
import {
Outlet,
Link,
useLoaderData,
Form,
redirect, // ← Importar redirect
} from "react-router-dom";
import { getContacts, createContact } from "../contacts";
export async function action() {
const contact = await createContact();
return redirect(`/contacts/${contact.id}/edit`); // ← Redirección
}
Cambios:
redirect de React Router/contacts/[nuevo-id]/editResultado:
Concepto: UX fluida Este patrón es común en aplicaciones CRUD:
Con múltiples contactos en la lista, no es obvio cuál estamos viendo actualmente.
React Router proporciona <NavLink>, una versión mejorada de <Link> con capacidades de styling activo.
// src/routes/root.jsx
import {
Outlet,
NavLink, // ← Cambiar de Link a NavLink
useLoaderData,
Form,
redirect,
} from "react-router-dom";
export default function Root() {
const { contacts } = useLoaderData();
return (
<>
<div id="sidebar">
<nav>
{contacts.length ? (
<ul>
{contacts.map((contact) => (
<li key={contact.id}>
<NavLink
to={`contacts/${contact.id}`}
className={({ isActive, isPending }) =>
isActive
? "active"
: isPending
? "pending"
: ""
}
>
{contact.first || contact.last ? (
<>
{contact.first} {contact.last}
</>
) : (
<i>No Name</i>
)}{" "}
{contact.favorite && <span>★</span>}
</NavLink>
</li>
))}
</ul>
) : (
<p>
<i>No contacts</i>
</p>
)}
</nav>
</div>
<div id="detail">
<Outlet />
</div>
</>
);
}
Análisis del NavLink:
className={({ isActive, isPending }) => ...}
Estados disponibles:
isActive:
true: La URL actual coincide con el to del linkfalse: No coincide/contacts/1, link a contacts/1 → isActive = trueisPending:
true: El link fue clickeado, datos están cargandofalse: No hay navegación pendiente o ya cargóisActive ? "active" : isPending ? "pending" : ""
a.active {
background-color: #e3e3e3;
color: #000;
}
a.pending {
color: #999;
}
Ventajas de NavLink sobre Link:
Al navegar entre contactos, especialmente con conexión lenta:
React Router expone el estado de navegación a través del hook useNavigation.
// src/routes/root.jsx
import {
Outlet,
NavLink,
useLoaderData,
Form,
redirect,
useNavigation, // ← Nuevo import
} from "react-router-dom";
export default function Root() {
const { contacts } = useLoaderData();
const navigation = useNavigation(); // ← Usar el hook
return (
<>
<div id="sidebar">
{/* código del sidebar */}
</div>
<div
id="detail"
className={
navigation.state === "loading" ? "loading" : ""
}
>
<Outlet />
</div>
</>
);
}
Análisis del código:
const navigation = useNavigation();
Estados posibles de navigation.state:
“idle”:
“loading”:
“submitting”:
className={navigation.state === "loading" ? "loading" : ""}
#detail.loading {
opacity: 0.25;
transition: opacity 200ms;
transition-delay: 200ms;
}
opacity: 0.25: Atenúa el contenidotransition: 200ms: Animación suavetransition-delay: 200ms: Evita flicker en cargas rápidas¿Por qué el delay?
transition-delay: 200ms;
const navigation = useNavigation();
// Estado actual
navigation.state // "idle" | "loading" | "submitting"
// Información de la navegación
navigation.location // Location object de destino
navigation.formData // FormData si es submit de formulario
navigation.formAction // Action URL del formulario
navigation.formMethod // Método del formulario ("get" | "post")
Ejemplos de uso avanzado:
// Mostrar spinner global
{navigation.state === "loading" && <GlobalSpinner />}
// Deshabilitar form durante submit
<button
disabled={navigation.state === "submitting"}
>
{navigation.state === "submitting" ? "Saving..." : "Save"}
</button>
// Barra de progreso
{navigation.state !== "idle" && <ProgressBar />}
Aunque no se implementa en este tutorial, useNavigation también permite crear Optimistic UI:
function ContactList() {
const navigation = useNavigation();
const contacts = useLoaderData();
// Mostrar datos optimistas durante submit
const displayContacts = navigation.formData
? [...contacts, parseFormData(navigation.formData)]
: contacts;
return displayContacts.map(contact => ...);
}
El tutorial menciona algo importante:
“Note that our data model (src/contacts.js) has a clientside cache, so navigating to the same contact is fast the second time.”
¿Qué significa esto?
contacts.js implementa caché en memoriaComportamiento de React Router:
Navegación: /contacts/1 → /contacts/2
- Re-ejecuta loader de contact (cambió)
- NO re-ejecuta loader de root (no cambió)
El tutorial continúa con temas avanzados:
:paramName en paths{ params, request }function App() {
const [page, setPage] = useState('home');
const [contacts, setContacts] = useState([]);
const [currentContact, setCurrentContact] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
fetchContacts();
}, []);
async function fetchContacts() {
setLoading(true);
const data = await fetch('/api/contacts');
setContacts(data);
setLoading(false);
}
async function createContact() {
await fetch('/api/contacts', { method: 'POST' });
await fetchContacts(); // Re-cargar manualmente
setPage('list');
}
function navigateTo(page, contactId) {
setPage(page);
if (contactId) {
setCurrentContact(contacts.find(c => c.id === contactId));
}
}
return (
<div>
{page === 'list' && <ContactList contacts={contacts} />}
{page === 'detail' && <ContactDetail contact={currentContact} />}
{page === 'edit' && <ContactEdit contact={currentContact} />}
</div>
);
}
Problemas:
// main.jsx
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
loader: rootLoader,
action: rootAction,
children: [
{
path: "contacts/:contactId",
element: <Contact />,
loader: contactLoader,
},
{
path: "contacts/:contactId/edit",
element: <EditContact />,
loader: contactLoader,
action: editAction,
},
],
},
]);
// Componentes simples sin lógica de navegación
function ContactList() {
const { contacts } = useLoaderData(); // Datos automáticos
return contacts.map(contact => (
<Link to={`contacts/${contact.id}`}>{contact.name}</Link>
));
}
Ventajas:
src/
├── routes/
│ ├── root.jsx
│ │ ├── export loader
│ │ ├── export action
│ │ └── export default Component
│ ├── contact.jsx
│ │ ├── export loader
│ │ └── export default Component
│ └── edit.jsx
│ ├── export loader
│ ├── export action
│ └── export default Component
├── components/
│ ├── ContactCard.jsx
│ ├── SearchBar.jsx
│ └── ... (componentes reutilizables)
├── api/
│ └── contacts.js (funciones de API)
└── main.jsx (configuración del router)
Principios:
// ✅ BIEN: Nombres descriptivos al importar
import Root, {
loader as rootLoader,
action as rootAction
} from "./routes/root";
import Contact, {
loader as contactLoader
} from "./routes/contact";
import EditContact, {
loader as editContactLoader,
action as editContactAction
} from "./routes/edit";
// ❌ MAL: Conflictos de nombres
import { loader, action } from "./routes/root";
import { loader, action } from "./routes/contact"; // Error!
// En loader o action
export async function loader({ params }) {
const contact = await getContact(params.contactId);
if (!contact) {
throw new Response("Not Found", { status: 404 });
}
return { contact };
}
// En errorElement
export default function ErrorPage() {
const error = useRouteError();
if (error.status === 404) {
return <div>Contacto no encontrado</div>;
}
if (error.status === 401) {
return <div>No autorizado</div>;
}
return <div>Error: {error.message}</div>;
}
export async function action({ request, params }) {
const formData = await request.formData();
const updates = Object.fromEntries(formData);
// Validación
const errors = {};
if (!updates.first && !updates.last) {
errors.name = "Debe proporcionar al menos un nombre";
}
if (updates.email && !isValidEmail(updates.email)) {
errors.email = "Email inválido";
}
if (Object.keys(errors).length > 0) {
return { errors }; // Retornar errores en lugar de redirect
}
await updateContact(params.contactId, updates);
return redirect(`/contacts/${params.contactId}`);
}
// En el componente
function EditContact() {
const { contact } = useLoaderData();
const actionData = useActionData(); // Datos retornados por action
return (
<Form method="post">
<input name="first" defaultValue={contact.first} />
{actionData?.errors?.name && (
<span className="error">{actionData.errors.name}</span>
)}
<button type="submit">Save</button>
</Form>
);
}
function ContactList() {
const { contacts } = useLoaderData();
const navigation = useNavigation();
// Detectar si estamos navegando a un contacto específico
const isLoadingContact = navigation.state === "loading"
&& navigation.location.pathname.includes("/contacts/");
return (
<ul>
{contacts.map(contact => (
<li
key={contact.id}
className={isLoadingContact ? "dim" : ""}
>
<NavLink to={`contacts/${contact.id}`}>
{contact.name}
</NavLink>
</li>
))}
</ul>
);
}
// Si múltiples rutas necesitan los mismos datos
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
loader: rootLoader, // Carga lista de contactos
children: [
{
path: "contacts/:contactId",
element: <Contact />,
loader: contactLoader, // Carga detalles
children: [
{
path: "edit",
element: <EditContact />,
// No necesita loader, usa datos del padre
action: editAction,
}
]
},
],
},
]);
// EditContact puede acceder a datos del contactLoader del padre
function EditContact() {
// useLoaderData busca hacia arriba en la jerarquía
const { contact } = useLoaderData();
return <Form>...</Form>;
}
function EditContact() {
const { contact } = useLoaderData();
const [isDirty, setIsDirty] = useState(false);
// Advertir antes de salir si hay cambios sin guardar
useEffect(() => {
if (isDirty) {
const handleBeforeUnload = (e) => {
e.preventDefault();
e.returnValue = '';
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}
}, [isDirty]);
return (
<Form
method="post"
onChange={() => setIsDirty(true)}
>
{/* inputs */}
</Form>
);
}
Antes de client-side routing:
Después de client-side routing:
// En loaders
export async function loader({ params, request }) {
console.log('Loading contact:', params.contactId);
console.log('URL:', request.url);
const data = await getContact(params.contactId);
console.log('Loaded:', data);
return { contact: data };
}
// En actions
export async function action({ request, params }) {
const formData = await request.formData();
console.log('Form data:', Object.fromEntries(formData));
// ... resto del código
}
// En componentes
function Root() {
const navigation = useNavigation();
console.log('Navigation state:', navigation.state);
// ... resto del código
}
Error: “useLoaderData must be used within a data router”
// ❌ MAL: Usar useLoaderData fuera de un componente de ruta
function Sidebar() {
const data = useLoaderData(); // Error!
}
// ✅ BIEN: Solo en componentes configurados como element
function Root() {
const data = useLoaderData(); // OK
return <Sidebar data={data} />; // Pasar como prop
}
Error: “No routes matched location”
// ❌ MAL: Path incorrecto
{
path: "/contacts/:contactId",
// ...
}
<Link to="/contact/1"> // Falta la 's'
// ✅ BIEN: Paths coinciden
<Link to="/contacts/1">
Error: “Cannot read property of undefined”
// ❌ MAL: No manejar datos vacíos
function Contact() {
const { contact } = useLoaderData();
return <div>{contact.name}</div>; // Error si contact es null
}
// ✅ BIEN: Usar optional chaining
function Contact() {
const { contact } = useLoaderData();
return <div>{contact?.name || 'No name'}</div>;
}
Estos temas se cubren en partes posteriores del tutorial:
// URL: /contacts?q=john&sort=name
export async function loader({ request }) {
const url = new URL(request.url);
const query = url.searchParams.get("q");
const sort = url.searchParams.get("sort");
const contacts = await getContacts(query, sort);
return { contacts, query };
}
// Para búsquedas, usar GET en lugar de POST
<Form method="get" action="/contacts">
<input name="q" />
<button type="submit">Search</button>
</Form>
// Actualiza la URL con query params
// /contacts?q=searchterm
function ContactList() {
const { contacts } = useLoaderData();
const navigation = useNavigation();
// Mostrar cambios inmediatamente
const optimisticContacts = navigation.formData
? updateContactsOptimistically(contacts, navigation.formData)
: contacts;
return optimisticContacts.map(...);
}
// Para mutaciones sin navegación
import { useFetcher } from "react-router-dom";
function Favorite({ contact }) {
const fetcher = useFetcher();
return (
<fetcher.Form method="post" action={`/contacts/${contact.id}/favorite`}>
<button>
{fetcher.state === "submitting" ? "..." : "★"}
</button>
</fetcher.Form>
);
}
// Renderizar UI mientras los datos cargan
export async function loader() {
return defer({
contacts: getContacts(), // Promise sin await
});
}
function Root() {
const { contacts } = useLoaderData();
return (
<Suspense fallback={<div>Loading...</div>}>
<Await resolve={contacts}>
{(loadedContacts) => (
<ContactList contacts={loadedContacts} />
)}
</Await>
</Suspense>
);
}
Modifica la aplicación para incluir un campo de teléfono en los contactos.
Pasos:
EditContactContactImplementa confirmación al hacer clic en “Cancel” si hay cambios.
Pasos:
window.confirm()Muestra un mensaje temporal después de guardar.
Pasos:
useActionData() en el componenteAgrega capacidad de ordenar contactos por nombre o fecha.
Pasos:
React Router transforma el desarrollo de Single Page Applications al:
✅ Simplificar la navegación: Links declarativos y navegación automática
✅ Gestionar datos elegantemente: Loaders y actions eliminan boilerplate
✅ Mejorar la UX: Transiciones suaves, estado de loading, UI optimista
✅ Mantener URLs significativas: Cada vista tiene su propia URL única
✅ Reducir complejidad: Revalidación automática de datos
✅ Seguir estándares web: Basado en formularios HTML y History API
Antes: Gestionar manualmente estado, navegación, sincronización de datos
Después: Declarar rutas, loaders y actions; React Router gestiona el resto
Action: Función que procesa mutaciones de datos (POST, PUT, DELETE)
Browser Router: Router que usa la History API del navegador
Client-Side Routing: Navegación sin recargar la página
Deferred Data: Datos que se cargan mientras se renderiza la UI
Dynamic Segment: Parte variable de una URL (:param)
Error Boundary: Componente que captura errores de rutas
Fetcher: API para mutaciones sin navegación
FormData: API nativa para manejar datos de formularios
Loader: Función que carga datos antes de renderizar
NavLink: Link con capacidades de styling activo/pendiente
Nested Routes: Rutas hijas que se renderizan dentro de rutas padres
Outlet: Componente donde se renderizan rutas hijas
Optimistic UI: Actualizar UI antes de confirmar con servidor
Params: Parámetros capturados de la URL
Pending State: Estado de transición durante carga
Redirect: Navegación programática a otra ruta
Revalidation: Re-ejecución de loaders después de mutaciones
Root Route: Ruta contenedora principal de la aplicación
SPA: Single Page Application
URL Search Params: Parámetros de query en la URL (?key=value)
useActionData: Hook para acceder a datos retornados por actions
useLoaderData: Hook para acceder a datos cargados por loaders
useNavigation: Hook para estado de transiciones de navegación
useRouteError: Hook para acceder a errores capturados
| Hook | Propósito | Retorna |
|---|---|---|
useLoaderData() |
Datos del loader | Datos cargados |
useActionData() |
Datos de la action | Datos retornados |
useNavigation() |
Estado de navegación | { state, location, formData, ... } |
useRouteError() |
Error capturado | Objeto de error |
useParams() |
Parámetros de URL | { paramName: value, ... } |
useSearchParams() |
Query params | [searchParams, setSearchParams] |
| Componente | Propósito | Props Clave |
|---|---|---|
<RouterProvider> |
Proveedor del router | router |
<Outlet> |
Renderiza rutas hijas | - |
<Link> |
Navegación básica | to |
<NavLink> |
Link con estados | to, className |
<Form> |
Formulario con routing | method, action |
{
path: "/ruta/:param", // URL path con params
element: <Component />, // Componente a renderizar
loader: loaderFunction, // Carga datos
action: actionFunction, // Procesa mutaciones
errorElement: <Error />, // Maneja errores
children: [...], // Rutas hijas
}
¡Felicitaciones! Has completado una guía exhaustiva de los fundamentos de React Router. Este documento cubre desde conceptos básicos hasta patrones avanzados, proporcionándote una base sólida para construir aplicaciones web modernas con enrutamiento del lado del cliente.
Recuerda: la mejor manera de aprender es practicando. Construye proyectos, experimenta con diferentes patrones, y no dudes en consultar la documentación oficial cuando necesites profundizar en temas específicos.