An example of using searchParams, useSearchParams and Next router in Next 15

Peter Jacxsens - Oct 28 - - Dev Community

This is part 2 of a series on searchParams in Next 15. In the first part we talked about synchronous and asynchronous searchParams. Later, we want to run some tests on this but this means we first need some code to run tests on. This will be our focus in this chapter.

Note: this code is available in a github repo.

SPOILER: Right now, @testing-library/react doesn't work for Next 15. This means that we cannot write these tests yet. See part 3.

List

We will make a little app where we can sort a list of fruits ascending or descending. Preview:

an example app

This app consists of 2 components: the actual list and the sort buttons. For the list, we will pass the searchParams prop from page to <List />. So route list?sortOrder=asc will pass asc: <List sortOrder='asc' />.

For the sort buttons we will use the useSearchParams hook. This gives me the opportunity to demonstrate how to mock next/navigation hooks in Next 15. Pushing the buttons calls push function on useRouter, f.e. router.push('/list?sortOrder=desc').

/list/page.tsx

Our first component is the route root page.tsx:

// src/app/list/page.tsx

import List from '@/components/List';
import ListControles from '@/components/ListControles';
import validateSortOrder from '@/lib/validateSortOrder';

type Props = {
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
};

const ITEMS = ['apple', 'banana', 'cherry', 'lemon'];

export default async function ListPage({ searchParams }: Props) {
  const currSearchParams = await searchParams;
  const sortOrder = validateSortOrder(currSearchParams.sortOrder);
  return (
    <>
      <h2 className='text-2xl font-bold mb-2'>List</h2>
      <ListControles />
      <List items={ITEMS} sortOrder={sortOrder} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

In this component we use the asynchronous searchParams request. We extract a sortOrder value from searchParams using the validateSortOrder function:

// src/lib/validateSortOrder.ts

import isSortOrderT from '@/types/isSortOrderT';
import { SortOrderT } from '@/types/SortOrderT';

const DEFAULT_SORT_ORDER: SortOrderT = 'asc';

export default function validateSortOrder(
  value: string | string[] | undefined | null
) {
  if (!value) return DEFAULT_SORT_ORDER;
  if (Array.isArray(value)) return DEFAULT_SORT_ORDER;
  if (!isSortOrderT(value)) return DEFAULT_SORT_ORDER;
  return value;
}
Enter fullscreen mode Exit fullscreen mode

This function checks if searchParams.sortOrder is either asc or desc and returns the default asc when it's not.

List

Our <List /> component receives the validated sortOrder value ('asc' | 'desc') and simply sorts the fruits accordingly. Nothing new here:

// scr/components/List.tsx

import { SortOrderT } from '@/types/SortOrderT';

type Props = {
  items: string[];
  sortOrder: SortOrderT;
};

const SORT_CALLBACKS = {
  asc: (a: string, b: string) => (a > b ? 1 : -1),
  desc: (a: string, b: string) => (a < b ? 1 : -1),
};

export default function List({ items, sortOrder }: Props) {
  const sortedItems = items.sort(SORT_CALLBACKS[sortOrder]);
  return (
    <ul className='list-disc'>
      {sortedItems.map((item) => (
        <li key={item}>{item}</li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

ListControles

Our final component holds our sort buttons and uses the useSearchParams hook.

// src/components/ListControles.tsx

'use client';

import validateSortOrder from '@/lib/validateSortOrder';
import { SortOrderT } from '@/types/SortOrderT';
import { usePathname, useSearchParams, useRouter } from 'next/navigation';

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

  // get validated sortOrder value
  const sortOrder = validateSortOrder(searchParams.get('sortOrder'));

  function handleSort(val: SortOrderT) {
    const newParams = new URLSearchParams(searchParams);
    newParams.set('sortOrder', val);
    newParams.set('ha ha ha ha', '123 456 789');
    router.push(`${pathname}?${newParams.toString()}`);
  }
  return (
    <div>
      <div className='mb-2'>current sort order: {sortOrder}</div>
      <div className='flex gap-1'>
        <button
          className='bg-blue-700 text-white py-1 px-4 rounded-sm'
          onClick={() => handleSort('asc')}
        >
          sort ascending
        </button>
        <button
          className='bg-blue-700 text-white py-1 px-4 rounded-sm'
          onClick={() => handleSort('desc')}
        >
          sort descending
        </button>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

useSearchParams

The useSearchParams hook returns a readonly URLSearchParams interface: ReadonlyURLSearchParams.

The URLSearchParams interface defines utility methods to work with the query string of a URL.

source: MDN

These utility methods includes things like: has(), get() and set(). So, for example, on url /list?foo=bar, we can do this:

const searchParams = useSearchParams();

searchParams.has('foo'); // true
searchParams.has('mooooo'); // false
searchParams.get('foo'); // 'bar'
searchParams.get('mooooo'); // null
Enter fullscreen mode Exit fullscreen mode

But, in this case, we can't use .set. Why not? Because useSearchParams returns a readonly ReadonlyURLSearchParams interface.

searchParams.set('foo', bar); // Unhandled Runtime Error (crash)
Enter fullscreen mode Exit fullscreen mode

Given the simplicity of this app we could just manually write our new search params:

router.push(`/list?sortOrder=${value}`);
Enter fullscreen mode Exit fullscreen mode

But that has 2 potential problems. Firstly, value isn't url encoded (we could again do this manually). Secondly, we lose other potential search params. For example if we are on url /list?sortOrder=desc&color=red, we would want to keep the color parameter and not just delete it.

That is why we use URLSearchParams. But, we need to go from a readonly to a read and write URLSearchParams interface. Luckily, this is quite easy. Here is our handleSort function from our <ListControles /> component

function handleSort(val: SortOrderT) {
  const newParams = new URLSearchParams(searchParams);
  newParams.set('sortOrder', val);
  router.push(`${pathname}?${newParams.toString()}`);
}
Enter fullscreen mode Exit fullscreen mode
  • We create a new URLSeachParams interface and pass in the old one. This way we don't lose any search params. The new URLSeachParams is not readonly.
  • Next, we write a new sortOrder value using the .set method. This will also url encode both the key and the value.
  • Finally, we need to go from a URLSearchParams interface to an actual url search params string which we do by simply calling the .toString() method on the interface.

By doing it this way we preserve existing search params and we also get some free url encoding. All and all a handy API. I like it.

Recap

We just build a simple fruit sorting app so we have some code to test and mock with Jest and React Testing Library.

We have our page component that uses asynchronous searchParams prop and then our <ListControles /> component were we will have to mock usePathname, useSearchParamsand useRouter.

We will do this in the next chapters.


If you want to support my writing, you can donate with paypal.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .