- Published on
Managing React UI Search and Filter State in the URL
- Authors
- Name
- Dan Orlando
- @danorlando1
Update: Since the writing of this article, React Location has been updated and renamed to Tanstack Router, however the concepts and code examples remain the same.
In my previous post, I introduced React Location as a slick and more viable routing solution compared to React Router. In this article we talk about why you might switch to React Location and discuss the features it provides with implementation. In this post I'll demonstrate how to use the URL to manage and save sort, filter, and pagination state with React Location. By utilizing this feature of React Location, users can bookmark the page and essentially freeze the application state so they can come back to it without having to re-enter the necessary criteria again. This can be extremely valuable for administrative dashboards where support staff often need to refer back to specific states on the fly when dealing with customers, for example.
For being a very recent addition to the TanStack library collection, React Location is really robust and feels like a very mature library. I think this is largely because React Location started as a wrapper for React Router and as more features continued to be added, it eventually outgrew its usefullness as a React Router wrapper and needed to be a homegrown solution in its entirety for Tanner to be able to do what he wanted to with it. Nevertheless, a lot of these features have been refined based on business and developer needs over some time, allowing for a very powerful yet easy to integrate routing solution for your applications. If you do get stuck though, the documentation is easy to follow and the code examples are helpful, especially the "Kitchen Sink" example.
Installation
If you haven't already, install and setup react-location (you can use this article as a reference).
I also highly recommend installing react-location-jsurl for use with React Location's search params. This will give you custom stringifySearch
and parseSearch
functions that you provide to your ReactLocation
instance, which keeps URLs small and readable through a custom serialization mechanism: npm install react-location-jsurl
or yarn add react-location-jsurl
.
In addition to react-location-jsurl, make sure to install React Location's DevTools, which is super-helpful for troubleshooting and understanding what React Location is doing behind the scenes: npm install react-location-devtools --save-dev
.
Step 1: Create Generics
We'll start by creating a few types. The first couple we'll use for our sorting parameters, but the third will be for creating a generics object that we'll call UsersViewGenerics
, which will be used for the useNavigate()
and useSearch()
hooks.
import { MakeGenerics, useSearch, useNavigate } from 'react-location'
type UsersViewSortBy = 'firstName' | 'lastName' | 'email' | 'primaryId'
type UsersViewSortOrder = 'asc' | 'desc'
type UsersViewGenerics = MakeGenerics<{
Search: {
pagination?: {
pageNumber?: number,
pageLimit?: number
},
filters?: {
searchText?: string
},
sorting?: {
sortBy?: UsersViewSortBy,
sortOrder?: UsersViewSortOrder
}
}
}>
Step 2: Include useNavigate and useSearch hooks
The useNavigate hook allows for programmatic navigation anywhere in the application, while useSearch provides access to the search params state for the current location.
const navigate = useNavigate<UsersViewGenerics>();
const {pagination, filters, sorting} = useSearch<UsersViewGenerics>();
You'll notice in the next code snippet that we use navigate
to programmatically navigate using the search criteria located in the useSearch
JSON object. We'll also do our updating of filters, pagination, and sorting by accessing these values through useSearch
.
Manage Search State with useState and useEffect:
We'll work with useState
to manage state with our five search params, and the useEffect
hook will be used to programmatically navigate and update the search params with React Location. The useSearch
JSON object that gets JSURL-encoded and decoded is immutable from render to render. This means that it can be used for change-detection, even as useEffect dependencies (seen below).
const [searchText, setSearchText] = useState(filters?.searchText ?? '')
const [pageNumber, setPageNumber] =
useState < number > (pagination?.pageNumber ?? 1)
const [pageLimit, setPageLimit] =
useState < number > (pagination?.pageLimit ?? 20)
const [sortBy, setSortBy] =
useState < UsersViewSortBy > (sorting?.sortBy ?? 'firstName')
const [sortOrder, setSortOrder] =
useState < UsersViewSortOrder > (sorting?.sortOrder ?? 'asc')
React.useEffect(() => {
navigate({
search: old => {
return {
...old,
filters: {
...old?.filters,
searchText: searchText || undefined
},
sorting: {
...old?.sorting,
sortBy: sortBy,
sortOrder: sortOrder
},
pagination: {
...old?.pagination,
pageNumber: pageNumber,
pageLimit: pageLimit
}
}
},
replace: true
})
}, [
navigate,
filters,
sorting,
pagination,
searchText,
pageNumber,
pageLimit,
sortBy,
sortOrder
])
Notice that where the state variables are declared, we're setting the default values from the params that are accessible via useSearch
. This means that if for example, a user is returning after having bookmarked the page, the values will be set according to what React Location gets from the search params contained in the URL.
There is also another way we could manage the search state parameters that does not involve useState
and useEffect
. This involves setting the values explicitly as they are updated rather than doing it with useState
and useEffect
, as seen in the following code snippet.
const sortBy = sorting?.sortBy ?? 'firstName'
const pageNumber = pagination?.pageNumber ?? 1
const pageLimit = pagination?.pageLimit ?? 20
const searchText = filters?.searchText
const sortOrder = sorting?.sortOrder ?? 'asc'
const setPageNumber = (pageNumber: number) =>
navigate({
search: old => {
return {
...old,
pagination: {
...(old?.pagination ?? {}),
pageNumber
}
}
},
replace: true
})
const setPageLimit = (pageLimit: number) =>
navigate({
search: old => {
return {
...old,
pagination: {
...(old?.pagination ?? {}),
pageLimit
}
}
}
})
const setSortBy = (sortBy: UsersViewSortBy) =>
navigate({
search: old => {
return {
...old,
sorting: {
...(old?.sorting ?? {}),
sortBy
}
}
},
replace: true
})
const setSortOrder = (sortBy: UsersViewSortBy) =>
navigate({
search: old => {
return {
...old,
sorting: {
...(old?.sorting ?? {}),
sortOrder
}
}
},
replace: true
})
const setSearchText = (searchText: string) =>
navigate({
search: old => {
return {
...old,
filters: {
...(old?.filters ?? {}),
searchText
}
}
},
replace: true
})
In this example, we're still setting the values using the search params obtained from useSearch
, but rather than updating all of them in the useEffect
, we're updating each one invdividually using a method that is similar to the setState
method of useState
. When a user enters a term in the search input for example, setSearchText
will be called, which will update filters.searchText
from the useSearch
JSON object.
Update the Filter Components
The last thing that needs to happen is we need to update our filter components to accept the default values on page load. This way, if the url contains sort, filter, and pagination parameter values, the components will load those values and render the users table accordingly when the component renders. To do this, we can simply add defaultValue
parameters for the Filter components and assign them to their respective state variables.
The final code for the UsersView
container with React Location-enabled filtering, pagination, and sorting looks like this:
import React, {useState} from 'react';
import {useTenantUsers} from 'data-provider';
import {LoadingSpinner} from 'modules/common';
import {WarningAlert} from 'modules/common';
import {Grid} from 'modules/common/components';
import {Filter} from 'modules/user-store/components';
import {MakeGenerics, useSearch, useNavigate} from 'react-location';
type UsersViewSortBy = 'firstName' | 'lastName' | 'email' | 'primaryId';
type UsersViewSortOrder = 'asc' | 'desc';
//These generics are managed by React Location
type UsersViewGenerics = MakeGenerics<{
Search: {
pagination?: {
pageNumber?: number;
pageLimit?: number;
};
filters?: {
searchText?: string;
};
sorting?: {
sortBy?: UsersViewSortBy;
sortOrder?: UsersViewSortOrder;
};
};
}>;
export const UsersView = () => {
const navigate = useNavigate<UsersViewGenerics>();
const {pagination, filters, sorting} = useSearch<UsersViewGenerics>();
const [searchText, setSearchText] = useState(filters?.searchText ?? '');
const [pageNumber, setPageNumber] = useState<number>(pagination?.pageNumber ?? 1);
const [pageLimit, setPageLimit] = useState<number>(pagination?.pageLimit ?? 20);
const [sortBy, setSortBy] = useState<UsersViewSortBy>(sorting?.sortBy ?? 'firstName');
const [sortOrder, setSortOrder] = useState<UsersViewSortOrder>(sorting?.sortOrder ?? 'asc');
//Table data comes from this hook, which we get from the data-provider using react-query.
//Whenever any of the five search params are updated, the query will get new data for the users table.
const {isLoading, data, isError, isFetching} = useTenantUsers({
pageNumber,
pageLimit,
searchText,
sortBy,
sortOrder,
});
//react-location filter state updates in useEffect with useNavigate and useSearch RL hooks
React.useEffect(() => {
navigate({
search: (old) => {
return {
...old,
filters: {
...old?.filters,
searchText: searchText || undefined,
},
sorting: {
...old?.sorting,
sortBy: sortBy,
sortOrder: sortOrder,
},
pagination: {
...old?.pagination,
pageNumber: pageNumber,
pageLimit: pageLimit,
},
};
},
replace: true,
});
console.info(pagination, filters, sorting);
}, [searchText, pageNumber, pageLimit, sortBy, sortOrder]);
if (isLoading) {
return <LoadingSpinner />;
}
if (isError) {
return <WarningAlert>Could not get users. There was an error</WarningAlert>;
}
if (!data) {
return <WarningAlert>Could not get users. There is no data</WarningAlert>;
}
if (data) {
return (
<>
<Container>
<Filter
onPageLimitChange={(limit) => setPageLimit(limit)}
onSearchTextChange={(text) => setSearchText(text)}
onSortByChange={(sortBy) => setSortBy(sortBy)}
onSortOrderChange={(sortOrder) => setSortOrder(sortOrder)}
sortBy={sortBy}
sortOrder={sortOrder}
searchText={searchText}
pageLimit={pageLimit}
/>
</Container>
<Container>
<HeaderAndButtons />
<Grid.Container>
<Grid.Row>
<Grid.Col>
{isFetching ? <LoadingSpinner /> : <UsersTable usersList={data} />}
</Grid.Col>
</Grid.Row>
<Grid.Row>
<Grid.Col>
{
<UsersPagination
total={data.totalTenantUsersCount}
pageLimit={pageLimit}
setPageNumber={setPageNumber}
pageNumber={pageNumber}
/>
}
</Grid.Col>
</Grid.Row>
</Grid.Container>
</Container>
</>
);
}
};
You'll notice in the previous code example the addition of another hook, useTenantUsers
. This hook comes from a data-provider package that utiltizes React Query for data synchonization and provides us with the data for the users table.
It may be difficult to see the url bar in the image above, so here is what the JSURL-encoded URL looks like after running a search for "test", setting the number of items to display to 35, then navigating to page 5: http://localhost:20000/admin/UsersView?filters=~(searchText~-test)&sorting=~(sortBy~-firstName~sortOrder~-asc)&pagination=~(pageNumber~5~pageLimit~-35)
Integrating React Location into an administrative UI like this and using it to manage search state is super easy and provides users with the added benefit of being able to save the application state and return back to it without having to enter a bunch of search criteria again. This is especially helpful for advanced search and filter interfaces where it can take some time to acquire an exact dataset.