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
anduseLocation
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
(withSuspense
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,
};
}
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;
If you also use a
SearchBar
, mirror the sameonCreateCustomer
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;
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>
);
}
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 /> }],
},
]);
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>
);
}
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)