DEV Community

Peter Jacxsens
Peter Jacxsens

Posted on

An example of using async params and async searchParams in Next 16

Our goal is to write tests and mocks for params, searchParams and some hooks. Therefore we need something to test. That is what we will be doing in this chapter. Here is what we will build:

testing params and searchParams in Next 16

Glorious yes? The code for this example is available on github.

Route

We start by adding a route: app/list/[listSlug]/page.tsx. This will give access to a param (listSlug) that we can use for testing.

import Link from 'next/link';

export default async function ListPage({
  params,
}: PageProps<'/list/[listSlug]'>) {
  const { listSlug } = await params;

  return (
    <div>
      <Link href='/' className='inline-block underline text-blue-400 mb-4'>
        home
      </Link>
      <h1 className='font-bold text-xl mb-2'>List of {listSlug}</h1>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This should be clear. We just accessed the params props and then destructure it into listSlug variable.

Data

To keep thing simple, I added this data object:

const data: Record<string, string[]> = {
  fruit: ['apple', 'banana', 'cherry'],
  names: ['Adam', 'Bob', 'Cole'],
};
Enter fullscreen mode Exit fullscreen mode

We will use params to access this object. So http:localhost:3000/list/fruit will give us the listSlug "fruit" and we can then render a list:

<ul>
  {data[listSlug].map((item) => (
    <li key={item} className='list-disc ml-3'>
      {item}
    </li>
  ))}
</ul>
Enter fullscreen mode Exit fullscreen mode

To guard against non existing slugs, we add this line:

const { listSlug } = await params;
if (!(listSlug in data)) {
  return <p>Invalid param.</p>;
}
Enter fullscreen mode Exit fullscreen mode

searchParams

To make our glorious app interactive, we will use 2 buttons that will push a route with a sort parameter: /list/fruit?sortOrder=asc or /list/fruit/sortOrder=desc. In our page component, we check for the sortOrder searchParam and sort the list accordingly. So, we will destructure searchParams from our page props and then read the value.

const searchParamsResolved = await searchParams;
const sortOrder = validateSortOrder(searchParamsResolved);
Enter fullscreen mode Exit fullscreen mode

validateSortOrder is a little helper function I wrote because we have to take all cases into account:

  • no sortOrder param http:localhost:3000/list/fruit
  • invalid sortOrder param http:localhost:3000/list/fruit?sortOrder=foobar
  • valid sortOrder param http:localhost:3000/list/fruit?sortOrder=desc

The function just checks if there is a property sortOrder AND if said property equals 'desc'. In all other cases it returns the default 'asc'.

// lib/validateSortOrder.tsx

export type SortOrderT = 'asc' | 'desc';

export function validateSortOrder(
  searchParams: Awaited<PageProps<'/list/[listSlug]'>['searchParams']>
): SortOrderT {
  if ('sortOrder' in searchParams && searchParams.sortOrder === 'desc')
    return 'desc';
  return 'asc';
}
Enter fullscreen mode Exit fullscreen mode

Sorting

Now that we have a sortOrder, we apply it to the list.

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

//...

<ul>
  {data[listSlug].sort(sortCallbacks[sortOrder]).map((item) => (
    <li key={item} className='list-disc ml-3'>
      {item}
    </li>
  ))}
</ul>;
Enter fullscreen mode Exit fullscreen mode

(We put the callbacks in a separate object to make it a bit more clean.)

<ListControls /> component

The only thing missing now are the buttons. Since they are buttons we need to put them inside a client component <ListControls />.

We are going to use the buttons to push a new route to the router. So on clicking the button 'descending' we want to do this:

router.push('/list/fruit?sortOrder=desc');
Enter fullscreen mode Exit fullscreen mode

We could more or less hardcode this route but that would cause a small problem. If more searchParams were present, they would be deleted. So if we're on this route: http:localhost:3000/list/fruit?sortOrder=asc&foo=bar and we push the above route, we would lose the searchParam foo=bar.

useSearchParams

To solve this, we first use the useSearchParams hook that 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/fruit?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 the useSearchParams() hook returns a readonly ReadonlyURLSearchParams interface.

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

It's readonly, we can't write it. So we first need to convert it to a URLSearchParams interface that will allow us to write:

// ReadonlyURLSearchParams
const searchParams = useSearchParams();
const pathName = usePathname();
const router = useRouter();

function handleSort(newSortOrder: SortOrderT) {
  // create new URLSearchParams and pass it ReadonlyURLSearchParams
  // note the .toString()
  const newSearchParams = new URLSearchParams(searchParams.toString());
  // overwrite sortOrder with new value
  // other query params are passed
  newSearchParams.set('sortOrder', newSortOrder);
  // push to router
  // usePathname() returns current path: /list/fruit
  router.push(`${pathName}?${newSearchParams.toString()}`);
}
Enter fullscreen mode Exit fullscreen mode

Let me quickly recap this. We do not want to overwrite unrelated search parameters. So we first retrieve all searchParams and then just overwrite sortOrder. The method of doing this is a bit complex with the ReadonlyURLSearchParams that needs to be converted. Lastly, we construct a new url and push it to router.

validateSortOrder (again)

There is a second use for useSearchParams. We need the current sortOrder value to highlight to currently active button. This means we have to reuse the validateSortOrder helper function we used in page.tsx.

But there is a small issue here. validateSortOrder takes the searchParams page prop object and then returns 'asc' | 'desc'. But we don't get that object here. To get around that I used this:

const searchParams = useSearchParams();

// ...

// get sortOrder from useSearchParams()
const rawSortOrder = searchParams.get('sortOrder'); // string | null
// validateSortOrder expects: {[key: string]: string | string[] | undefined}
// validateSortOrder returns 'asc' | 'desc'
const sortOrder = validateSortOrder(
  rawSortOrder ? { sortOrder: rawSortOrder } : {}
);
Enter fullscreen mode Exit fullscreen mode

This will call validateSortOrder either with an empty object or with { sortOrder: string } and validateSortOrder handles this just fine.

Here is our full <ListControls /> component. Apart from the code just above it's just a title and 2 buttons.

// components/ListControls.tsx

'use client';

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

export default function ListControls() {
  const searchParams = useSearchParams();
  const pathName = usePathname();
  const router = useRouter();
  const params = useParams();

  function handleSort(newSortOrder: SortOrderT) {
    const newSearchParams = new URLSearchParams(searchParams.toString());
    newSearchParams.set('sortOrder', newSortOrder);
    router.push(`${pathName}?${newSearchParams.toString()}`);
  }

  const rawSortOrder = searchParams.get('sortOrder');
  const sortOrder = validateSortOrder(
    rawSortOrder ? { sortOrder: rawSortOrder } : {}
  );

  return (
    <>
      <h2 className='font-semibold mb-1'>Sort {params.listSlug}</h2>
      <div className='flex gap-2 mb-2'>
        <button
          className={`text-white px-2 py-1 rounded cursor-pointer ${
            sortOrder === 'asc' ? 'bg-amber-600' : 'bg-slate-600'
          }`}
          onClick={() => handleSort('asc')}
        >
          ascending
        </button>
        <button
          className={`text-white px-2 py-1 rounded cursor-pointer ${
            sortOrder === 'desc' ? 'bg-amber-600' : 'bg-slate-600'
          }`}
          onClick={() => handleSort('desc')}
        >
          descending
        </button>
      </div>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Quick side note: notice that we could've simply passed params and searchParams from page.tsx to <ListControls />. But, in a real world scenario that would include a lot of prop drilling and this also gives us a chance to use and later test and mock useParams (we used it in the h2 title) and useSearchParams.

Here is our final page.tsx component:

// app/list/[listSlug]/page.tsx
import ListControls from '@/components/ListControls';
import { validateSortOrder } from '@/lib/validateSortOrder';
import Link from 'next/link';

const data: Record<string, string[]> = {
  fruit: ['apple', 'banana', 'cherry'],
  names: ['Adam', 'Bob', 'Cole'],
};

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

export default async function ListPage({
  params,
  searchParams,
}: PageProps<'/list/[listSlug]'>) {
  const { listSlug } = await params;
  if (!(listSlug in data)) {
    return <p>Invalid param.</p>;
  }
  const searchParamsResolved = await searchParams;
  const sortOrder = validateSortOrder(searchParamsResolved);

  return (
    <div>
      <Link href='/' className='inline-block underline text-blue-400 mb-4'>
        home
      </Link>
      <h1 className='font-bold text-xl mb-2'>List of {listSlug}</h1>
      <ListControls />
      <ul>
        {data[listSlug].sort(sortCallbacks[sortOrder]).map((item) => (
          <li key={item} className='list-disc ml-3'>
            {item}
          </li>
        ))}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Note how much code we needed to do this. Anyway, in the next chapter we will setup Jest, React Testing Library and eslint for these libraries.

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

Top comments (0)