DEV Community

Cover image for React Router Data Mode: Parte 5 - refactor, useParams y NavLink
Kevin Julián Martínez Escobar
Kevin Julián Martínez Escobar

Posted on • Edited on

React Router Data Mode: Parte 5 - refactor, useParams y NavLink

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
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

¿Cómo marcamos el enlace activo?

Para marcar correctamente qué contacto está seleccionado, usamos el hook useParams:

const { contactId } = useParams<{ contactId: string }>();
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)