javascript
59 lines · 8 steps
URL-driven tabs with React Router
A tab component that stores the active tab in the URL query string instead of local state, making selections shareable and bookmarkable.
Explained by
highlit
1import { useSearchParams } from "react-router-dom";
2
3const TABS = [
4 { id: "overview", label: "Overview" },
5 { id: "activity", label: "Activity" },
6 { id: "settings", label: "Settings" },
7];
8
9const DEFAULT_TAB = TABS[0].id;
10
11export function ProjectTabs({ children }) {
12 const [searchParams, setSearchParams] = useSearchParams();
13
14 const requested = searchParams.get("tab");
15 const activeTab = TABS.some((t) => t.id === requested) ? requested : DEFAULT_TAB;
16
17 const selectTab = (tabId) => {
18 setSearchParams(
19 (prev) => {
20 const next = new URLSearchParams(prev);
21 if (tabId === DEFAULT_TAB) {
22 next.delete("tab");
23 } else {
24 next.set("tab", tabId);
25 }
26 return next;
27 },
28 { replace: true }
29 );
30 };
31
32 return (
33 <div className="project-tabs">
34 <div role="tablist" aria-label="Project sections">
35 {TABS.map((tab) => (
36 <button
37 key={tab.id}
38 role="tab"
39 id={`tab-${tab.id}`}
40 aria-selected={tab.id === activeTab}
41 aria-controls={`panel-${tab.id}`}
42 tabIndex={tab.id === activeTab ? 0 : -1}
43 className={tab.id === activeTab ? "tab is-active" : "tab"}
44 onClick={() => selectTab(tab.id)}
45 >
46 {tab.label}
47 </button>
48 ))}
49 </div>
50 <div
51 role="tabpanel"
52 id={`panel-${activeTab}`}
53 aria-labelledby={`tab-${activeTab}`}
54 >
55 {children[activeTab]}
56 </div>
57 </div>
58 );
59}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Storing UI state in the URL makes views shareable, bookmarkable, and survivable across reloads.
- 2Validating the raw query value against a known list guards against arbitrary or stale input.
- 3Wiring ARIA roles and tabIndex to the derived active tab keeps keyboard and screen-reader behavior correct for free.
Related explainers
javascript
const { Pool } = require('pg'); const pool = new Pool({ connectionString: process.env.DATABASE_URL,
Per-request Postgres connections in Express
connection-pooling
middleware
transactions
Intermediate
8 steps
javascript
const express = require('express'); const router = express.Router(); const db = require('../db');
Building a paginated orders page in Express
pagination
routing
sql-queries
Intermediate
7 steps
javascript
import { Suspense } from 'react'; import { searchProducts } from '@/lib/products'; import SearchInput from './search-input';
Streaming search results in a Next.js Server Component
server-components
suspense
streaming
Intermediate
8 steps
javascript
import { useState } from 'react'; function StarRating({ value = 0, max = 5, onChange, size = 24 }) { const [hovered, setHovered] = useState(null);
Building an accessible star rating in React
controlled-component
accessibility
state-management
Intermediate
8 steps
javascript
import { notFound } from 'next/navigation' import { Suspense } from 'react' import { getPostBySlug, getRelatedPosts } from '@/lib/posts' import { RelatedPosts } from '@/components/related-posts'
Building a dynamic blog post page in Next.js
server-components
dynamic-routing
metadata
Intermediate
8 steps
javascript
const express = require('express'); const { createProxyMiddleware, fixRequestBody } = require('http-proxy-middleware'); const router = express.Router();
Building an API gateway proxy in Express
reverse-proxy
middleware
api-gateway
Intermediate
6 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/url-driven-tabs-with-react-router-explained-javascript-05a6/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.