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 delete icon in the top-right corner. Click it, and a confirmation dialog appears. Click the Delete button—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 {
FetchHttpClient,
HttpClient,
HttpClientRequest,
HttpClientResponse,
} from "@effect/platform";
import { Effect } from "effect";
import { apiBaseUrlConfig } from "@/lib/config";
import { USERS_PER_PAGE } from "@/lib/constants";
import {
ConfigError,
DeleteUserError,
DeleteUserRequestError,
DeleteUserResponseError,
GetUserError,
GetUserParseError,
GetUserRequestError,
GetUserResponseError,
GetUsersError,
GetUsersParseError,
GetUsersRequestError,
GetUsersResponseError,
} from "@/errors";
import {
User,
UserSchema,
UsersSchema,
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,
}),
),
),
);
76 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.delay("1 second"),
Effect.asVoid,
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,
}),
),
}),
);
}
return { getUsers, getUser, deleteUser };
}),
dependencies: [FetchHttpClient.layer],
accessors: true,
},
) {}

Adding the Delete User Atom

Now add the delete user Atom in atoms/user.ts:

atoms/user.ts
import { Atom } from "@effect-atom/atom-react";
import { Duration, Effect } from "effect";
import { atomRuntime } from "@/atom-runtime";
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);
}),
);

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()

With atomRuntime.atom(), we create an Atom that holds a value, tracks dependencies, and notifies subscribers when that value changes. It’s designed for data that persists and updates over time.

Mutations are different. A delete operation doesn’t produce persistent state. It performs an action and completes. This is what atomRuntime.fn() provides. It creates a function Atom that:

  • Executes an Effect when invoked
  • Integrates with the same runtime and services as your other Atoms

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/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.failureOption(exit.cause);
const errorMessage = Option.isSome(failureOption)
? failureOption.value.message
: "An unexpected error occurred";
setError(errorMessage);
}
}
return (
37 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 delete 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.

Sign in to save progress

Stay in the loop

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