DEV Community

Cover image for React Router Data Mode: Part 5 - Refactor, useParams and NavLink

React Router Data Mode: Part 5 - Refactor, useParams and NavLink

We continue with the fifth part of this series on React Router Data Mode. This time, it will be a brief post where we’ll do some refactors, review the useParams hook, and improve navigation with NavLink.


If you’re coming from the previous post, you can continue with your project as is. But if you prefer to start fresh or make sure you’re at the exact point, run the following commands:

# Repository link 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

Let’s start by improving the detail view.

Create 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

Then update the detail page src/pages/ContactDetail.tsx to use this new component:

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

Now create the component 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

This component already includes local search, although that won’t be the focus of this post.
The important thing here is navigation.

Update the main page 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

How do we mark the active link?

To correctly indicate which contact is selected, we use the useParams hook:

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

And with that, adjust the button in the contact list:

<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

With this change, the active contact is now correctly shown in the list.

What about NavLink?

React Router also includes the component https://reactrouter.com/api/components/NavLink#props, which extends Link with improvements for the active and pending states.

Specifically:

  • It automatically applies classes to the link when it’s active or pending.
  • It adds the attribute aria-current="page" when the link represents the current route.

In our case, since we use ShadCN’s Button, we don’t take advantage of NavLink’s CSS classes, but we can benefit from its accessibility support (aria-current), which is a good practice for navigations like this.

<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

And that’s it for this part. In the next post we’ll dive into actions, another interesting concept inherited from Remix, which will allow us to start making mutations within the app 💥

Top comments (0)