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
Update usersAtom in atoms/user.ts to declare their reactivity keys:
import { Duration, Effect } from "effect";import { Atom } from "effect/unstable/reactivity";
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* () { const userService = yield* UserService; return yield* userService.getUsers(); }), ) .pipe(Atom.setIdleTTL(Duration.hours(1)), Atom.withReactivity(["users"]));
27 collapsed lines
// ============ 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); }),);
// ============ Add User Atom ============export const addUserAtom = atomRuntime.fn<AddUserFormValues>()( Effect.fnUntraced(function* (user) { const userService = yield* UserService; return yield* userService.addUser(user); }),);Now update deleteUserAtom to trigger invalidation:
28 collapsed lines
import { Duration, Effect } from "effect";import { Atom } from "effect/unstable/reactivity";
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* () { const userService = yield* UserService; 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* () { 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); }), { reactivityKeys: ["users"] },);
7 collapsed lines
// ============ Add User Atom ============export const addUserAtom = atomRuntime.fn<AddUserFormValues>()( Effect.fnUntraced(function* (user) { const userService = yield* UserService; 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 trash icon on any user card and confirm. The toast appears, and the user card disappears from the grid. You no longer need to manually refresh the page. 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-react";import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";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 { AddUserFormStandardSchema, 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: standardSchemaResolver(AddUserFormStandardSchema), 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.findErrorOption(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, reactivityKeys handles everything automatically. For cross-page mutations, an explicit refresh before navigation ensures the data is up to date.