Type Safe Search Params in React
The Problem with Search Params
Working with URL search parameters in React, especially with libraries like React Router, often involves a lot of manual parsing and string manipulation. You pull values from window.location.search, use URLSearchParams to get individual keys, and then convert them to the correct types. This is tedious and, more importantly, error-prone. What happens if you expect a number but get a string? Or a boolean that’s not true or false?
This is where type safety becomes crucial. We want to ensure that when we read a search param, we get exactly what we expect, or at least handle the cases where we don’t gracefully.
A Pattern for Type Safety
Let’s build a simple, reusable hook to manage type-safe search parameters. We’ll use TypeScript to define the expected types and ensure compile-time safety.
Step 1: Define Your Schema
First, define an interface or type that represents your expected search parameters and their types. For example, let’s say we have a product listing page with filtering options for category (string), priceLimit (number), and inStock (boolean).
interface ProductSearchParams { category: string; priceLimit?: number; // Optional parameter inStock?: boolean; // Optional parameter}Step 2: Create a Generic Hook
Now, let’s create a hook, let’s call it useTypedSearchParams, that takes a generic type representing our schema and returns a function to get typed values and a function to set them.
We’ll leverage URLSearchParams under the hood, but add our type-checking logic.
import { useMemo, useCallback } from 'react';import { useLocation, useNavigate } from 'react-router-dom';
// Helper to parse a single value with type conversionfunction parseParam<T>(value: string | null, type: 'string' | 'number' | 'boolean'): T | undefined { if (value === null) { return undefined; }
switch (type) { case 'string': return value as unknown as T; case 'number': const num = parseFloat(value); return isNaN(num) ? undefined : (num as unknown as T); case 'boolean': // Consider 'true', '1', 'yes' as true, anything else as false const lowerValue = value.toLowerCase(); return (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes') ? true as unknown as T : false as unknown as T; default: return undefined; }}
// Helper to stringify a value for URLfunction stringifyParam(value: unknown): string | undefined { if (value === undefined || value === null) { return undefined; } if (typeof value === 'boolean') { return value ? 'true' : 'false'; } return String(value);}
function useTypedSearchParams<T extends Record<string, any>>() { const location = useLocation(); const navigate = useNavigate();
const searchParams = useMemo(() => new URLSearchParams(location.search), [location.search]);
const getParams = useCallback(<K extends keyof T>(key: K, type: 'string' | 'number' | 'boolean'): T[K] | undefined => { const value = searchParams.get(String(key)); return parseParam<T[K]>(value, type); }, [searchParams]);
const setParams = useCallback((paramsToSet: Partial<Record<keyof T, unknown>> & { [key in keyof T]?: T[key] | undefined }) => { const newSearchParams = new URLSearchParams(location.search); Object.entries(paramsToSet).forEach(([key, value]) => { const stringValue = stringifyParam(value); if (stringValue === undefined) { newSearchParams.delete(key); } else { newSearchParams.set(key, stringValue); } }); navigate(`${location.pathname}?${newSearchParams.toString()}`, { replace: true }); }, [navigate, location.pathname, location.search]);
return { getParams, setParams, currentSearchParams: searchParams };}
export default useTypedSearchParams;Step 3: Use the Hook in Your Component
Now, you can use this hook in your React components. You’ll pass the expected type to the hook.
import React from 'react';import useTypedSearchParams from './useTypedSearchParams'; // Assuming you saved the hook here
interface ProductSearchParams { category: string; priceLimit?: number; inStock?: boolean;}
function ProductList() { const { getParams, setParams } = useTypedSearchParams<ProductSearchParams>();
// Get values safely const category = getParams('category', 'string') || 'all'; const priceLimit = getParams('priceLimit', 'number'); const inStock = getParams('inStock', 'boolean');
// Example of how to update params const handleFilterChange = (newCategory: string) => { setParams({ category: newCategory, priceLimit: undefined, inStock: true }); };
const handlePriceChange = (newPrice: number | undefined) => { setParams({ priceLimit: newPrice }); };
return ( <div> <h1>Products</h1> <p>Current Category: {category}</p> {priceLimit !== undefined && <p>Max Price: {priceLimit}</p>} {inStock !== undefined && <p>In Stock: {inStock ? 'Yes' : 'No'}</p>}
<button onClick={() => handleFilterChange('electronics')}>Filter Electronics</button> <button onClick={() => handlePriceChange(100)}>Set Max Price to $100</button> <button onClick={() => setParams({ priceLimit: undefined, inStock: undefined })}>Clear Filters</button> </div> );}
export default ProductList;Why This Works
- Type Safety: By defining
ProductSearchParamsand using TypeScript generics inuseTypedSearchParams, you get compile-time checks. If you try togetParams('nonExistentKey', 'string')orgetParams('priceLimit', 'string'), TypeScript will flag it. - Centralized Logic: All the parsing, stringifying, and navigation logic is in one place, the
useTypedSearchParamshook. This makes your components cleaner and less repetitive. - Readability: Components become more readable because the intent is clear: you’re getting a
stringforcategory, anumberforpriceLimit, etc. - Maintainability: If you need to change how a parameter is parsed (e.g., handle a new boolean format), you only need to update the
parseParamfunction within the hook.
This pattern provides a robust way to handle URL search parameters in your React applications, ensuring type safety and simplifying your code.
Tags
Web Development, React, TypeScript, URLSearchParams, Frontend Patterns, State Management