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
- 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.
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
Effect Atom uses the @effect/experimental package for the reactivity
feature.
Update usersAtom in atoms/user.ts to declare their reactivity keys:
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); }),);Now update deleteUserAtom to trigger invalidation:
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:
"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.