Deleting a User

Avatar of Hemanta SundarayHemanta Sundaray

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:

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:

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:

components/user-delete-dialog.tsx
"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 a Promise<Exit<void, DeleteUserError>>.
  • Set loading state: Before starting the operation, we set isDeleting to true and clear any previous errors.
  • Execute the mutation: await deleteUser(user.id) runs the Effect and returns an Exit.
  • 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 the Cause and 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.

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.