Using Automatic Cache Invalidation

Avatar of Hemanta SundarayHemanta Sundaray

When we delete a user, the user list should update automatically. Users shouldn’t need to manually refresh the page to see their changes. Effect Atom provides a reactivity system that handles exactly this.

How Reactivity Works

Effect Atom’s reactivity is built on a simple concept: shared keys.

  • Tag Atoms with keys: You mark Atoms with reactivity keys that describe what data they represent. For example, the users Atom might have the key "users".
  • Tag mutations with keys: You mark mutations with the same keys to indicate what data they affect. A delete user mutation would also use the key "users".
  • Automatic invalidation: When a mutation tagged with "users" completes successfully, Effect Atom automatically invalidates (refreshes) all Atoms tagged with "users".

This creates a declarative system where you describe relationships once, and the framework handles synchronization.

Note

Automatic invalidation only works when the target Atom has active subscribers. If no component is currently subscribed to the Atom, the invalidation signal has no effect. We’ll see why this matters shortly.

Setting Up Reactivity

Note

Effect Atom uses the @effect/experimental package for the reactivity feature.

Update usersAtom in atoms/user.ts to declare their reactivity keys:

atoms/user.ts
import { Atom } from "@effect-atom/atom-react";
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"]));
24 collapsed lines
// ============ 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);
}),
);
// ============ Add User Atom ============
export const addUserAtom = atomRuntime.fn<AddUserFormValues>()(
Effect.fnUntraced(function* (user) {
return yield* UserService.addUser(user);
}),
);

Atom.withReactivity(["users"]) tags this Atom with the "users" key. Any mutation that invalidates "users" will cause this Atom to refresh.

Now update deleteUserAtom to trigger invalidation:

atoms/user.ts
26 collapsed lines
import { Atom } from "@effect-atom/atom-react";
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"] },
);
6 collapsed lines
// ============ Add User Atom ============
export const addUserAtom = atomRuntime.fn<AddUserFormValues>()(
Effect.fnUntraced(function* (user) {
return yield* UserService.addUser(user);
}),
);

The { reactivityKeys: ["users"] } option tells the reactivity system: “When this operation succeeds, invalidate everything tagged with 'users'.”

Testing Automatic Invalidation

Let’s verify that everything works.

Testing User Deletion

Click the delete icon on any user card and confirm. The toast appears, and—without refreshing—the user card disappears from the grid. The usersAtom automatically refetched the data because the deleteUserAtom mutation invalidated the "users" key.

This works because when you delete a user, you’re on the home page. The UserGridSuspense component is mounted and subscribed to usersAtom. When the reactivity system invalidates the "users" key, there’s an active subscriber listening, so the Atom refetches immediately.

Why Reactivity Keys Don’t Work for Adding Users

You might wonder: why not add reactivityKeys: ["users"] to addUserAtom as well?

The problem is that when you add a user, you’re on the /add-user page. The UserGridSuspense component is not mounted, which means no component is subscribed to usersAtom. The reactivity system invalidates the "users" key, but since nothing is listening, the invalidation has no effect. When you navigate back to the home page, usersAtom still holds its old cached data. You won’t see the newly added user.

Reactivity keys are subscription-driven. They work when the target Atom has active subscribers. For mutations that happen on the same page as the data they affect (like delete), they work perfectly. For mutations that happen on a different page (like add), we need a different approach.

Refreshing Before Navigation

The solution is to explicitly refresh usersAtom after adding a user, before navigating back to the home page.

Update components/add-user-form.tsx as shown below:

components/add-user-form.tsx
"use client";
import { startTransition, useId, useState } from "react";
import { useRouter } from "next/navigation";
import { useAtomRefresh, useAtomSet } from "@effect-atom/atom-react";
import { effectTsResolver } from "@hookform/resolvers/effect-ts";
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, usersAtom } from "@/atoms/user";
import {
AddUserFormSchema,
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 refreshUsers = useAtomRefresh(usersAtom);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<AddUserFormValues>({
resolver: effectTsResolver(AddUserFormSchema),
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;
refreshUsers();
toast.success("User Added Successfully", {
description: `${user.firstName} ${user.lastName} was added successfully.`,
});
startTransition(() => {
startProgress();
router.push("/");
});
} else {
const failureOption = Cause.failureOption(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>
);
}

useAtomRefresh takes an Atom and returns a function that, when called, tells the Registry to invalidate the Atom and re-run its Effect. We call refreshUsers() after a successful addition, before navigating to the home page. This ensures usersAtom fetches fresh data that includes the newly added user.

Testing User Addition

Click Add User, fill out the form, and submit. After the redirect, you should see your newly added user in the list.

The reactivity system keeps your UI synchronized with your data. For same-page mutations like delete, reactivityKeys handles everything automatically. For cross-page mutations like add, an explicit refresh before navigation ensures the data is up to date.

Sign in to save progress

Stay in the loop

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