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
import { Effect, Layer, ServiceMap } from "effect";
import {
FetchHttpClient,
HttpClient,
HttpClientRequest,
HttpClientResponse,
} from "effect/unstable/http";
import { apiBaseUrlConfig } from "@/lib/config";
import { USERS_PER_PAGE } from "@/lib/constants";
import { mapHttpError } from "@/lib/http-error";
import { ParseError, type HttpError } from "@/errors";
import {
UserSchema,
UsersSchema,
type AddUserFormValues,
type User,
type UsersResponse,
} from "@/schema/user-schema";
export class UserService extends ServiceMap.Service<UserService>()(
"app/UserService",
{
make: Effect.gen(function* () {
const client = (yield* HttpClient.HttpClient).pipe(
HttpClient.filterStatusOk,
);
const apiBaseUrl = yield* apiBaseUrlConfig;
// ============ Get Users ============
function getUsers(page: number): Effect.Effect<UsersResponse, HttpError> {
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.catchTag("HttpClientError", (error) =>
Effect.fail(mapHttpError(error)),
),
Effect.catchTag("SchemaError", (error) =>
Effect.fail(
new ParseError({
message: "Received an unexpected response from the server.",
cause: error,
}),
),
),
);
}
61 collapsed lines
// ============ Get User ============
function getUser(id: string): Effect.Effect<User, HttpError> {
return client.get(`${apiBaseUrl}/users/${id}`).pipe(
Effect.delay("1 second"),
Effect.flatMap(HttpClientResponse.schemaBodyJson(UserSchema)),
Effect.catchTag("HttpClientError", (error) =>
Effect.fail(mapHttpError(error)),
),
Effect.catchTag("SchemaError", (error) =>
Effect.fail(
new ParseError({
message: "Received an unexpected response from the server.",
cause: error,
}),
),
),
);
}
// ============ Delete User ============
function deleteUser(userId: string): Effect.Effect<void, HttpError> {
return client.del(`${apiBaseUrl}/users/${userId}`).pipe(
Effect.delay("1 second"),
Effect.asVoid,
Effect.catchTag("HttpClientError", (error) =>
Effect.fail(mapHttpError(error)),
),
);
}
// ============ Add User ============
function addUser(
user: AddUserFormValues,
): Effect.Effect<User, HttpError> {
return HttpClientRequest.post(`${apiBaseUrl}/users`).pipe(
HttpClientRequest.bodyJson(user),
Effect.delay("1 second"),
Effect.flatMap(client.execute),
Effect.flatMap(HttpClientResponse.schemaBodyJson(UserSchema)),
Effect.catchTag("HttpClientError", (error) =>
Effect.fail(mapHttpError(error)),
),
Effect.catchTag("SchemaError", (error) =>
Effect.fail(
new ParseError({
message: "Received an unexpected response from the server.",
cause: error,
}),
),
),
);
}
return { getUsers, getUser, deleteUser, addUser };
}),
},
) {
static layer = Layer.effect(this, this.make).pipe(
Layer.provide(FetchHttpClient.layer),
);
}

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/unstable/reactivity";
export const pageQueryParamAtom = Atom.searchParam(
"page",
) as unknown as Atom.Writable<string, string>;
export const pageAtom = Atom.writable(
(get) => {
const rawPage = get(pageQueryParamAtom);
if (rawPage === "") {
return 1;
}
const parsedPage = Number(rawPage);
if (!Number.isInteger(parsedPage) || parsedPage < 1) {
return 1;
}
return parsedPage;
},
(ctx, page: number) => {
const nextPage = Math.max(1, Math.floor(page));
if (nextPage === 1) {
ctx.set(pageQueryParamAtom, "");
} else {
ctx.set(pageQueryParamAtom, String(nextPage));
}
},
);

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). URL query parameters are always strings, so pageQueryParamAtom holds a raw string value. When the URL contains ?page=2, the Atom’s value is "2". When there’s no ?page= parameter, the Atom’s value is an empty string "".

We could use pageQueryParamAtom directly in our components, but working with raw strings everywhere would be inconvenient. We’d need to parse the string into a number every time we read the value, handle the empty string case, and convert numbers back to strings 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, which lets you read other Atoms. Here, we use get(pageQueryParamAtom) to read the current URL parameter as a raw string. If the string is empty (no ?page= in the URL), we default to 1. Otherwise, we parse it into a number. If the parsed value isn’t a valid positive integer, we fall back to 1 as a safeguard. This means components can work with a plain number instead of dealing with string parsing.

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 an empty string, 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 convert the number to a string and set it, 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 { Duration, Effect } from "effect";
import { Atom, Reactivity } from "effect/unstable/reactivity";
import { atomRuntime } from "@/atom-runtime";
import { pageAtom } from "@/atoms/page";
import { type AddUserFormValues } from "@/schema/user-schema";
import { UserService } from "@/services/user-service";
const baseUsersAtom = atomRuntime
.atom((get) =>
Effect.gen(function* () {
const page = get(pageAtom);
const userService = yield* UserService;
return yield* userService.getUsers(page);
}),
)
.pipe(Atom.setIdleTTL(Duration.hours(1)), Atom.withReactivity(["users"]));
44 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* () {
const userService = yield* UserService;
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) {
const userService = yield* UserService;
yield* userService.deleteUser(userId);
}),
{ reactivityKeys: ["users"] },
);
// ============ Add User Atom ============
export const addUserAtom = atomRuntime.fn<AddUserFormValues>()(
Effect.fnUntraced(function* (user) {
const userService = yield* UserService;
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 { useAtom, useAtomValue } from "@effect/atom-react";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { Icons } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { USERS_PER_PAGE } from "@/lib/constants";
import { pageAtom } from "@/atoms/page";
import { usersAtom } from "@/atoms/user";
const selectUsersCount = (result: Atom.Type<typeof usersAtom>) =>
AsyncResult.map(result, (data) => data.usersCount);
export function UserPagination() {
const [page, setPage] = useAtom(pageAtom);
const usersCountResult = useAtomValue(usersAtom, selectUsersCount);
return AsyncResult.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="mt-4 flex items-center justify-between border-t py-4">
<p className="text-muted-foreground text-sm">
Page {page} of {totalPages}
</p>
<div className="flex items-center space-x-2">
<Button
variant="secondary"
size="sm"
onClick={() => setPage((page) => 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) => 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 20 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.

Last updated on March 11, 2026

Sign in to save progress

Stay in the loop

Get notified when new Effect Atom related content is published.