Adding Pagination

Avatar of Hemanta SundarayHemanta Sundaray

Our db.json has 12 users, and right now we display all of them on the home page. Let’s say we want to display only 8 users per page. This means we need pagination.

Before we start, let’s decide where to store the pagination state. We could use React state, but storing the page number in the URL is a better choice because it gives us several benefits:

  • Browser navigation: Back and forward buttons move between pages as expected
  • Bookmarkability: Users can bookmark specific pages

Here’s our plan for implementing pagination:

  • Add a page query parameter to the request made to the /users endpoint.
  • Create a page Atom that syncs with the URL’s ?page= parameter.
  • Connect the page Atom to the users Atom so changing the page triggers a refetch.
  • Wire up the pagination component to read and update the page.

Updating the getUsers Function

Update the getUsers function in services/user-service.ts to accept a page parameter:

services/user-service.ts
36 collapsed lines
import {
FetchHttpClient,
HttpClient,
HttpClientRequest,
HttpClientResponse,
} from "@effect/platform";
import { Effect } from "effect";
import { apiBaseUrlConfig } from "@/lib/config";
import { USERS_PER_PAGE } from "@/lib/constants";
import {
AddUserBodySerializationError,
AddUserError,
AddUserParseError,
AddUserRequestError,
AddUserResponseError,
ConfigError,
DeleteUserError,
DeleteUserRequestError,
DeleteUserResponseError,
GetUserError,
GetUserParseError,
GetUserRequestError,
GetUserResponseError,
GetUsersError,
GetUsersParseError,
GetUsersRequestError,
GetUsersResponseError,
} from "@/errors";
import {
User,
UserSchema,
UsersSchema,
type AddUserFormValues,
type UsersResponse,
} from "@/schema/user-schema";
export class UserService extends Effect.Service<UserService>()(
"app/UserService",
{
effect: Effect.gen(function* () {
const client = (yield* HttpClient.HttpClient).pipe(
HttpClient.filterStatusOk,
);
const apiBaseUrl = yield* apiBaseUrlConfig.pipe(
Effect.catchTag("ConfigError", (error) =>
Effect.fail(
new ConfigError({
message: "API base URL is not configured.",
cause: error,
}),
),
),
);
// ============ Get Users ============
function getUsers(
page: number,
): Effect.Effect<UsersResponse, GetUsersError> {
const request = HttpClientRequest.get(`${apiBaseUrl}/users`).pipe(
HttpClientRequest.setUrlParams({
_page: page.toString(),
_limit: USERS_PER_PAGE.toString(),
}),
);
return client.execute(request).pipe(
Effect.delay("1 second"),
Effect.flatMap((response) =>
Effect.all({
users: HttpClientResponse.schemaBodyJson(UsersSchema)(response),
usersCount: Effect.succeed(
Number(response.headers["x-total-count"]),
),
}),
),
Effect.catchTags({
RequestError: (requestError) =>
Effect.fail(
new GetUsersRequestError({
message: "Failed to get users",
cause: requestError,
}),
),
ResponseError: (responseError) =>
Effect.fail(
new GetUsersResponseError({
message: `Failed to get users: status ${responseError.response.status}`,
cause: responseError,
}),
),
ParseError: (parseError) => {
return Effect.fail(
new GetUsersParseError({
message: "Failed to parse getUsers response",
cause: parseError,
}),
);
},
}),
);
}
104 collapsed lines
// ============ Get User ============
function getUser(id: string): Effect.Effect<User, GetUserError> {
return client.get(`${apiBaseUrl}/users/${id}`).pipe(
Effect.delay("1 second"),
Effect.flatMap(HttpClientResponse.schemaBodyJson(UserSchema)),
Effect.catchTags({
RequestError: (requestError) =>
Effect.fail(
new GetUserRequestError({
message: `Failed to get user ${id}`,
cause: requestError,
}),
),
ResponseError: (responseError) =>
Effect.fail(
new GetUserResponseError({
message: `Failed to get user ${id}: status ${responseError.response.status}`,
cause: responseError,
}),
),
ParseError: (parseError) =>
Effect.fail(
new GetUserParseError({
message: "Failed to parse getUser response",
cause: parseError,
}),
),
}),
);
}
function deleteUser(
userId: string,
): Effect.Effect<void, DeleteUserError> {
return client.del(`${apiBaseUrl}/users/${userId}`).pipe(
Effect.delay("1 second"),
Effect.asVoid,
Effect.catchTags({
RequestError: (requestError) =>
Effect.fail(
new DeleteUserRequestError({
message: `Failed to delete user ${userId}`,
cause: requestError,
}),
),
ResponseError: (responseError) =>
Effect.fail(
new DeleteUserResponseError({
message: `Failed to delete user ${userId}: status ${responseError.response.status}`,
cause: responseError,
}),
),
}),
);
}
// ============ Add User ============
function addUser(
user: AddUserFormValues,
): Effect.Effect<User, AddUserError> {
return HttpClientRequest.post(`${apiBaseUrl}/users`).pipe(
HttpClientRequest.bodyJson(user),
Effect.delay("1 second"),
Effect.flatMap(client.execute),
Effect.flatMap(HttpClientResponse.schemaBodyJson(UserSchema)),
Effect.catchTags({
HttpBodyError: (err) =>
Effect.fail(
new AddUserBodySerializationError({
message: "Failed to serialize addUser request body",
cause: err,
}),
),
RequestError: (err) =>
Effect.fail(
new AddUserRequestError({
message: "Failed to create user request",
cause: err,
}),
),
ResponseError: (err) =>
Effect.fail(
new AddUserResponseError({
message: `Failed to create user: status ${err.response.status}`,
cause: err,
}),
),
ParseError: (err) =>
Effect.fail(
new AddUserParseError({
message: "Failed to parse created user response",
cause: err,
}),
),
}),
);
}
return { getUsers, getUser, deleteUser, addUser };
}),
dependencies: [FetchHttpClient.layer],
accessors: true,
},
) {}

The function now takes a page argument, which we pass as the _page URL parameter, which json-server uses for pagination. When you request _page=2 with _limit=8, json-server returns users 9 through 16.

Creating the Page Atom

In atoms/page.ts, add the following code:

atoms/page.ts
import { Atom } from "@effect-atom/atom-react";
import { Option, Schema } from "effect";
export const pageQueryParamAtom = Atom.searchParam("page", {
schema: Schema.NumberFromString,
});
export const pageAtom = Atom.writable(
(get) => get(pageQueryParamAtom).pipe(Option.getOrElse(() => 1)),
(ctx, page: number) => {
if (page === 1) {
ctx.set(pageQueryParamAtom, Option.none());
} else {
ctx.set(pageQueryParamAtom, Option.some(page));
}
},
);

Here we’ve used two new APIs: Atom.searchParam and Atom.writable. Let’s understand what each one does.

First, we create pageQueryParamAtom using Atom.searchParam. This API creates an Atom that syncs with a URL query parameter (in our case, the page parameter). The schema option tells Effect Atom how to transform the value. URL parameters are always strings, but we want to work with numbers. Schema.NumberFromString handles this conversion. It parses “2” into 2 when reading, and converts back when writing.

The Atom’s value is Option<number> because the parameter might not exist in the URL. When someone visits without a ?page= parameter, the Atom returns Option.none().

We could use pageQueryParamAtom directly in our components, but working with Option<number> everywhere would be inconvenient. We’d need to unwrap the Option and handle the “no page parameter” case every time we read the value. We’d also need to wrap values in Option.some() every time we write.

This is why we create pageAtom using Atom.writable. This API creates an Atom with custom read and write logic. It takes two functions:

  • Read function: This function determines what value components get when they read pageAtom. The function receives a get parameter — the same Atom.Context function we saw earlier — which lets you read other Atoms. Here, we use get(pageQueryParamAtom) to read the current URL parameter. Since pageQueryParamAtom returns Option<number>, we use Option.getOrElse(() => 1) to unwrap it. If the URL has a ?page= parameter, we get that number; if not (the user is on the base URL with no page parameter), we default to 1. This means components can work with a plain number instead of Option<number>.
  • Write function: Defines what happens when a component calls setPage(newPage). The function receives two parameters: ctx, which provides methods like ctx.set() for updating other Atoms, and page, which is the value passed by the component. Inside, we update pageQueryParamAtom based on the page number. When the page is 1, we set the parameter to Option.none(), which removes ?page= from the URL entirely. (there’s no need to show ?page=1 since page 1 is the default.) For any other page, we set it to Option.some(page), which adds ?page=2, ?page=3, etc. to the URL.

Updating the Users Atom

Update baseUsersAtom in atoms/user.ts to use the page:

atoms/user.ts
import { Atom } from "@effect-atom/atom-react";
import { Reactivity } from "@effect/experimental";
import { Duration, Effect } from "effect";
import { atomRuntime } from "@/atom-runtime";
import { type AddUserFormValues } from "@/schema/user-schema";
import { UserService } from "@/services/user-service";
import { pageAtom } from "./page";
const baseUsersAtom = atomRuntime
.atom((get) =>
Effect.gen(function* () {
const page = get(pageAtom);
return yield* UserService.getUsers(page);
}),
)
.pipe(Atom.setIdleTTL(Duration.hours(1)), Atom.withReactivity(["users"]));
41 collapsed lines
// ============ Users Atom ============
export const usersAtom = (
typeof window !== "undefined"
? Atom.refreshOnWindowFocus(baseUsersAtom)
: baseUsersAtom
).pipe(Atom.setIdleTTL(Duration.hours(1)), Atom.withReactivity(["users"]));
// ============ User Atom ============
export const userAtom = Atom.family((id: string) => {
const base = atomRuntime
.atom(
Effect.gen(function* () {
return yield* UserService.getUser(id);
}),
)
.pipe(Atom.setIdleTTL(Duration.hours(1)));
return typeof window !== "undefined" ? Atom.refreshOnWindowFocus(base) : base;
});
// ============ Delete User Atom ============
export const deleteUserAtom = atomRuntime.fn(
Effect.fnUntraced(function* (userId: string) {
yield* UserService.deleteUser(userId);
}),
{ reactivityKeys: ["users"] },
);
// ============ Add User Atom ============
export const addUserAtom = atomRuntime.fn<AddUserFormValues>()(
Effect.fnUntraced(function* (user) {
return yield* UserService.addUser(user);
}),
);
// ============ Invalidate Users Atom ============
export const invalidateUsersAtom = atomRuntime.fn()(
Effect.fnUntraced(function* () {
yield* Reactivity.invalidate(["users"]);
}),
);

Notice that the function passed to atomRuntime.atom() now receives a get parameter. This is an Atom.Context function that lets you read the value of other Atoms. Here, we use get(pageAtom) to read the current page number and pass it to UserService.getUsers().

But get(pageAtom) does more than just read a value. It also establishes a dependency between baseUsersAtom and pageAtom. This dependency is key. When pageAtom changes from 1 to 2, Effect Atom automatically invalidates baseUsersAtom. The Atom refetches with the new page value, and the UI updates.

Updating the Pagination Component

Replace the code inside components/user-pagination.tsx with the following:

components/user-pagination.tsx
"use client";
import { Result, useAtom, useAtomValue } from "@effect-atom/atom-react";
import { pageAtom } from "@/atoms/page";
import { usersAtom } from "@/atoms/user";
import { Icons } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { ConfigError, GetUsersError } from "@/errors";
import { USERS_PER_PAGE } from "@/lib/constants";
import { UsersResponse } from "@/schema/user-schema";
const selectUsersCount = (
result: Result.Result<UsersResponse, ConfigError | GetUsersError>,
) => Result.map(result, (data) => data.usersCount);
export function UserPagination() {
const [page, setPage] = useAtom(pageAtom);
const usersCountResult = useAtomValue(usersAtom, selectUsersCount);
return Result.match(usersCountResult, {
onInitial: () => null,
onFailure: () => null,
onSuccess: (result) => {
const totalPages = Math.ceil(result.value / USERS_PER_PAGE);
if (totalPages <= 1) return null;
return (
<div className="flex items-center justify-between py-4 border-t mt-4">
<p className="text-sm text-muted-foreground">
Page {page} of {totalPages}
</p>
<div className="flex items-center space-x-2">
<Button
variant="secondary"
size="sm"
onClick={() => setPage(page - 1)}
disabled={page <= 1}
className="hover:bg-neutral-200"
>
<Icons.chevronLeft className="size-4" />
Previous
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setPage(page + 1)}
disabled={page >= totalPages}
className="hover:bg-neutral-200"
>
Next
<Icons.chevronRight className="size-4" />
</Button>
</div>
</div>
);
},
});
}

This component introduces the useAtom hook. While useAtomValue only reads and useAtomSet only writes, useAtom gives you both:

const [page, setPage] = useAtom(pageAtom);

This returns a tuple like React’s useState. When you call setPage(2), it triggers the write function we defined in pageAtom, which updates the URL.

Testing Pagination

The pagination logic is complete. The only thing left is to update the USERS_PER_PAGE constant. Inside lib/constants.ts, change its value from 12 to 8:

lib/constants.ts
export const USERS_PER_PAGE = 8;

Visit the home page. You should see 8 user cards and a pagination component below the grid showing “Page 1 of 2.” Click Next. The URL updates to ?page=2, and the grid shows the remaining 4 users (assuming you have 12 users in db.json). Click Previous. The ?page= parameter disappears from the URL since page 1 is the default, and you’re back to the first 8 users. Try using your browser’s back and forward buttons. They navigate between pages as expected.

Sign in to save progress

Stay in the loop

Get notified when new chapters are added and when this course is complete.