Published on

Managing React UI Search and Filter State in the URL

Authors

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.

UsersView page holding search state in the URL

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.