Spring Data REST sorting and paging with tanstack router and react query (part 4)

In the previous two articles part 2, part 3 we have built a Spring Boot application with a React and Svelte frontend that allowed us to list and edit clubs stored in a database.

Previously, we focused on validation and the frontend applications. In this article, I want to focus more on sorting and paging in the React version of the client app. For Svelte, most of the changes should be equivalent.

Paging and sorting

In order to see paging in action we simply add a few more rows into our table using the REST API.

Data Preparation

This can be done very easily with httpie.

1
http POST :8080/api/clubs clubName=club3 managerEmail=manager@club3.com

produces

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
HTTP/1.1 201
Connection: keep-alive
Content-Type: application/json
Date: Sun, 05 Feb 2023 08:04:32 GMT
Keep-Alive: timeout=60
Location: http://localhost:8080/api/clubs/01GRG9NYHG8A8XMM6W8V2415R4
Transfer-Encoding: chunked
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers

{
    "_links": {
        "club": {
            "href": "http://localhost:8080/api/clubs/01GRG9NYHG8A8XMM6W8V2415R4"
        },
        "self": {
            "href": "http://localhost:8080/api/clubs/01GRG9NYHG8A8XMM6W8V2415R4"
        }
    },
    "clubName": "club3",
    "id": "01GRG9NYHG8A8XMM6W8V2415R4",
    "managerEmail": "manager@club3.com"
}

This basically sends a POST request with the following json object body

1
2
3
4
{ 
  "clubName": "club3",
  "managerEmail": "manager@club3.com"
}

So we now repeat this POST call for a couple more times to get at least 6 or more rows into the table.

1
2
3
http POST :8080/api/clubs clubName=club4 managerEmail=manager@club4.com
http POST :8080/api/clubs clubName=club5 managerEmail=manager@club5.com
http POST :8080/api/clubs clubName=club6 managerEmail=amanager@club6.com

Note that for club 6 I changed the email so that we get a different order when sorting by managerEmail.

Paging and sorting in action

On the Spring Boot side, we are already done. Spring Data REST automatically supports paging and sorting out of the box as our ClubRepository inherits from PagingAndSortingRepository (see the documentation).

If we check the response to GET http://localhost:8080/api/clubs more closely we will find that there is already paging information built in:

1
http :8080/api/clubs

produces

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
    "_embedded": {
        "clubs": [
           ...
        ]
      },
    "_links": {...},
    "page": {
        "number": 0,
        "size": 20,
        "totalElements": 2,
        "totalPages": 1
    }
}

So from the response it becomes clear that Spring Data REST already automatically applies paging with a page size of 20 elements, and in our result list there are currently only two elements and therefore only a single page.

We can now play with the api to test out sorting, by default Spring Data REST supports the following query parameters on the list endpoint

  • page: The page number to access (0 indexed, defaults to 0).
  • size: The page size requested (defaults to 20).
  • sort: A collection of sort directives in the format ($propertyname,)+[asc|desc]?.

Lets sort by clubName

1
2
3
4
5
6
7
http ":8080/api/clubs?sort=clubName" | jq '._embedded.clubs[].clubName'
"club1"
"club2"
"club3"
"club4"
"club5"
"club6"

Note that this requires a unix like shell and having httpie and jq to be installed.

Here we simply request all clubs to be sorted by clubName and then just extract the clubName field from the presented list of clubs.

We can also sort by managerEmail:

1
2
3
4
5
6
7
http ":8080/api/clubs?sort=managerEmail" | jq '._embedded.clubs[].clubName'
"club6"
"club1"
"club2"
"club3"
"club4"
"club5"

Now club6 is on top since it has the alphabetically smallest managerEmail.

We can also reduce the page size to enable paging.

1
2
3
4
http ":8080/api/clubs?sort=managerEmail&size=3" | jq '._embedded.clubs[].clubName'
"club6"
"club1"
"club2"

Display the 2nd page:

1
2
3
4
http ":8080/api/clubs?sort=managerEmail&size=3&page=1" | jq '._embedded.clubs[].clubName'
"club3"
"club4"
"club5"

Cool, with this information we should be able to add this to our react client.

Adding paging and sorting to the React UI

In order to get better type safety we start with defining a TypeScript interface for our server response in src/api/api.ts.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
export interface PageResponseInfo {
  size: number
  totalElements: number
  totalPages: number
  number: number
}

export interface ClubsResponse {
  _embedded: {
    clubs: Club[]
  }
  page: PageResponseInfo
}

Then we update our fetchClubs method to allow us to add paging and sorting parameters.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
export type ClubSort = keyof Club
export type SortDirection = 'asc' | 'desc'
export type SortCriterium<T> = { column: T; dir: SortDirection }

export async function fetchClubs(
  pageNum: number,
  pageSize: number,
  sort?: SortCriterium<ClubSort>,
) {
  const queryParams = new URLSearchParams()
  queryParams.append('size', `${pageSize}`)
  queryParams.append('page', `${pageNum}`)
  if (sort) {
    queryParams.append('sort', 
      `${sort.column},${sort.dir}` // (1)
    )
  }
  const result = await fetch('/api/clubs')
  return await extractJsonOrError<ClubsResponse>(result)
}

ClubSort only accepts attributes of the Club object, this allows us to get type-safe sort parameters. Also note that (1) is the place where we convert from our internal SortCriterium representation to the Spring Data REST API.

Now that we have those extra parameters we need to update our useClubsQuery method to be able to pass those on.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
export interface PagingInfo {
  pageNum: number
  pageSize: number
}

export function useClubsQuery(
  pageInfo: PagingInfo,
  sort?: SortCriterium<ClubSort>,
) {
  return useQuery({
    queryKey: ['clubs', { sort, pageInfo }],
    queryFn: ctx =>
      fetchClubs(pageInfo.pageNum, pageInfo.pageSize, sort),
  })
}

Now this is where it gets interesting. Previously, the queryKey was simply ['clubs'], now we need to include all the query parameters into the query key to enable automatic refetching as soon as the queryKey changes.

Note that unlike React dependencies, query keys are always deterministically serialized, which means that dynamically recreating the query key all the time will only lead to a re-fetching if the query key object has actually changed (i.e. no === comparison but more of a deepEquals semantic), for more information see the documentation.

We can now integrate the paging and sorting functionality in the UI. In the first step we will do the naive approach where we ignore the functionality of TanStack router and will use useState exclusively for now.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
const rowsPerPageOptions = [2, 5, 10, 20]

export const ClubListPage: FC = () => {
  // (2) state for sorting / paging
  const [sort, setSort] = useState<SortCriterium<ClubSort>>(['clubName', 'asc'])
  const [pageInfo, setPageInfo] = useState<PagingInfo>({
    pageNum: 0,
    pageSize: 5,
  })

  const { data, error, isLoading } = useClubsQuery(pageInfo, sort) // (3)
  const clubs = data?._embedded?.clubs // (4)

  const navigate = useNavigate({ from: clubsRoute.id }) // (5)

  return (
    <div>
      <h3>Clubs List!</h3>
      {error && <Message severity={'error'}>{JSON.stringify(error)}</Message>}
      <DataTable
        value={clubs}
        responsiveLayout="scroll"
        loading={isLoading}
        selectionMode="single"
        onSelectionChange={e => {
          navigate({
            to: '/clubs/$clubId',
            params: { clubId: e.value.id },
          }).catch(console.error)
        }}
        onSort={e => {
          console.log('sort changed', e)
          setSort(
              {
                column: e.sortField as ClubSort,
                dir: e.sortOrder === 1 ? 'asc' : 'desc',
              }
          )
        }}
        paginator={true}
        rows={data?.page?.totalElements}
        rowsPerPageOptions={rowsPerPageOptions}
        first={
          (data?.page?.number ?? 0) * (data?.page?.size ?? pageInfo.pageSize)
        }
        sortField={sort.column}
        sortOrder={sort.dir === 'asc' ? 1 : -1}
        dataKey="id"
      >
        <Column field="id" header="ID"/>
        <Column field="clubName" header="Club Name" sortable={true}/>
        <Column
          field="managerEmail"
          header="managerEmail"
          sortable={true}
        />
      </DataTable>
    </div>
  )
}

As a first step (see (2)) we create state variables for sorting and paging.

Then in (3) we simply use our updated useClubsQuery method and pass in those state variables.

In (4), we extract the list of clubs out of the query response and finally we grab a reference to the navigate method which we can use to programmatically navigate to other pages (when the user clicks on a row, see (5)).

The rest is only now enabling sorting and paging on the PrimeReact DataTable.

The only complexity is calculating between the page number semantic from Spring Data REST to a first/count semantic which PrimeReact DataTable paginator expects.

When you now start the Development Server via pnpm dev in the client-react directory and open the browser at

http://localhost:5173/clubs

Then you will see paging and sorting live.

Sorting and Paging in action

Migrating useState to QueryParams

Now the solution we built is quite nice but there is one small problem, if we share the URL of our app the receiver of the link won’t get the same sorting and page information.

This is one of the big benefits of the new TanStack Router. It makes it very easy to move all the page state into the URL as query parameters. While this was obviously also possible previously, it required you to do a whole lot of heavy lifting to synchronize your useState variables with the browser URL. TanStack Router makes this a breeze.

To update our component we update use useClubsQuery hook

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
export function useClubsQuery(
  pageNum: number,
  pageSize: number,
  sort?: SortCriterium<ClubSort>,
) {
  return useQuery({
    queryKey: ['clubs', { sort, pageNum, pageSize }],
    queryFn: () => fetchClubs(pageNum, pageSize, sort),
  })
}

This looks very much the same as previously however, we separated the PageInfo object since we don’t need it as a combined object. Previously, I decided to create an own container object for Paging as I wanted to combine pageNum and pageSize as you would typically update both together.

Now, those values will be parsed straight out of the URL and therefore, we can leave them separate.

fetchClubs pretty much stays the same apart from the updated parameters.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
export async function fetchClubs(
  pageNum: number,
  pageSize: number,
  sort?: SortCriterium<ClubSort>,
) {
  const queryParams = new URLSearchParams()
  queryParams.append('size', `${pageSize}`)
  queryParams.append('page', `${pageNum}`)
  if (sort) {
    queryParams.append('sort', `${sort.column},${sort.dir}`)
  }
  const result = await fetch(`/api/clubs?${queryParams.toString()}`)
  return await extractJsonOrError<ClubsResponse>(result)
}

Now it gets interesting. How do we parse all the query parameters out of the URL?

First we update the clubsRoute definition as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
export const ClubPageSearchParams = z.object({
  pageNum: z.number().optional().default(0),
  pageSize: z.number().optional().default(5),
  sort: z
    .object({
      column: ClubSchema.keyof(),
      dir: z.enum(['asc', 'desc']),
    })
    .optional()
    .default({ column: 'clubName', dir: 'asc' }),
})

export const clubsRoute = new Route({
  getParentRoute: () => rootRoute,
  path: '/clubs',
  component: ClubListPage,
  validateSearch: searchObj => ClubPageSearchParams.parse(searchObj),
})

We basically define a zod schema for the query params and now get both type safe query params as well as automatic validation of the parameters. This is quite ingenious and super handy (and IMHO will change the way we will work with client side routers in the future).

Now in our page component, we can simply useSearch and get the correctly typed query parameters out.

1
2
3
4
5
6
7
8
export const ClubListPage: FC = () => {
  const search = useSearch({ from: clubsRoute.id, strict: true })
  const { data, error, isLoading } = useClubsQuery(
    search.pageNum,
    search.pageSize,
    [search.sort as ClubSort, search.dir],
    undefined
  )

We pass them straight to the useClubsQuery hook and the rest pretty much stays the same. The only remaining difference is the handling of paging/sorting change in our PrimeReact DataTable

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
 <DataTable
          lazy={true}
          value={clubs}
          responsiveLayout="scroll"
          loading={isLoading}
          ...
          onSort={e => {
            console.log('sort changed', e)
            navigate({  // (6)
              to: clubsRoute.id,
              search: {
                ...search,
                sort: {
                  column: e.sortField as ClubSort,
                  dir: e.sortOrder === 1 ? 'asc' : 'desc',
                },
              },
            }).catch(console.error)
          }}
          onPage={e => {
            console.log('page changed', e)
            navigate({
              to: clubsRoute.id,
              search: { ...search, pageNum: e.page, pageSize: e.rows },
            }).catch(console.error)
          }}
          paginator={true}
          ...

On every sorting/paging related change we simply navigate to the same page with updated search params (see (6)).

Whenever the sorting changes we go to our current page and update the search object with the new sort value which contains both the sort-column and the sort-direction.

On the next re-render our useSearch call will receive the updated search params and re-fetch the useClubsQuery automatically.

There you have it, we have a fully type safe sorting/paging experience where the whole page state is stored in the browser URL. A shared URL will contain all the relevant information including the current page and sort order.

Not only does this improve user experience since users can bookmark their favorite sorting/paging information but also it makes developing a lot nicer as well as you don’t need to re-play the same steps when reloading the page, you are straight to where you left off.

I hope you are as excited as I am about the advancements in routing that TanStack Router will bring us once its done.

If you plan to use TanStack router in your projects, be sure to stay up to date on the latest changes and expect a few breaking API changes along the way. Hopefully, we will reach a 1.0 soon which will broaden the appeal even more.

This concludes part 4 of the tutorial. In the next article we will look at filtering in more detail.

You can find the completed sources on GitHub.

About the author
Rainer Burgstaller is an architect, full stack developer on the endless quest to maximise developer productivity.

read more from Rainer