A while back I wrote a fairly popular article on dev.to: How to mock Next router with Jest. Since then, Next 13
has arrived and brought us a new router and new router hooks.
In this first part we will build a little project using all these new hooks. In the second and third parts we will learn how to test these new hooks and functions. All the files we use are available on github.
The plan
We are building a project where you can sort a list of fruits by clicking sort buttons.
Clicking a button pushes a new route with a sortOrder
parameter to the router: /sortList?sortOrder=asc
or /sortList?sortOrder=desc
. The list of fruits is sorted accordingly.
I used a fresh create-next-app
(13.4.8) install with typescript and eslint. Cleaned out the boilerplate, added all Jest
packages, added eslint
packages for Jest
and configured them.
Note: I originally tried building this with radio inputs but due to a bug in Next 13
, it didn't work properly. I left the files in the repo in case you are interested.
Get query strings
There are 2 ways to get access to url parameters:
-
searchParams
page prop in the root of a route (the page file) returns an object with the parameters: f.e.{ foo: 'bar' }
- The
useSearchParams
hook returns a readonlyURLSearchParams
interface.
The URLSearchParams interface defines utility methods to work with the query string of a URL.
source: MDN
Among others, it allow you to get or set a parameter, to check if a parameter is present, to get the keys and values of all the parameters,... In short, it is a handy set of tools to read and update query strings.
Project outline
There are 3 things we want to work with:
-
searchParams
page prop -
useSearchParams
hook -
useRouter
hook
We will make a <List />
component that is responsible for sorting our list of fruits and rendering it. To read the sortOrder
parameter from the url, we use searchParams
in the page root and pass it to <List />
.
function Page({ searchParams }) {
return <List {...pass searchParams...} />;
}
Our second main component <ListControlesButtons />
will hold our sort buttons. In this component we will use the useSearchParams
hook to get access to the query string and also use useRouter
to push new routes to the router.
function Page({ searchParams }) {
return (
<>
<ListControlesButtons />
<List {...pass searchParams...} />
</>
);
}
searchParams prop
What do we expect from searchParams
? There are a couple of options:
sortorder | route | searchParams |
---|---|---|
none | /sortList |
{} |
valid | /sortList?sortOrder=asc |
{ sortOrder: 'asc' } |
/sortList?sortOrder=desc |
{ sortOrder: 'desc' } | |
invalid | /sortList?sortOrder=foobar |
{ sortOrder: 'foobar' } |
/sortList?sortOrder |
{ sortOrder: '' } |
This means we will have to validate searchParams
. We create a type for represent searchParams
:
// types/index.d.ts
export type SearchParams = {
sortOrder?: string;
};
page.js
Here is our full Page
component:
// app/sortList/page.tsx
import { SearchParams } from '@/types';
import getSortOrderFromSearchParams from '@/lib/getSortOrderFromSearchParams';
import ListControlesButtons from '@/components/ListControlesButtons';
import List from '@/components/List';
type Props = {
searchParams: SearchParams;
};
export default function Page({ searchParams }: Props) {
const sortOrder = getSortOrderFromSearchParams(searchParams);
return (
<>
<ListControlesButtons />
<List sortOrder={sortOrder} />
</>
);
}
getSortOrderFromSearchParams.ts
getSortOrderFromSearchParams
is the function that validates searchParams
. It returns either asc
or desc
. It will return asc
as the default value when:
- There is no
sortOrder
param. -
sortOrder
value is empty. -
sortOrder
value is invalid (notasc
ordesc
).
When none of the above conditions were fulfilled, the value of sortOrder will be valid (asc
or desc
) and we can use it, else the default asc
is passed. We also created a type for this:
// types/index.d.ts
...
export type SortOrder = 'asc' | 'desc';
// lib/getSortOrderFromSearchParams.ts
import { SearchParams, SortOrder } from '@/types';
import validateSortOrder from './validateSortOrder';
export default function getSortOrderFromSearchParams(
searchParams: SearchParams
): SortOrder {
const sortOrderParam = searchParams.sortOrder;
let sortOrder: SortOrder = 'asc';
if ('sortOrder' in searchParams && sortOrderParam) {
if (validateSortOrder(sortOrderParam)) {
sortOrder = sortOrderParam;
}
}
return sortOrder;
}
validateSortOrder.ts
The validateSortOrder
is a type predicate. It checks if the value is either asc
or desc
and returns a boolean.
// lib/validateSortOrder.ts
import { SortOrder } from '@/types';
export default function validateSortOrder(value: string): value is SortOrder {
const validOptions: [SortOrder[0], SortOrder[1]] = ['asc', 'desc'];
return validOptions.includes(value as SortOrder);
}
To recap: in page.js
we validate searchParams
and we then pass it to <List sortOrder={sortOrder} />
. When sortOrder
is passed, it is validated, meaning it will always be asc
or desc
.
List.js
Our <List />
component is simple. It just sorts a list of fruits along the sortOrder
prop:
// components/List.tsx
import { SortOrder } from '@/types';
type Props = {
sortOrder: SortOrder;
};
export default function List({ sortOrder }: Props) {
const list = ['Banana', 'Apple', 'Lemon', 'Cherry'];
const sortedList = [...list].sort((a, b) => {
if (sortOrder === 'asc') {
return a > b ? 1 : -1;
}
return a < b ? 1 : -1;
});
return (
<ul>
{sortedList.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
);
}
ListControlesButtons.tsx
This is our second main component. We factored away all the hooks inside a custom hook useSort
that returns sortOrder
(asc
or desc
) and an event handler handleSort
.
// components/ListControlesButtons.tsx
'use client';
import useSort from '@/hooks/useSort';
export default function ListControlesButtons() {
const { sortOrder, handleSort } = useSort();
return (
<div>
<div>sort order: {sortOrder === 'asc' ? 'ascending' : 'descending'}</div>
<button onClick={() => handleSort('asc')}>sort ascending</button>
<button onClick={() => handleSort('desc')}>sort descending</button>
</div>
);
}
useSort.ts
useSort
is a custom hook. It return 2 things:
-
sortOrder
: validated fromuseSearchParams()
hook -
handleSort
: an event handler that pushes routes to the router
// hooks/useSort.ts
import { SortOrder } from '@/types';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import getSortOrderFromUseSearchParams from '../lib/getSortOrderFromUseSearchParams';
export default function useSort() {
const params = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const sortOrder = getSortOrderFromUseSearchParams(params);
const handleSort = (value: SortOrder) => {
const newParams = new URLSearchParams(params.toString());
newParams.set('sortOrder', value);
router.push(`${pathname}?${newParams.toString()}`);
};
return { sortOrder, handleSort };
}
sortOrder
In useSort
we use the useSearchParams
hook. As mentioned above, this returns a readonly URLSearchParams
interface. Similar to what we did in page.js
, we need to validate: check if there is a sortOrder
parameter and that its value is valid.
But we can't reuse our getSortOrderFromSearchParams
function because that only accepts objects and we now have a URLSearchParams
interface. So, we wrote a new function getSortOrderFromUseSearchParams
. It does the exact same checks but uses the URLSearchParams
methods like .has()
and .get()
.
// lib/getSortOrderFromUseSearchParams.ts
import { SortOrder } from '@/types';
import { ReadonlyURLSearchParams } from 'next/navigation';
import validateSortOrder from '../lib/validateSortOrder';
export default function getSortOrderFromUseSearchParams(
params: ReadonlyURLSearchParams
) {
const sortOrderParam = params.get('sortOrder');
let sortOrder: SortOrder = 'asc';
if (params.has('sortOrder') && sortOrderParam) {
if (validateSortOrder(sortOrderParam)) {
sortOrder = sortOrderParam;
}
}
return sortOrder;
}
handleSort()
Finally, our handleSort function is an event listener that gets called when clicking the sort buttons. What does this function need to do?
First, we have our query string that we got from useSearchParams
. We want to append or overwrite to this string the following: sortOrder=asc
or sortOrder=desc
.
But, useSearchParams
returns a readonly URLSearchParams
interface. We can't change it. So we create a new URLSearchParams
and pass into that the URLSearchParams
we got from useSearchParams
. Why do we pass in the old URLSearchParams
? Because there may be other parameters and we don't want to discard them.
const newParams = new URLSearchParams(params.toString());
The URLSearchParams
constructor accepts a string so we use the build-in toString
method. If we were on route /sortList?sortOrder=desc&foo=bar
then params.toString()
would equal sortOrder=desc&foo=bar
.
Our newParams
variable now has access to these parameters:
-
newParams.get('sortOrder')
->desc
. -
newParams.get('foo')
->bar
.
newParams is also not readonly. We can make changes. How? By using the set()
method:
newParams.set('sortOrder', value);
value
is the value that handleSort
got called with inside our buttons: either 'asc' and 'desc'. We don't need to validate this because they are hardcoded inside our buttons.
The last step we need is to construct a route and push this route to router:
router.push(`${pathname}?${newParams.toString()}`);
We got pathname
from the usePathname
hook and it would be localhost/sortList
. We then add the ?
and our updated query string (newParams.toString()
) and push the route. And we are done. Our project is fully functioning.
Recap
You may be thinking this was a lot of code for such a little project. But we were quite thorough:
- We used Typescript.
- We validated our query strings.
- We split our code into functions and custom hooks to facilitate testing. (see next part)
- We used both
searchParams
page prop anduseSearchParams
hook to access our query string.
In the end, the project is quite simple:
- In our route root (page.js) we validate
searchParams
and passsortOrder
to<List />
. -
<List />
sorts our fruits along thissortOrder
parameter. -
<ListControlesButtons />
renders our buttons. It gets access tosortOrder
andhandleSort
via a custom hookuseSort
. - The custom
useSort
hook:- Gets the query string via
useSearchParams
hook. It validates this string usinggetSortOrderFromUseSearchParams
and returnssortOrder
(asc
|desc
). - Creates an event handler for the buttons:
handleSort
. When called, this function creates a newURLSearchParams
from the readonly one thatuseSearchParams
returns. It adds (or overwrites)sortOrder
with a new value. It then constructs a new route fromusePathname
and our newURLSearchParams
and pushes that to the router.
- Gets the query string via
In the next parts we are going to test all of these components and functions with Jest
and rtl
.