DEV Community

Cristian Sifuentes
Cristian Sifuentes

Posted on

React Router Like a Pro: A Reusable `useRouteNav` Hook + Button Click Navigation (TypeScript)

React Router Like a Pro: A Reusable  raw `useRouteNav` endraw  Hook + Button Click Navigation (TypeScript)

Production-grade navigation in React Router isn’t just useNavigate('/path') sprinkled everywhere. Centralize it. Type it. Test it. This post shows a reusable navigation hook (useRouteNav) and how to wire it into components like a pro.


What we’ll build

  • A tiny custom hook that wraps useNavigate and useLocation with typed, reusable intent methods.
  • A Topbar component with a Create Customer button that navigates to /addCustomer.
  • A HomePage that wires search + navigate together.
  • Optional: List cards that navigate to /customers/:id on click.
  • A tidy router using createBrowserRouter (with Suspense for lazy pages).
  • A minimal CustomerAddPage stub to complete the flow.

This pattern scales nicely: all app navigation lives in one place. When you later add analytics, permissions, or audits around navigation, you do it once.


1) The Hook — useRouteNav

import { useCallback } from "react";
import { useLocation, useNavigate } from "react-router-dom";

type NavOptions = {
  replace?: boolean;
  state?: unknown;
};

export function useRouteNav() {
  const navigate = useNavigate();
  const location = useLocation();

  // Navigate to Add Customer (absolute)
  const goToAddCustomer = useCallback(
    (opts?: NavOptions) => {
      navigate("/addCustomer", { replace: !!opts?.replace, state: opts?.state });
    },
    [navigate]
  );

  // Navigate to a specific customer id
  const goToCustomerById = useCallback(
    (id: string | number, opts?: NavOptions) => {
      navigate(`/customers/${id}`, { replace: !!opts?.replace, state: opts?.state });
    },
    [navigate]
  );

  // Navigate to search while preserving current search params (optional)
  const goToSearchWithQuery = useCallback(
    (query: string, opts?: NavOptions) => {
      const params = new URLSearchParams(location.search);
      params.set("q", query.trim());
      navigate({ pathname: "/search", search: params.toString() }, { replace: !!opts?.replace, state: opts?.state });
    },
    [navigate, location.search]
  );

  return {
    goToAddCustomer,
    goToCustomerById,
    goToSearchWithQuery,
  };
}
Enter fullscreen mode Exit fullscreen mode

Why a hook?

  • Single responsibility: components call semantic methods (e.g., goToAddCustomer) instead of hardcoding paths.
  • Encapsulation: you can add analytics, auth guards, A/B logic, or feature flags inside the hook later.
  • Type safety: NavOptions keeps intent explicit (replace, state).

2) Topbar with “Create Customer”

Add an onCreateCustomer prop and wire a button click to it.

import { useEffect, useState, type KeyboardEvent } from "react";

interface Props {
  placeholder?: string;
  onQuery: (query: string) => void;
  onCreateCustomer: () => void; // NEW
}

export const Topbar = ({ placeholder = "Search", onQuery, onCreateCustomer }: Props) => {
  const [query, setQuery] = useState("");

  const handleSearch = () => onQuery(query);

  useEffect(() => {
    const timeoutId = setTimeout(() => onQuery(query), 1000);
    return () => clearTimeout(timeoutId);
  }, [query, onQuery]);

  const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
    if (event.key === "Enter") handleSearch();
  };

  return (
    <div className="topbar">
      <div className="title">
        <div className="logo" aria-hidden="true">💳</div>
        <h1>Customers</h1>
        <span className="pill" id="countPill">0</span>
      </div>

      <div className="controls">
        <input
          id="q"
          type="search"
          placeholder={placeholder}
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          onKeyDown={handleKeyDown}
          autoComplete="off"
        />
        <span className="spacer" />
        <button className="primary" id="searchBtn" onClick={handleSearch}>Search</button>
        <button className="secondary" id="addCustomerBtn" onClick={onCreateCustomer}>
          Create Customer
        </button>
      </div>
    </div>
  );
};

export default Topbar;
Enter fullscreen mode Exit fullscreen mode

If you also use a SearchBar, mirror the same onCreateCustomer prop there—or keep the create action only in the Topbar for clarity.


3) Wire it in the HomePage

import Topbar from "../../../shared/components/Topbar";
import Cards from "../../components/Cards";
import { useCustomersChargeBee } from "../../hooks/useCustomersChargeBee";
import { useRouteNav } from "../../../shared/hooks/useRouteNav";

const HomePage = () => {
  const { handleSearch, customers } = useCustomersChargeBee();
  const { goToAddCustomer } = useRouteNav();

  return (
    <>
      <Topbar
        placeholder="Search name, email, company, id…"
        onQuery={handleSearch}
        onCreateCustomer={goToAddCustomer}
      />
      <Cards list={customers} />
    </>
  );
};

export default HomePage;
Enter fullscreen mode Exit fullscreen mode

4) (Optional) Card → Detail Route

// src/customers/components/Cards.tsx (sketch)
import { useRouteNav } from "../../shared/hooks/useRouteNav";
import type { List } from "../interfaces/customer.response";

export default function Cards({ list }: { list: List[] }) {
  const { goToCustomerById } = useRouteNav();
  return (
    <div className="cards">
      {list.map((c) => (
        <article key={c.id} onClick={() => goToCustomerById(c.id)} className="card">
          <h3>{c.first_name} {c.last_name}</h3>
          <p>{c.email}</p>
        </article>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

5) Router Setup (lazy + Suspense)

Use react-router-dom for browser apps and wrap lazy routes.

import { createBrowserRouter } from "react-router-dom";
import { Suspense, lazy } from "react";

import ChargeBeeLayout from "../customers/layouts/ChargeBeeLayout";
import AdminLayout from "../admin/layouts/AdminLayout";
import AdminPage from "../admin/pages/AdminPage";
import HomePage from "../customers/pages/home/HomePage";
import CustomerPage from "../customers/pages/customer/CustomerPage";

const SearchPage = lazy(() => import("../customers/pages/search/SearchPage"));
const CustomerAddPage = lazy(() => import("../customers/pages/customer/CustomerAddPage"));

const withSuspense = (el: JSX.Element) => <Suspense fallback={<div>Loading…</div>}>{el}</Suspense>;

export const appRouter = createBrowserRouter([
  {
    path: "/",
    element: <ChargeBeeLayout />,
    children: [
      { index: true, element: <HomePage /> },
      { path: "customers/:id", element: <CustomerPage /> },
      { path: "search", element: withSuspense(<SearchPage />) },
      { path: "addCustomer", element: withSuspense(<CustomerAddPage />) },
    ],
  },
  {
    path: "/admin",
    element: <AdminLayout />,
    children: [{ index: true, element: <AdminPage /> }],
  },
]);
Enter fullscreen mode Exit fullscreen mode

6) Minimal CustomerAddPage

export default function CustomerAddPage() {
  return (
    <section style={{ padding: 24 }}>
      <h1>Create Customer</h1>
      {/* Your form goes here */}
      <p>Coming soon: create customer form.</p>
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

Pro Tips (for production apps)

  • Prefer intent methods (goToAddCustomer) to raw paths in components.
    • Refactors & path changes become one-line edits in the hook.
  • Use replace: true for post-auth/login/logouts to avoid “Back to login” loops.
  • Pass state to carry non-URL data (e.g., flash messages) without polluting the query string.
  • Preserve/merge search params with URLSearchParams when moving between filterable pages.
  • Centralize analytics: send events from inside the hook before/after navigate.

Summary

Feature Purpose
useRouteNav Centralizes navigation logic (intent-based, testable)
goToAddCustomer Click → /addCustomer
goToCustomerById Click a card → /customers/:id
goToSearchWithQuery Navigate to /search while preserving current search params
Lazy routes + Suspense Faster initial loads, cleaner code splitting

This pattern keeps your components clean, your navigation consistent, and your future self happy. ⚡


Want this post as a starter repo? I can scaffold a TypeScript + Vite template with everything above (routes, hook, pages, CSS) and add a couple of tests.

✍️ Written by: Cristian Sifuentes --- Full-stack developer & AI
enthusiast, passionate about building scalable architectures and
teaching dev teams how to thrive in the age of AI.

Top comments (0)