javascript
43 lines · 7 steps
Syncing search state with the URL hash in React
A React search box keeps its query in the URL hash, debounces the sync, and scrolls to the matching section.
Explained by
highlit
1import { useState, useEffect, useRef } from 'react';
2
3export function SectionSearch({ sections }) {
4 const [query, setQuery] = useState(() => decodeURIComponent(window.location.hash.slice(1)));
5 const debounceRef = useRef(null);
6
7 useEffect(() => {
8 clearTimeout(debounceRef.current);
9 debounceRef.current = setTimeout(() => {
10 const next = query ? `#${encodeURIComponent(query)}` : ' ';
11 window.history.replaceState(null, '', next);
12 }, 250);
13 return () => clearTimeout(debounceRef.current);
14 }, [query]);
15
16 useEffect(() => {
17 if (!query) return;
18 const match = sections.find((s) =>
19 s.title.toLowerCase().includes(query.toLowerCase())
20 );
21 if (!match) return;
22 const el = document.getElementById(match.id);
23 el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
24 }, [query, sections]);
25
26 useEffect(() => {
27 const onHashChange = () => {
28 setQuery(decodeURIComponent(window.location.hash.slice(1)));
29 };
30 window.addEventListener('hashchange', onHashChange);
31 return () => window.removeEventListener('hashchange', onHashChange);
32 }, []);
33
34 return (
35 <input
36 type="search"
37 value={query}
38 onChange={(e) => setQuery(e.target.value)}
39 placeholder="Jump to section..."
40 aria-label="Search sections"
41 />
42 );
43}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Seeding state from the URL makes a component's view sharable and restorable across reloads.
- 2A ref holding a timer id lets you debounce side effects and cancel them cleanly on unmount.
- 3Listening for hashchange closes the loop so browser navigation feeds back into component state.
Related explainers
javascript
function evaluate(expression) { const tokens = tokenize(expression); let pos = 0;
Building a recursive descent calculator
parsing
recursion
operator-precedence
Intermediate
8 steps
javascript
import { useState, useEffect, useCallback, useRef } from 'react'; const cache = new Map(); const inflight = new Map();
Building a stale-while-revalidate hook in React
caching
request-deduplication
custom-hooks
Advanced
10 steps
javascript
import { useEffect, useRef, useState } from 'react'; export function useDelayedFlag(active, delay = 300) { const [visible, setVisible] = useState(false);
Delaying a loading spinner with a React hook
custom-hooks
debouncing
cleanup
Intermediate
8 steps
javascript
const SWIPE_THRESHOLD = 80; const MAX_TRANSLATE = 120; export function attachSwipeToDismiss(element, onDismiss) {
Building a swipe-to-dismiss gesture in JS
touch-events
gesture-detection
dom-manipulation
Intermediate
10 steps
javascript
const { pool } = require('./db'); function withTransaction() { return async (req, res, next) => {
A per-request transaction middleware in Express
middleware
database-transactions
connection-pooling
Advanced
7 steps
javascript
function collapseConsecutiveLogs(lines, { keyFn = (l) => l.message } = {}) { const groups = []; for (const line of lines) {
Collapsing consecutive log lines in JavaScript
grouping
run-length-encoding
data-transformation
Intermediate
7 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/syncing-search-state-with-the-url-hash-in-react-explained-javascript-c9f0/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.