Continuamos con la quinta entrega de esta serie sobre React Router Data Mode. En esta ocasión, será un post breve donde haremos algunos refactors y repasaremos el hook useParams
, además de mejorar la navegación con NavLink
.
Si vienes del post anterior, puedes continuar con tu proyecto tal cual. Pero si prefieres empezar limpio o asegurarte de estar en el punto exacto, ejecuta los siguientes comandos:
# Enlace del repositorio https://github.com/kevinccbsg/react-router-tutorial-devto
git reset --hard
git clean -d -f
git checkout 04-refactor-sidebar-detail
Refactor
Empezamos mejorando la vista de detalle.
Creamos src/components/ContactCard/ContactCard.tsx
:
import { Form } from "react-router";
import { Star, StarOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Card, CardContent } from "@/components/ui/card";
interface Contact {
id: string;
name: string;
username: string;
favorite: boolean;
avatar?: string;
}
export default function ContactCard({ avatar, name, username, favorite, id }: Contact) {
return (
<Card className="max-w-md mx-auto">
<CardContent className="flex flex-col items-center gap-4 p-6">
<Avatar className="w-32 h-32">
<AvatarImage src={avatar || undefined} />
<AvatarFallback>{name[0]}</AvatarFallback>
</Avatar>
<div className="text-center">
<h2 className="text-xl font-bold">{name}</h2>
{username && (
<p className="text-sm text-muted-foreground">{username}</p>
)}
</div>
<div className="flex gap-2">
<Form method="DELETE">
<input type="hidden" name="id" value={id} />
<Button type="submit" variant="destructive">Delete</Button>
</Form>
<Form method="PATCH">
<input type="hidden" name="id" value={id} />
<input type="hidden" name="favorite" value={String(!favorite)} />
<Button type="submit" variant="ghost">
{favorite ? <Star className="w-4 h-4" /> : <StarOff className="w-4 h-4" />}
</Button>
</Form>
</div>
</CardContent>
</Card>
)
}
Luego actualizamos la página de detalle src/pages/ContactDetail.tsx
para usar ese nuevo componente:
import { useParams, useRouteLoaderData } from "react-router";
import { loadContacts } from "./loader";
import ContactCard from "@/components/ContactCard/ContactCard";
const ContactDetail = () => {
const { contactId } = useParams<{ contactId: string }>(); // Needs TS type annotation
const routeData = useRouteLoaderData<typeof loadContacts>("root");
if (!routeData) {
return <div>Loading...</div>;
}
const { contacts } = routeData;
// Find the contact locally (outside the store)
const contact = contacts.find((c) => c.id === contactId);
if (!contact) {
return <div>Contact not found</div>;
}
return (
<ContactCard
avatar={contact.avatar}
name={`${contact.firstName} ${contact.lastName}`}
username={contact.username}
favorite={contact.favorite}
id={contact.id}
/>
);
}
export default ContactDetail;
Ahora creamos el componente src/components/Sidebar/Sidebar.tsx
:
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Link } from "react-router"
import { useState } from "react";
interface Contact {
id: string;
name: string;
}
export default function Sidebar({ contacts }: { contacts: Contact[] }) {
const [search, setSearch] = useState("");
const handlesearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
};
const filteredContacts = contacts.filter(contact =>
contact.name.toLowerCase().includes(search.toLowerCase())
);
return (
<>
<Input placeholder="Search..." className="mb-2" value={search} onChange={handlesearchChange} />
<Button className="w-full" variant="secondary" asChild>
<Link to="/contacts/new" viewTransition>
New
</Link>
</Button>
<ScrollArea className="flex-1">
<div className="flex flex-col gap-1 mt-4">
{filteredContacts.map(contact => (
<Button
key={contact.id}
className="justify-start"
asChild
>
<Link to={`/contacts/${contact.id}`} viewTransition>
{contact.name}
</Link>
</Button>
))}
</div>
</ScrollArea>
</>
)
}
Este componente ya incorpora búsqueda local, aunque no será el foco en este post.
Lo importante aquí es la navegación.
Actualizamos la página principal pages/contacts.tsx
:
import { Outlet, useLoaderData } from "react-router";
import { loadContacts } from "./loader";
import Sidebar from "@/components/Sidebar/Sidebar";
const ContactsPage = () => {
const { contacts } = useLoaderData<typeof loadContacts>();
return (
<div className="h-screen grid grid-cols-[300px_1fr]">
{/* Sidebar */}
<div className="border-r p-4 flex flex-col gap-4">
<Sidebar contacts={contacts.map(contact => ({
id: contact.id,
name: `${contact.firstName} ${contact.lastName}`,
}))} />
</div>
{/* Detail View */}
<div className="p-8">
<Outlet />
</div>
</div>
);
};
export default ContactsPage;
¿Cómo marcamos el enlace activo?
Para marcar correctamente qué contacto está seleccionado, usamos el hook useParams
:
const { contactId } = useParams<{ contactId: string }>();
Y con eso, ajustamos el botón en la lista de contactos:
<Button
key={contact.id}
className="justify-start"
variant={contact.id === contactId ? "default" : "ghost"}
asChild
>
<Link to={`/contacts/${contact.id}`} viewTransition>
{contact.name}
</Link>
</Button>
Con este cambio, ya se muestra correctamente el contacto activo en el listado.
¿Y NavLink?
React Router también incluye el componente https://reactrouter.com/api/components/NavLink#props, que extiende Link
con mejoras para los estados active
y pending
.
En concreto:
- Aplica automáticamente classes al link cuando el enlace está activo o pendiente.
- Añade el atributo
aria-current="page"
cuando el enlace representa la ruta actual.
En nuestro caso, como usamos Button
de ShadCN, no aprovechamos las classes CSS de NavLink
, pero sí podemos beneficiarnos de su soporte de accesibilidad (aria-current), lo cual es una buena práctica para navegaciones como esta.
<Button
key={contact.id}
className="justify-start"
variant={contact.id === contactId ? "default" : "ghost"}
asChild
>
<NavLink to={`/contacts/${contact.id}`} viewTransition>
{contact.name}
</NavLink>
</Button>
Y eso sería todo por esta parte. En la siguiente entrega entraremos con actions, otro concepto heredado de Remix muy interesante, que nos permitirá empezar a hacer mutaciones dentro de la app 💥
¡Nos vemos en la próxima!
Top comments (0)