Andy Simon's Blog

asimon@blog:~/2025-01-02-url-state-management/$ _

Shareable State with URL Parameters

by asimon
reacttypescriptnextjsux

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 entries
  • scroll: 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.