A reusable useQueryParams hook in Next.js with built-in validation using Zod

January 20, 2024

Article thumbnail

Introduction

At my current work, I found myself repeating some code to preserve URL query state and changes, along with the validation and coercion of those parameters. Thats why you are probably reading this, so in this post I will show you a custom hook called useQueryParams, which is a thin wrapper over Next.js router.query enhanced with built-in validation and coercion capabilities to solve this problem.

This hook was used with the Pages Router. If you want to know how to use it with the App Router, go to the last section of the post.

Key Features:

  • Built-in Validation: Leverages zod for validating query parameters against a predefined schema, ensuring that your application only works with valid data.
  • Type Safety: Fully supports TypeScript, allowing you to define the shape of your query parameters upfront, thus avoiding common runtime errors.

How it works

I will show you the full code directly and below you will find how it works and its configuration options:

"use client"

import { useState, useEffect } from 'react'
import { useRouter } from 'next/router'
import { z } from 'zod'

interface UseQueryParamsConfig<T extends z.ZodTypeAny> {
  schema: T
  defaultValues: z.infer<T>
}

export function useQueryParams<T extends z.ZodTypeAny>(
  config: UseQueryParamsConfig<T>
): {
  queryParams: z.infer<T>
  setQueryParams: (newParams: Partial<z.infer<T>>) => void
} {
  const router = useRouter()

  const parsedQuery = config.schema.safeParse({
    ...config.defaultValues,
    ...router.query,
  })

  const initialValues = parsedQuery.success
    ? parsedQuery.data
    : config.defaultValues

  const [queryParams, setQueryParamsState] = useState<T>(initialValues)

  useEffect(() => {
    if (!router.isReady) return

    if (Object.keys(router.query).length === 0) {
      setQueryParamsState(config.defaultValues)
      return
    }

    const query = { ...queryParams, ...router.query }
    const parsedQuery = config.schema.safeParse(query)

    if (parsedQuery.success) {
      const data = parsedQuery.success ? parsedQuery.data : config.defaultValues
      setQueryParamsState(data)
    } else console.log(parsedQuery.error)
  }, [router.isReady, router.query])

  const setQueryParams = (newParams: Partial<z.infer<T>>) => {
    const mergedQueryParams = {
      ...queryParams,
      ...router.query,
      ...newParams,
    }

    router.push(
      {
        pathname: router.pathname,
        query: mergedQueryParams,
      },
      undefined,
      { shallow: true }
    )
  }

  return {
    queryParams,
    setQueryParams,
  }
}

Configuration Options:

The hook is initialized with a configuration object that contains the following properties:

  • schema: A Zod schema that describes the expected shape and constraints of your query parameters.
  • defaultValues: Default values for your query parameters, ensuring that your application has sensible defaults.

Execution order

  1. Initialization: When the hook is called, it immediately attempts to parse the current query parameters from the URL using the provided Zod schema and merges them with any default values specified.

  2. Validation: The parsed query parameters are validated against the schema.

  3. State Management: The hook manages the state of the query parameters internally, providing you with queryParams for accessing the current state and a setQueryParams function for updating the query parameters in the URL.

  4. URL Synchronization: When you use the setQueryParams function to update the query parameters, the hook automatically synchronizes these changes with the URL, ensuring that the browser’s address bar reflects the current state of the application.

Example using the hook

Here’s a basic example to get you started, notice how we dont need to parse values from strings by using zod coercion:

import { useQueryParams } from './useQueryParams';
import { z } from 'zod';

const queryParamSchema = z.object({
  page: z.coerce.number().default(0),
  pageSize: z.coerce.number().default(10),
  search: z.string().optional(),
});

const MyComponent = () => {
  const { queryParams, setQueryParams } = useQueryParams({
    schema: queryParamSchema,
    defaultValues: { search: '', page: 1, pageSize: 10 },
  });

  return (
    <div>
      <h1>My current search params: {JSON.stringify(queryParams)}</h1>
      <button onClick={() => setQueryParams({ search: "Your search query", page: 1 })}>
        Change search param
      </button>
    </div>
  );
};

App Router

In the new App Router, there are some substantial changes we need to make:

  1. We need to change the way to get the search params because router.query is not available. The new way is by using the useSearchParams hook, but we need to convert them to an object.
  2. The shallow option to make client updates without refreshing the page is not available in the new router from next/navigation. Luckily, from Next.js 14.1, there is a new experimental API that allows to make client side updates just as the shallow property of the pages router.
  3. useEffect can be removed as the new hooks returns valid values from the first render and dont need to check if router is ready.
"use client";

import { useState } from "react";
import { useSearchParams, usePathname } from "next/navigation";
import { z } from "zod";

interface UseQueryParamsConfig<T extends z.ZodTypeAny> {
  schema: T;
  defaultValues: z.infer<T>;
}

export function useQueryParams<T extends z.ZodTypeAny>(
  config: UseQueryParamsConfig<T>
): {
  queryParams: z.infer<T>;
  setQueryParams: (newParams: Partial<z.infer<T>>) => void;
} {
  const searchParams = useSearchParams();
  const pathname = usePathname();

  // Convert URLSearchParams to an object
  const searchParamsObject = Object.fromEntries(searchParams.entries());

  const [queryParams, setQueryParamsState] = useState<z.infer<T>>(() => {
    const initialParse = config.schema.safeParse({
      ...config.defaultValues,
      ...searchParamsObject,
    });
    return initialParse.success ? initialParse.data : config.defaultValues;
  });

  const setQueryParams = (newParams: Partial<z.infer<T>>) => {
    const mergedParams = { ...queryParams, ...newParams };

    const parsedQuery = config.schema.safeParse(mergedParams);

    if (parsedQuery.success) {
      setQueryParamsState(parsedQuery.data);

      const newUrlParams = new URLSearchParams(parsedQuery.data);

      window.history.pushState(
        null,
        "",
        `${pathname}?${newUrlParams.toString()}`
      );
    } else {
      console.error("Validation failed:", parsedQuery.error);
    }
  };

  return {
    queryParams,
    setQueryParams,
  };
}

© 2024 Angel Hodar