Using URL params as the state

March 19, 2025

Hero image for URL as store

GitHub | Demo

Content table

First, the 'why'!

Shareability stands out as the primary benefit. Because the URL captures the application's state, users can easily share their current view or configuration via a direct link. Furthermore, bookmarking these URLs allows users to quickly return to specific states without any extra effort.

Seamless navigation through application states is provided by the browser's back and forward buttons, mirroring the experience of standard web browsing. Furthermore, deep linking functionality enables external links to direct users to precise locations within the application.

Reloading the page doesn't result in lost application state. This is especially beneficial for users working with complex forms or filters, as their progress is maintained. Additionally, URL-based state can sometimes enhance SEO, as search engines may index specific application views.

URL-based state seamlessly integrates with Server-Side Rendering (SSR). This is because the server can directly generate the application's initial HTML based on the URL, ensuring consistent rendering from the start.

Simple example

To illustrate this concept, consider a common scenario: an online clothing store. While the following example uses Next.js APIs, the underlying logic can be implemented directly with the browser's location API.

GitHub link for the component | Demo

const sizes = [
  {
    label: 'Extra Small',
    value: '24px',
  },
  ...
];
const colors = [
  'teal',
  'magenta',
  ...
];

export function Example() {
  const searchParams = useSearchParams();
  const router = useRouter();
  const pathname = usePathname();

  const size = searchParams.get('size') ?? 'Small';
  const color = searchParams.get('color') ?? 'black';

  const handleUpdate = (qName: string) => (qValue: string) => {
    const params = new URLSearchParams(searchParams.toString());
    params.set(qName, qValue);
    router.push(`${pathname}?${params.toString()}`);
  };

  return (
    ...
      <div>
        <Shirt
          size={sizes.find((i)=> i.label= size)?.value}
          fill={color}
          strokeWidth={1}
        />
        ...
    
        <h2>Controls</h2>
        <div>
          <DropDown
            label="Size"
            list={sizes.map(({ label })=> label)}
            onUpdate={handleUpdate('size')}
          />
          <DropDown
            label="Color"
            list={colors}
            onUpdate={handleUpdate('color')}
          />
        </div>
    ...
  );
}

The code includes size and color dropdowns. Changing these dropdown values updates the URL, which then dynamically reflects those changes on the displayed t-shirt icon. Consequently, pasting the updated URL into a new tab or window will reproduce the exact same visual state.

Custom implementation

GitHub link for the page | Demo

Let's consider a more complex use case. This example demonstrates a TanStack Table with URL state management. The full table implementation can be reviewed on GitHub DataTable. To streamline the process, a custom hook useUrlState was created to encapsulate the URL state reading and updating logic, building upon the previous example.

GitHub link for the custom hook

import { OnChangeFn } from '@tanstack/react-table';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useMemo } from 'react';

type SetValue<T> = (old: T) => T;

export const useUrlState = <T,>(qName: string): [T, OnChangeFn<T>] => {
  const searchParams = useSearchParams();
  const router = useRouter();
  const pathname = usePathname();

  const createQueryString = useCallback(
    (name: string, value: string) => {
      const params = new URLSearchParams(searchParams.toString());
      params.set(name, value);
      return params.toString();
    },
    [searchParams]
  );

  const qValue = searchParams.get(qName);

  const getValue: T = useMemo(
    () => (qValue ? JSON.parse(qValue) : []),
    [qValue]
  );

  const setValue = useCallback(
    (updaterOrValue: T | SetValue<T>) => {
      const value =
        typeof updaterOrValue === 'function'
          ? (updaterOrValue as SetValue<T>)(getValue)
          : updaterOrValue;
      router.push(
        `${pathname}?${createQueryString(qName, JSON.stringify(value))}`
      );
    },
    [createQueryString, getValue, pathname, qName, router]
  );

  return [getValue, setValue];
};

With a signature similar to React.useState, the useUrlState is reusable hook that takes a query parameter key and provides a getter and setter. The getter fetches the value from the URL query string, and the setter modifies the URL by updating the query parameter's value. This specific implementation of setValue is necessary to conform to the OnChangeFn type signature defined by TanStack Table, similar to the setter in React.useState.

'use client';
import ...
import { useUrlState } from '@/lib/query-string-hook';

export function UserDataTable({ data }: { data: Users[] }) {
  const [sorting, setSorting] = useUrlState<SortingState>('sorting'); // <---
  const [columnFilters, setColumnFilters] =
    useUrlState<ColumnFiltersState>('columnFilters'); // <---

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    getFilteredRowModel: getFilteredRowModel(),
    state: {
      sorting,
      columnFilters,
    },
  });

  return <DataTable table={table} />
}

The UserDataTable component implements a TanStack Table, with its state synced to the URL via the useUrlState hook. TanStack Table requires the SortingState and ColumnFiltersState types to be passed for proper operation.

Using zustand

GitHub like for the page | Demo

In production applications, state management tools are typically already in use. To demonstrate this, we'll explore Zustand, a widely popular library according to npm trends. Fortunately, Zustand offers built-in support for syncing state with URL query parameters. Let's adapt our previous example by replacing the custom hook with a Zustand store.

GitHub link for the zustand store

import {
  ColumnFiltersState,
  OnChangeFn,
  SortingState,
} from '@tanstack/react-table';
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { hashStorage } from './store-utils';

interface TableStore {
  columnFilters: ColumnFiltersState;
  setColumnFilters: OnChangeFn<ColumnFiltersState>;
  sorting: SortingState;
  setSorting: OnChangeFn<SortingState>;
}

export const useTableStore = create(
  persist<TableStore>(
    (set) => ({
      columnFilters: [],
      setColumnFilters: (
        updaterOrValue:
          | ColumnFiltersState
          | ((old: ColumnFiltersState) => ColumnFiltersState)
      ) =>
        set((state) => ({
          columnFilters:
            typeof updaterOrValue === 'function'
              ? updaterOrValue(state.columnFilters)
              : updaterOrValue,
        })),
      sorting: [],
      setSorting: (
        updaterOrValue: SortingState | ((old: SortingState) => SortingState)
      ) =>
        set((state) => ({
          sorting:
            typeof updaterOrValue === 'function'
              ? updaterOrValue(state.sorting)
              : updaterOrValue,
        })),
    }),
    {
      name: 'table-store',
      storage: createJSONStorage(() => hashStorage),
    }
  )
);

useTableStore hook is a zustand store, that has getter and setter for column filters and sorting data that is persisted. This specific implementation of setter is necessary to conform to the OnChangeFn type signature defined by TanStack Table.

'use client';
...
import { useTableStore } from '@/lib/store';

export function UserDataTable({ data }: { data: Users[] }) {
  const { sorting, setSorting, columnFilters, setColumnFilters } =
    useTableStore((state) => state); // <---

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    getFilteredRowModel: getFilteredRowModel(),
    state: {
      sorting,
      columnFilters,
    },
  });

  return <DataTable table={table} />
}

In the UserDataTable component, a TanStack Table's state is managed by a zustand store, which takes care of URL synchronization. Consequently, the component's implementation is straightforward, simply retrieve the getters and setters from the store.

Conslusion

In conclusion, when using the URL for state management, prioritize data sanitization due to potential security risks. Employing a library like zod to parse query strings is a straightforward way to achieve this. Be mindful of URL length limitations for complex states, and never store sensitive information directly in the URL. Ensure all data is properly encoded to prevent conflicts with special characters.