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
export function buildPaginationUrl(baseUrl, { page, perPage, filters = {}, sort } = {}) { const url = new URL(baseUrl); const params = url.searchParams;
Building and parsing pagination URLs
url-parsing
query-string
pagination
Intermediate
8 steps
typescript
import { Directive, ElementRef, HostListener, Input, OnDestroy, OnInit } from '@angular/core'; @Directive({ selector: '[appTrapFocus]',
Building a focus-trap directive in Angular
accessibility
focus-management
dom
Intermediate
9 steps
javascript
const crypto = require('crypto'); function securityHeaders(options = {}) { const {
A configurable security-headers middleware in Express
middleware
http-headers
content-security-policy
Intermediate
7 steps
javascript
import { useRef, useCallback, useEffect, useState } from "react"; function useThrottle(callback, delay) { const lastRun = useRef(0);
Building a throttle hook in React
throttling
custom-hooks
refs
Intermediate
8 steps
javascript
async function copyToClipboard(text) { if (navigator.clipboard && window.isSecureContext) { try { await navigator.clipboard.writeText(text);
Copying to the clipboard with a fallback
clipboard
progressive-enhancement
dom
Intermediate
8 steps
javascript
import { useEffect, useRef, useState } from "react"; export function Dropdown({ label, children }) { const [open, setOpen] = useState(false);
Closing a dropdown on outside click in React
outside-click
event-listeners
cleanup
Intermediate
8 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.