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:
- Bookmarkability: Users can bookmark specific pages
Here’s our plan for implementing pagination:
- Add a
pagequery parameter to the request made to the/usersendpoint. - 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:
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:
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 agetparameter — the sameAtom.Contextfunction we saw earlier — which lets you read other Atoms. Here, we useget(pageQueryParamAtom)to read the current URL parameter. SincepageQueryParamAtomreturnsOption<number>, we useOption.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 ofOption<number>. - Write function: Defines what happens when a component calls
setPage(newPage). The function receives two parameters:ctx, which provides methods likectx.set()for updating other Atoms, andpage, which is the value passed by the component. Inside, we updatepageQueryParamAtombased on thepagenumber. When the page is 1, we set the parameter toOption.none(), which removes?page=from the URL entirely. (there’s no need to show?page=1since page 1 is the default.) For any other page, we set it toOption.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:
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:
"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:
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.