Shareable State with URL Parameters
Building a reusable React hook for URL-based state persistence with debouncing, encoding strategies, and SSR considerations
title: "Shareable State with URL Parameters" summary: "Building a reusable React hook for URL-based state persistence with debouncing, encoding strategies, and SSR considerations" date: "2025-01-02" tags: ["react", "typescript", "nextjs", "ux"] topics: ["react-patterns", "developer-experience", "typescript"] prerequisites: ["2025-12-30-building-an-interactive-terminal-shell"] related: ["2025-01-03-playwright-e2e-testing"] author: "asimon" published: true
Shareable State with URL Parameters
When you tweak settings in a calculator or configure options in a form, what happens when you share that page? Usually, the recipient sees defaults, not your configuration. URL state fixes that.
This post walks through building a useUrlState hook that keeps state in sync with the URL - enabling shareable links, bookmarks, and browser back/forward navigation.
Why URL State?
Consider a ROI calculator where users input costs, team size, and timeframes. Without URL state:
- Sharing requires screenshots - "Here's what I calculated"
- Bookmarks reset - Saved links lose all configuration
- No history navigation - Back button doesn't undo changes
With URL state:
/sandbox/roi?v=1&buildInitCost=150000&teamSize=5&salary=120000
That URL contains the full state. Share it, bookmark it, navigate back to it - the configuration persists.
Encoding Strategies
Not all state fits neatly in query params. We need two encoding strategies:
Simple Encoding (Flat Objects)
For objects with primitive values, use individual query params:
interface CalculatorInputs {
buildInitCost: number;
teamSize: number;
salary: number;
}
// URL: ?v=1&buildInitCost=150000&teamSize=5&salary=120000
Pros:
- Human-readable URLs
- Easy to debug
- Can modify in address bar
Cons:
- Verbose for many fields
- Only works with flat objects
Base64 Encoding (Complex Objects)
For nested structures or arrays, encode the entire state as base64 JSON:
interface MatrixState {
factors: Array<{
name: string;
weight: number;
scores: { option1: number; option2: number };
}>;
}
// URL: ?v=1&data=eyJmYWN0b3JzIjpbey...
Pros:
- Handles any structure
- Compact for complex state
Cons:
- Not human-readable
- Harder to debug
The Hook Interface
interface UseUrlStateOptions<T> {
key: string; // URL param key
defaultValue: T; // Initial state
encoding: "simple" | "base64"; // Encoding strategy
version?: number; // Schema version (default: 1)
}
interface UseUrlStateReturn<T> {
state: T; // Current state
setState: (newState: T) => void; // Update state
copyLink: () => Promise<boolean>; // Copy URL to clipboard
isFromUrl: boolean; // True if loaded from URL
}
Usage is straightforward:
function ROICalculator() {
const { state, setState, copyLink, isFromUrl } = useUrlState({
key: "roi",
defaultValue: { buildInitCost: 100000, teamSize: 3 },
encoding: "simple",
version: 1,
});
return (
<div>
{isFromUrl && <Banner>Loaded from shared link</Banner>}
<input
value={state.buildInitCost}
onChange={(e) => setState({ ...state, buildInitCost: Number(e.target.value) })}
/>
<button onClick={copyLink}>Copy Link</button>
</div>
);
}
Implementation Deep Dive
Parsing Initial State
On mount, we parse URL params into state. The version check ensures old URLs don't break when the schema changes:
const { initialState, isFromUrl } = useMemo(() => {
const urlVersion = searchParams?.get("v");
if (urlVersion !== String(version)) {
return { initialState: defaultValue, isFromUrl: false };
}
if (encoding === "simple") {
const parsed = { ...defaultValue };
for (const [k, v] of Object.entries(defaultValue)) {
const urlValue = searchParams?.get(k);
if (urlValue !== null) {
// Type coercion based on default value type
if (typeof v === "number") {
parsed[k] = Number(urlValue);
} else if (typeof v === "boolean") {
parsed[k] = urlValue === "true";
} else {
parsed[k] = urlValue;
}
}
}
return { initialState: parsed, isFromUrl: true };
}
// Base64 decoding
const data = searchParams?.get(key);
if (!data) return { initialState: defaultValue, isFromUrl: false };
try {
return { initialState: JSON.parse(atob(data)), isFromUrl: true };
} catch {
return { initialState: defaultValue, isFromUrl: false };
}
}, []); // Only run once on mount
The empty dependency array is intentional - we only want to parse URL params once on initial render, not on every update.
Debounced URL Updates
Typing in an input would spam history entries without debouncing:
const updateTimeoutRef = useRef<NodeJS.Timeout>();
const updateUrl = useCallback((newState: T) => {
if (updateTimeoutRef.current) {
clearTimeout(updateTimeoutRef.current);
}
updateTimeoutRef.current = setTimeout(() => {
const params = new URLSearchParams();
params.set("v", String(version));
if (encoding === "simple") {
for (const [k, v] of Object.entries(newState)) {
params.set(k, String(v));
}
} else {
params.set(key, btoa(JSON.stringify(newState)));
}
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
}, 300);
}, [version, encoding, key]);
Key decisions:
- 300ms debounce - Fast enough to feel responsive, slow enough to batch rapid changes
router.replace- Updates URL without adding history entriesscroll: false- Prevents page jumping on URL update
Avoiding Infinite Loops
This is the tricky part. The naive approach creates an infinite loop:
// ❌ Don't do this - infinite loop!
useEffect(() => {
updateUrl(state);
}, [state, updateUrl]); // updateUrl depends on router/pathname which change on URL update
The fix: store router and pathname in refs that don't trigger re-renders:
const routerRef = useRef(router);
const pathnameRef = useRef(pathname);
useEffect(() => {
routerRef.current = router;
pathnameRef.current = pathname;
}, [router, pathname]);
const updateUrl = useCallback((newState: T) => {
// ... debounce logic ...
routerRef.current.replace(
`${pathnameRef.current}?${params.toString()}`,
{ scroll: false }
);
}, [version, encoding, key]); // No router/pathname in deps!
Now updateUrl only changes when encoding options change, not on every URL update.
Next.js Gotcha
useRouter(), usePathname(), and useSearchParams() return new objects on every render in Next.js App Router. Using them directly in dependency arrays causes unexpected re-renders.
Skip Initial URL Update
We don't want to update the URL on first render - we just read from it:
const isInitialMount = useRef(true);
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
updateUrl(state);
}, [state, updateUrl]);
SSR and Static Generation
For Next.js static export, useSearchParams requires Suspense:
// In your page component
import { Suspense } from "react";
function Page() {
return (
<Suspense fallback={<Loading />}>
<ROICalculatorWithUrlState />
</Suspense>
);
}
The hook uses optional chaining (searchParams?.get()) to handle server-side rendering where search params aren't available.
Version Migration
The version parameter enables schema evolution:
// v1: { cost: number }
// v2: { buildCost: number, buyCost: number }
// When v=1 URL is loaded with v2 code, it falls back to defaults
// rather than trying to parse incompatible data
Future versions could include migration logic:
if (urlVersion === "1" && version === 2) {
// Transform v1 state to v2 schema
return migrateV1ToV2(parsedState);
}
Copy Link Implementation
The clipboard API is async and might fail (permissions, older browsers):
const copyLink = useCallback(async () => {
try {
const params = new URLSearchParams();
params.set("v", String(version));
if (encoding === "simple") {
for (const [k, v] of Object.entries(state)) {
params.set(k, String(v));
}
} else {
params.set(key, btoa(JSON.stringify(state)));
}
const url = `${window.location.origin}${pathname}?${params.toString()}`;
await navigator.clipboard.writeText(url);
return true;
} catch {
return false;
}
}, [state, version, encoding, key, pathname]);
The consuming component can show feedback:
<CopyLinkButton
onClick={async () => {
const success = await copyLink();
if (success) {
showToast("Link copied!");
}
}}
/>
Testing URL State
Playwright makes it easy to test the full round-trip:
test("URL state persists on navigation", async ({ page }) => {
// Set state via UI
await page.goto("/sandbox/roi");
await page.fill('[data-testid="build-cost"]', "200000");
// Get URL and verify params
const url = new URL(page.url());
expect(url.searchParams.get("buildInitCost")).toBe("200000");
// Navigate away and back
await page.goto("/");
await page.goto(url.toString());
// Verify state restored
await expect(page.locator('[data-testid="build-cost"]')).toHaveValue("200000");
});
Full Implementation
Here's the complete hook with all the pieces together:
export function useUrlState<T extends Record<string, unknown>>({
key,
defaultValue,
encoding,
version = 1,
}: UseUrlStateOptions<T>): UseUrlStateReturn<T> {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const updateTimeoutRef = useRef<NodeJS.Timeout>();
const isInitialMount = useRef(true);
// Refs to avoid dependency changes
const routerRef = useRef(router);
const pathnameRef = useRef(pathname);
useEffect(() => {
routerRef.current = router;
pathnameRef.current = pathname;
}, [router, pathname]);
// Parse initial state (runs once)
const { initialState, isFromUrl } = useMemo(() => {
// ... parsing logic ...
}, []);
const [state, setStateInternal] = useState<T>(initialState);
// Debounced URL update
const updateUrl = useCallback((newState: T) => {
// ... debounce + URL update logic ...
}, [version, encoding, key]);
// Update URL on state change (skip initial)
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
updateUrl(state);
}, [state, updateUrl]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (updateTimeoutRef.current) {
clearTimeout(updateTimeoutRef.current);
}
};
}, []);
const setState = useCallback(/* ... */, []);
const copyLink = useCallback(/* ... */, [state, version, encoding, key]);
return { state, setState, copyLink, isFromUrl };
}
Summary
URL state transforms single-session tools into shareable, bookmarkable experiences. The key insights:
- Two encoding strategies for different data shapes
- Debounce to avoid history pollution
- Refs to prevent infinite loops with Next.js hooks
- Version parameter for schema migration
The pattern works for any interactive tool: calculators, configurators, filters, or search forms. If users would benefit from sharing their configuration, URL state is worth implementing.