Using Manual Cache Invalidation

Avatar of Hemanta SundarayHemanta Sundaray

In the previous chapter, we used reactivityKeys to automatically refresh the user list after deletions, and useAtomRefresh to explicitly refresh after additions. These cover most scenarios. But sometimes you need more control. Perhaps you want to invalidate based on a condition, or trigger a refresh from a component after some user interaction that isn’t tied to a specific mutation. For these cases, you can create an invalidation Atom.

Creating an Invalidation Atom

In atoms/user.ts, add the invalidateUsersAtom:

atoms/user.ts
import { Atom } from "@effect-atom/atom-react";
import { Reactivity } from "@effect/experimental";
import { Duration, Effect } from "effect";
import { atomRuntime } from "@/atom-runtime";
import { type AddUserFormValues } from "@/schema/user-schema";
import { UserService } from "@/services/user-service";
34 collapsed lines
// ============ Users Atom ============
export const usersAtom = atomRuntime
.atom(
Effect.gen(function* () {
return yield* UserService.getUsers();
}),
)
.pipe(Atom.setIdleTTL(Duration.hours(1)), Atom.withReactivity(["users"]));
// ============ 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);
}),
{ reactivityKeys: ["users"] },
);
// ============ Add User Atom ============
export const addUserAtom = atomRuntime.fn<AddUserFormValues>()(
Effect.fnUntraced(function* (user) {
return yield* UserService.addUser(user);
}),
);
// ============ Invalidate Users ============
export const invalidateUsersAtom = atomRuntime.fn()(
Effect.fnUntraced(function* () {
yield* Reactivity.invalidate(["users"]);
}),
);

This creates a function Atom that, when called, invalidates all Atoms tagged with the "users" key. Keep in mind that, like reactivityKeys, this uses the reactivity system and only reaches Atoms with active subscribers.

Using Manual Invalidation

To see how this works, let’s temporarily use manual invalidation for the user deletion flow.

First, comment out the reactivityKeys option on deleteUserAtom in atoms/user.ts:

atoms/user.ts
import { Atom } from "@effect-atom/atom-react";
import { Reactivity } from "@effect/experimental";
import { Duration, Effect } from "effect";
import { atomRuntime } from "@/atom-runtime";
import { type AddUserFormValues } from "@/schema/user-schema";
import { UserService } from "@/services/user-service";
19 collapsed lines
// ============ Users Atom ============
export const usersAtom = atomRuntime
.atom(
Effect.gen(function* () {
return yield* UserService.getUsers();
}),
)
.pipe(Atom.setIdleTTL(Duration.hours(1)), Atom.withReactivity(["users"]));
// ============ 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);
}),
// { reactivityKeys: ["users"] },
);
13 collapsed lines
// ============ Add User Atom ============
export const addUserAtom = atomRuntime.fn<AddUserFormValues>()(
Effect.fnUntraced(function* (user) {
return yield* UserService.addUser(user);
}),
);
// ============ Invalidate Users Atom ============
export const invalidateUsersAtom = atomRuntime.fn()(
Effect.fnUntraced(function* () {
yield* Reactivity.invalidate(["users"]);
}),
);

Now in components/user-delete-dialog.tsx, import and call the invalidation Atom after a successful deletion:

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, invalidateUsersAtom } 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" });
const invalidateUsers = useAtomSet(invalidateUsersAtom);
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)) {
invalidateUsers(undefined);
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 (
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>
);
}

We call invalidateUsers(undefined) after confirming the deletion succeeded. useAtomSet always returns a setter function that expects one argument, which is the input to the function Atom. With deleteUserAtom, that argument is the user ID. With addUserAtom, it’s the form data. But invalidateUsersAtom doesn’t need any input; it just triggers an invalidation. Since the setter function still requires an argument, we pass undefined to satisfy it.

When to Use Each Approach

For same-page mutations like delete, automatic invalidation via reactivityKeys is the right fit. For cross-page mutations like add, useAtomRefresh handles it directly. Manual invalidation via invalidateUsersAtom is useful when you need to refresh data based on events that aren’t tied to a mutation Atom. For example, a ‘Refresh’ button, or after a complex multi-step workflow where you only want to invalidate at the end.

Since automatic invalidation is the right choice here, let’s revert our changes. In atoms/user.ts, uncomment the reactivityKeys option on deleteUserAtom:

atoms/user.ts
27 collapsed lines
import { Atom } from "@effect-atom/atom-react";
import { Reactivity } from "@effect/experimental";
import { Duration, Effect } from "effect";
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* () {
return yield* UserService.getUsers();
}),
)
.pipe(Atom.setIdleTTL(Duration.hours(1)), Atom.withReactivity(["users"]));
// ============ 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);
}),
{ reactivityKeys: ["users"] },
);
13 collapsed lines
// ============ Add User Atom ============
export const addUserAtom = atomRuntime.fn<AddUserFormValues>()(
Effect.fnUntraced(function* (user) {
return yield* UserService.addUser(user);
}),
);
// ============ Invalidate Users Atom ============
export const invalidateUsersAtom = atomRuntime.fn()(
Effect.fnUntraced(function* () {
yield* Reactivity.invalidate(["users"]);
}),
);

Then in components/user-delete-dialog.tsx, remove the invalidateUsersAtom import and the await invalidateUsers() call. The reactivityKeys on deleteUserAtom already handles the invalidation.

Keep the invalidateUsersAtom definition in atoms/user.ts though. It’s a useful reference for when you need manual control in the future.

Sign in to save progress

Stay in the loop

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