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 { 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;
61 collapsed lines
// ============ Get Users ============
function getUsers(): Effect.Effect<UsersResponse, HttpError> {
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.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,
}),
),
),
);
}
// ============ 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),
);
}

Adding the Add User Atom

In atoms/user.ts, add the the addUserAtom:

atoms/user.ts
import { Duration, Effect } from "effect";
import { Atom } from "effect/unstable/reactivity";
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* () {
const userService = yield* UserService;
return yield* userService.getUsers();
}),
)
.pipe(Atom.setIdleTTL(Duration.hours(1)));
// ============ User Atom ============
export const userAtom = Atom.family((id: string) =>
atomRuntime
.atom(
Effect.gen(function* () {
const userService = yield* UserService;
return yield* userService.getUser(id);
}),
)
.pipe(Atom.setIdleTTL(Duration.hours(1))),
);
// ============ Delete User Atom ============
export const deleteUserAtom = atomRuntime.fn(
Effect.fnUntraced(function* (userId: string, get) {
const userService = yield* UserService;
yield* userService.deleteUser(userId);
}),
);
// ============ Add User Atom ============
export const addUserAtom = atomRuntime.fn<AddUserFormValues>()(
Effect.fnUntraced(function* (user) {
const userService = yield* UserService;
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-react";
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
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 {
AddUserFormStandardSchema,
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: standardSchemaResolver(AddUserFormStandardSchema),
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.findErrorOption(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.

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.