Adding a User

Avatar of Hemanta SundarayHemanta Sundaray

Adding the addUser Method

In services/user-service.ts, add the addUser method:

services/user-service.ts
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,
}),
),
),
);
101 collapsed lines
// ============ Get Users ============
function getUsers(): Effect.Effect<UsersResponse, GetUsersError> {
const request = HttpClientRequest.get(`${apiBaseUrl}/users`).pipe(
HttpClientRequest.setUrlParams({
_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,
}),
);
},
}),
);
}
// ============ 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,
},
) {}

Adding the Add User Atom

In atoms/user.ts, add the following:

atoms/user.ts
import { Atom } from "@effect-atom/atom-react";
import { Duration, Effect } from "effect";
import { atomRuntime } from "@/atom-runtime";
import { type AddUserFormValues } from "@/schema/user-schema";
import { UserService } from "@/services/user-service";
// ============ Users Atom ============
export const usersAtom = atomRuntime
.atom(
Effect.gen(function* () {
return yield* UserService.getUsers();
}),
)
.pipe(Atom.setIdleTTL(Duration.hours(1)));
// ============ User Atom ============
export const userAtom = Atom.family((id: string) =>
atomRuntime
.atom(
Effect.gen(function* () {
return yield* UserService.getUser(id);
}),
)
.pipe(Atom.setIdleTTL(Duration.hours(1))),
);
// ============ Delete User Atom ============
export const deleteUserAtom = atomRuntime.fn(
Effect.fnUntraced(function* (userId: string) {
yield* UserService.deleteUser(userId);
}),
);
// ============ Add User Atom ============
export const addUserAtom = atomRuntime.fn<AddUserFormValues>()(
Effect.fnUntraced(function* (user) {
return yield* UserService.addUser(user);
}),
);

This follows the same pattern as deleteUserAtom. Notice the syntax is slightly different. atomRuntime.fn<AddUserFormValues>()() explicitly specifies the input type as a generic parameter. With deleteUserAtom, TypeScript could infer the input type from the userId: string annotation. Here we pass the type explicitly so that user inside the function is correctly typed as AddUserFormValues.

Updating the onSubmit Handler

Update components/add-user-form.tsx as shown below:

components/add-user-form.tsx
"use client";
import { startTransition, useId, useState } from "react";
import { useRouter } from "next/navigation";
import { useAtomSet } from "@effect-atom/atom-react";
import { effectTsResolver } from "@hookform/resolvers/effect-ts";
import { Cause, Exit, Option } from "effect";
import { useForm } from "react-hook-form";
import { useProgress } from "react-transition-progress";
import { toast } from "sonner";
import {
FormErrorMessage,
FormFieldErrorMessage,
} from "@/components/form-messages";
import { Icons } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { addUserAtom } from "@/atoms/user";
import {
AddUserFormSchema,
type AddUserFormValues,
} from "@/schema/user-schema";
export function AddUserForm() {
const id = useId();
const router = useRouter();
const startProgress = useProgress();
const [globalError, setGlobalError] = useState<string | null>(null);
const addUser = useAtomSet(addUserAtom, { mode: "promiseExit" });
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<AddUserFormValues>({
resolver: effectTsResolver(AddUserFormSchema),
defaultValues: {
firstName: "",
lastName: "",
email: "",
company: { name: "", title: "" },
address: { address: "", city: "", state: "" },
},
});
const onSubmit = async (data: AddUserFormValues) => {
setGlobalError(null);
const exit = await addUser(data);
if (Exit.isSuccess(exit)) {
const user = exit.value;
toast.success("User Added Successfully", {
description: `${user.firstName} ${user.lastName} was added successfully.`,
});
startTransition(() => {
startProgress();
router.push("/");
});
} else {
const failureOption = Cause.failureOption(exit.cause);
const errorMessage = Option.isSome(failureOption)
? failureOption.value.message
: "An unexpected error occurred";
setGlobalError(errorMessage);
}
};
return (
146 collapsed lines
<Card>
<CardContent className="mt-4">
<FormErrorMessage message={globalError} />
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* First Name */}
<div className="space-y-2">
<Label htmlFor={`${id}-firstName`}>First Name</Label>
<Input
id={`${id}-firstName`}
{...register("firstName")}
aria-invalid={!!errors.firstName}
aria-describedby={
errors.firstName ? `${id}-firstName-error` : undefined
}
/>
<FormFieldErrorMessage
id={`${id}-firstName-error`}
message={errors.firstName?.message}
/>
</div>
{/* Last Name */}
<div className="space-y-2">
<Label htmlFor={`${id}-lastName`}>Last Name</Label>
<Input
id={`${id}-lastName`}
{...register("lastName")}
aria-invalid={!!errors.lastName}
aria-describedby={
errors.lastName ? `${id}-lastName-error` : undefined
}
/>
<FormFieldErrorMessage
id={`${id}-lastName-error`}
message={errors.lastName?.message}
/>
</div>
{/* Email */}
<div className="space-y-2">
<Label htmlFor={`${id}-email`}>Email</Label>
<Input
id={`${id}-email`}
type="email"
{...register("email")}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? `${id}-email-error` : undefined}
/>
<FormFieldErrorMessage
id={`${id}-email-error`}
message={errors.email?.message}
/>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Company Name */}
<div className="space-y-2">
<Label htmlFor={`${id}-companyName`}>Company Name</Label>
<Input
id={`${id}-companyName`}
{...register("company.name")}
aria-invalid={!!errors.company?.name}
aria-describedby={
errors.company?.name ? `${id}-companyName-error` : undefined
}
/>
<FormFieldErrorMessage
id={`${id}-companyName-error`}
message={errors.company?.name?.message}
/>
</div>
{/* Job Title */}
<div className="space-y-2">
<Label htmlFor={`${id}-jobTitle`}>Job Title</Label>
<Input
id={`${id}-jobTitle`}
{...register("company.title")}
aria-invalid={!!errors.company?.title}
aria-describedby={
errors.company?.title ? `${id}-jobTitle-error` : undefined
}
/>
<FormFieldErrorMessage
id={`${id}-jobTitle-error`}
message={errors.company?.title?.message}
/>
</div>
</div>
{/* Address Info */}
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label htmlFor={`${id}-address`}>Street Address</Label>
<Input
id={`${id}-address`}
{...register("address.address")}
aria-invalid={!!errors.address?.address}
/>
<FormFieldErrorMessage
id={`${id}-address-error`}
message={errors.address?.address?.message}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor={`${id}-city`}>City</Label>
<Input
id={`${id}-city`}
{...register("address.city")}
aria-invalid={!!errors.address?.city}
/>
<FormFieldErrorMessage
id={`${id}-city-error`}
message={errors.address?.city?.message}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-state`}>State</Label>
<Input
id={`${id}-state`}
{...register("address.state")}
aria-invalid={!!errors.address?.state}
/>
<FormFieldErrorMessage
id={`${id}-state-error`}
message={errors.address?.state?.message}
/>
</div>
</div>
</div>
<div className="flex justify-end pt-4">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && (
<Icons.spinner className="size-4 animate-spin" />
)}
Add User
</Button>
</div>
</form>
</CardContent>
</Card>
);
}

This follows the same pattern as the delete handler:

  • Clear previous errors: Reset any error state before attempting the operation.
  • Execute the mutation: await addUser(data) submits the form data and returns an Exit.
  • Handle success: Extract the created user from exit.value, show a success toast, then redirect to the home page.
  • Handle failure: Extract the error message and display it on top of the form.

Testing Add User

Fill out the Add User form with valid data and submit. You should see a success toast, and you’ll be redirected to the home page.

But just like with deletion, the new user doesn’t appear in the list. Refresh the page manually, and you’ll see the newly added user. We have the same problem: the user grid’s cache is stale, and it doesn’t know that the data has changed. We’ll fix this next.

Sign in to save progress

Stay in the loop

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