Adding Search

Avatar of Hemanta SundarayHemanta Sundaray

Right now, the home page shows a placeholder text User Search above the user grid. Let’s replace it with an actual search input and wire up the search logic.

Updating the getUsers Method

Update getUsers in services/user-service.ts to accept a search query 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,
);
10 collapsed lines
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(
query: string,
page: number,
): Effect.Effect<UsersResponse, GetUsersError> {
const request = HttpClientRequest.get(`${apiBaseUrl}/users`).pipe(
HttpClientRequest.setUrlParams({
q: query,
_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,
}),
);
},
}),
);
}
105 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,
}),
),
}),
);
}
// ============ Delete User ============
function deleteUser(
userId: string,
): Effect.Effect<void, DeleteUserError> {
return client.del(`${apiBaseUrl}/users/${userId}`).pipe(
Effect.asVoid,
Effect.delay("1 second"),
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 query argument, which we pass as the q URL parameter. This is what json-server uses for full-text search. When you pass q=john, it returns all users where any field contains "john".

Creating the Search Atom

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

atoms/search.ts
import { Atom } from "@effect-atom/atom-react";
export const searchQueryAtom = Atom.searchParam("q");
export const debouncedSearchQueryAtom = searchQueryAtom.pipe(
Atom.debounce("300 millis"),
);

Since the search query is already a string and URL parameters are strings, Atom.searchParam returns a Writable<string> directly. There’s no need for a schema or an Option wrapper, unlike pageAtom where we needed Schema.NumberFromString to parse the string into a number.

Atom.debounce

Atom.debounce() creates a debounced version of an Atom. When the source Atom changes, the debounced Atom waits for the specified duration before updating. If another change occurs during that wait, the timer resets.

This is essential for search. Without debouncing, every keystroke triggers an API request. Type “john” and you’d fire four requests: “j”, “jo”, “joh”, “john”. With a 300ms debounce, we only fire one request after the user stops typing.

Updating the Users Atom

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

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 { debouncedSearchQueryAtom } from "@/atoms/search";
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);
const query = get(debouncedSearchQueryAtom);
return yield* UserService.getUsers(query, 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"]);
}),
);

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

components/user-search-bar.tsx
"use client";
import { useAtom, useAtomSet } from "@effect-atom/atom-react";
import { pageAtom } from "@/atoms/page";
import { searchQueryAtom } from "@/atoms/search";
import { Icons } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export function UserSearchBar() {
const [query, setQuery] = useAtom(searchQueryAtom);
const setPage = useAtomSet(pageAtom);
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value);
setPage(1);
}
function handleClear() {
setQuery("");
setPage(1);
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Escape") {
handleClear();
}
}
return (
<div className="relative">
<Icons.search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder="Search users..."
value={query}
onChange={handleChange}
onKeyDown={handleKeyDown}
className="search-input pl-9 pr-20"
/>
{query && (
<Button
variant="outline"
onClick={handleClear}
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-sm px-2 text-xs"
>
ESC
</Button>
)}
</div>
);
}

The component is a controlled input driven by searchQueryAtom. We use useAtom to get both the current search query and its setter, making the input’s value always reflect the URL’s q parameter.

The handleChange function does two things whenever the user types. It updates the search query atom with the new input value, and it resets the page back to 1. Resetting the page is important because search results change the total number of available pages. If you’re on page 3 and search for something that only has one page of results, staying on page 3 would show no results even though matches exist.

For clearing the search, we provide two mechanisms. Clicking the ESC button or pressing the Escape key both call handleClear, which sets the query to an empty string and resets the page.

Notice that we don’t debounce anything in this component. The input updates searchQueryAtom on every keystroke, so the URL’s q parameter and the input value stay perfectly in sync. The debouncing happens one layer deeper: baseUsersAtom reads from debouncedSearchQueryAtom, which waits 300ms after the last change before updating. This separation means the user sees their typing reflected immediately in both the input and the URL, while the expensive API call only fires once they pause.

When the user types, we update the search query and reset to page 1. It doesn’t make sense to stay on page 3 when search results might only have one page.

Updating the UserGridSuspense Component

Previously, when the users list was empty, UserGridSuspense rendered the grid with zero cards and no message. That was fine before search existed, but now there are three distinct reasons why no users might appear: the search query matched nothing, the page number exceeds the available pages, or the list is genuinely empty. The getNoUsersFoundReason helper in user-empty-card.tsx already handles this logic. We just need to give it the information it needs.

Update components/user-grid-suspense.tsx as shown below:

components/user-grid-suspense.tsx
"use client";
import {
useAtomRefresh,
useAtomSuspense,
useAtomValue,
} from "@effect-atom/atom-react";
import { Cause } from "effect";
import { FailureCard } from "@/components/failure-card";
import {
getNoUsersFoundReason,
UserEmptyCard,
} from "@/components/user-empty-card";
import { UserSuccessCard } from "@/components/user-success-card";
import { getErrorInfo } from "@/lib/utils";
import { pageAtom } from "@/atoms/page";
import { debouncedSearchQueryAtom } from "@/atoms/search";
import { usersAtom } from "@/atoms/user";
export function UserGridSuspense() {
const result = useAtomSuspense(usersAtom, { includeFailure: true });
const refresh = useAtomRefresh(usersAtom);
const searchQuery = useAtomValue(debouncedSearchQueryAtom);
const page = useAtomValue(pageAtom);
if (result._tag === "Failure") {
const error = Cause.squash(result.cause);
const { title, message } = getErrorInfo(error);
return <FailureCard title={title} message={message} onRetry={refresh} />;
}
const users = result.value.users;
if (users.length === 0) {
return (
<UserEmptyCard reason={getNoUsersFoundReason(users, searchQuery, page)} />
);
}
return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{users.map((user) => (
<UserSuccessCard key={user.id} user={user} waiting={result.waiting} />
))}
</div>
);
}

We read debouncedSearchQueryAtom and pageAtom via useAtomValue, then check whether the result array is empty before rendering the grid. If it is, we pass the users, search query, and page to getNoUsersFoundReason, which determines the appropriate message. If the search query is non-empty, it returns "no-matching-search-results". If the page is greater than 1 with no search query, it returns "page-out-of-range". Otherwise, it falls back to "empty-users-list".

Type the name of a user in the search bar. After a brief delay (the 300ms debounce), the user list filters to match your query. The URL updates to include the search parameter (e.g., ?q=emma).

If nothing matches the search query, you’ll see a message indicating no users were found.

Sign in to save progress

Stay in the loop

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