So far, we’ve only read data. But real applications also need to mutate data: create, update, and delete. Let’s learn how to handle deletion using Effect Atom.
Deleting a User
Each user card has a trash icon in the top-right corner. Click it, and a confirmation dialog appears. Click Delete—nothing happens. That’s because the handleDelete function inside components/user-delete-dialog.tsx is empty. Let’s fix that.
Adding the deleteUser Method
First, add the deleteUser method to your UserService in 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 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;
50 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)), ), ); }
return { getUsers, getUser, deleteUser }; }), },) { static layer = Layer.effect(this, this.make).pipe( Layer.provide(FetchHttpClient.layer), );}Adding the Delete User Atom
Now add the delete user Atom in atoms/user.ts:
import { Duration, Effect } from "effect";import { Atom } from "effect/unstable/reactivity";
import { atomRuntime } from "@/atom-runtime";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) { const userService = yield* UserService; yield* userService.deleteUser(userId); }),);Instead of atomRuntime.atom(), which we’ve used so far, we’re using atomRuntime.fn() here.
Let’s understand what this method does and why mutations need it.
Understanding atomRuntime.fn()
So far, we’ve used atomRuntime.atom() to create Atoms. An Atom created with .atom() runs its Effect automatically when a component subscribes to it. For example, the moment UserGridSuspense mounts and calls useAtomSuspense(usersAtom), the Effect executes and fetches the data. You don’t trigger it manually. The Atom also caches the result, tracks dependencies, and recomputes when those dependencies change.
Mutations don’t work this way. A delete operation shouldn’t run automatically when a component mounts. It should run only when the user explicitly clicks the Delete button. This is what atomRuntime.fn() provides. It creates a function Atom that sits idle until you invoke it with an argument. Nothing executes until you call it.
There’s another difference worth noting. An Atom created with .atom() holds persistent state. It always has a current value (the list of users, a single user’s data) that components can read at any time. A function Atom created with .fn() represents a one-off command. It tracks the result of the latest invocation, not an ongoing piece of state. Once the delete completes, the result tells you whether it succeeded or failed, but there’s no persistent value to cache and share across components.
To use a function Atom in a component, we use useAtomSet:
const deleteUser = useAtomSet(deleteUserAtom, { mode: "promiseExit" });const exit = await deleteUser(userId);The mode: "promiseExit" option makes the function return a Promise<Exit<A, E>>. An Exit is Effect’s way of representing the outcome of an operation. Either success with a value, or failure with a cause. This gives you explicit control over error handling.
Adding User Deletion Logic
Now update components/user-delete-dialog.tsx as shown below:
"use client";
import { useState } from "react";import { useAtomSet } from "@effect/atom-react";import { Cause, Exit, Option } from "effect";import { toast } from "sonner";
import { Icons } from "@/components/icons";import { Button } from "@/components/ui/button";import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger,} from "@/components/ui/dialog";
import { deleteUserAtom } from "@/atoms/user";import type { User } from "@/schema/user-schema";
interface UserDeleteDialogProps { user: User; trigger: React.ReactNode;}
export function UserDeleteDialog({ user, trigger }: UserDeleteDialogProps) { const [open, setOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [error, setError] = useState<string | null>(null);
const deleteUser = useAtomSet(deleteUserAtom, { mode: "promiseExit" });
function handleOpenChange(isOpen: boolean) { setOpen(isOpen); if (!isOpen) { setError(null); } }
async function handleDelete() { setIsDeleting(true); setError(null);
const exit = await deleteUser(user.id);
setIsDeleting(false);
if (Exit.isSuccess(exit)) { setOpen(false); toast.success("User deleted successfully", { description: `${user.firstName} ${user.lastName} has been deleted.`, }); } else { const failureOption = Cause.findErrorOption(exit.cause); const errorMessage = Option.isSome(failureOption) ? failureOption.value.message : "An unexpected error occurred"; setError(errorMessage); } }
return (36 collapsed lines
<Dialog open={open} onOpenChange={handleOpenChange}> <DialogTrigger asChild>{trigger}</DialogTrigger> <DialogContent className="sm:max-w-110"> <DialogHeader> <DialogTitle> Delete {user.firstName} {user.lastName}? </DialogTitle> <DialogDescription> Are you sure? This action cannot be undone. </DialogDescription> </DialogHeader>
{error && ( <div className="flex items-center gap-2 border border-red-200 bg-red-50 p-3 text-sm text-red-600"> <Icons.alert className="size-4 shrink-0" /> <span>{error}</span> </div> )}
<DialogFooter> <DialogClose asChild> <Button variant="outline" disabled={isDeleting}> Cancel </Button> </DialogClose> <Button variant="destructive" onClick={handleDelete} disabled={isDeleting} > {isDeleting && <Icons.spinner className="size-4 animate-spin" />} Delete </Button> </DialogFooter> </DialogContent> </Dialog> );}Let’s break down what this code does:
- Get the mutation function:
useAtomSet(deleteUserAtom, { mode: "promiseExit" })returns a callable function that returns aPromise<Exit<void, DeleteUserError>>. - Set loading state: Before starting the operation, we set
isDeletingtotrueand clear any previous errors. - Execute the mutation:
await deleteUser(user.id)runs the Effect and returns anExit. - Handle the result: We use
Exit.isSuccess()to check if the operation succeeded. On success, we close the dialog and show a toast. On failure, we extract the error message from theCauseand display it.
Testing User Deletion
Click the trash icon on any user card, then click Delete in the confirmation dialog. You should see a toast confirming the user was deleted.
But wait, the user card is still visible. Refresh the page manually. Now the deleted user is gone.
The deletion works, but requiring a manual refresh isn’t acceptable. The user grid should update automatically. We’ll address this in the upcoming Refreshing Data After Mutation chapter.