When you click the Add User link on the home page, you’re taken to a page with an “Add New User” form. Fill it out and submit—nothing happens. That’s because the onSubmit handler in components/add-user-form.tsx is empty. Let’s wire it up.
Adding the addUser Method
In services/user-service.ts, add the addUser method:
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:
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:
"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 anExit. - 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.