React 19 Changed Where I Draw the Line Between UI and Data
Server Actions and the use() hook moved the boundary between component and data layer in ways that change how I design component APIs.
React 19 didn't just ship new hooks. It shifted where the component boundary is responsible for data decisions. Before React 19, the line between UI and data layer was managed through conventions — custom hooks that called fetch, wrappers around SWR or React Query, API routes that sat between the component and the database. React 19 makes some of those conventions unnecessary and others obsolete. Server Actions, the use() hook, useOptimistic, and useFormStatus together change which decisions belong in a component and which decisions belong outside it.
Server Actions Replace a Category of Boilerplate
Before React 19, a form submission required: a client-side handler that called fetch, an API route that received the request, validated input, ran the mutation, and returned a response. The client then handled the response — updating state, showing errors, triggering revalidation.
Server Actions collapse this. A function marked "use server" runs on the server and can be passed directly to a form's action prop:
// actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
export async function createProject(formData: FormData) {
const name = formData.get("name") as string;
if (!name || name.trim().length === 0) {
return { error: "Project name is required" };
}
const project = await db.project.create({ data: { name: name.trim() } });
revalidatePath("/projects");
return { project };
}
// CreateProjectForm.tsx
import { createProject } from "./actions";
import { useFormStatus } from "react-dom";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending} aria-busy={pending}>
{pending ? "Creating..." : "Create project"}
</button>
);
}
export function CreateProjectForm() {
return (
<form action={createProject}>
<label htmlFor="name">Project name</label>
<input id="name" name="name" required />
<SubmitButton />
</form>
);
}
The SubmitButton component knows about pending state without receiving it as a prop, without a context provider, and without useState. useFormStatus reads the form's submission state from the nearest <form> ancestor. This is the architectural change: loading state for a form submission is no longer something the form component manages. The form posts; child components read the status.
What useFormStatus Changes About Component Design
Before useFormStatus, a disabled-during-submit button required the form to manage a loading state and thread it down as a prop, or a context provider wrapping the form, or a global store mutation. All of these made the submit button's API depend on the form's state shape.
With useFormStatus, the submit button is self-contained. It reads submission status from the form context that React manages. This means SubmitButton can be moved to a shared component library without any props for loading state — it always knows whether its parent form is submitting.
The design decision this changes: submit buttons no longer need a loading or isSubmitting prop. If you're still designing component APIs with those props for standard form submissions, you're carrying pre-React-19 conventions into a codebase that doesn't need them.
The use() Hook Unwraps Promises in Components
use() is not a replacement for useEffect + useState data fetching. It's a way to consume a promise that was created outside the component — passed down as a prop or coming from a server component — and read its value inside the component. React suspends the component while the promise is pending and renders it when the value is available.
// page.tsx (Server Component)
import { Suspense } from "react";
import { ProjectDetail } from "./ProjectDetail";
import { fetchProject } from "@/lib/data";
export default function Page({ params }: { params: { id: string } }) {
// Promise is created here, not awaited
const projectPromise = fetchProject(params.id);
return (
<Suspense fallback={<ProjectSkeleton />}>
<ProjectDetail projectPromise={projectPromise} />
</Suspense>
);
}
// ProjectDetail.tsx (Client Component)
"use client";
import { use } from "react";
export function ProjectDetail({ projectPromise }: { projectPromise: Promise<Project> }) {
const project = use(projectPromise); // suspends until resolved
return (
<div>
<h1>{project.name}</h1>
<p>{project.description}</p>
</div>
);
}
The boundary shift here is real: the server component starts the fetch, the client component consumes the result. Data transformation logic that used to live in the component (inside a useEffect or a custom hook) can now live in fetchProject on the server — closer to the data source, with access to server-only modules.
This changes where I put data transformation. Before, I'd transform API responses inside the component or its custom hook, because that was where the fetch lived. Now, when fetchProject runs on the server, transforming the response there is the natural place — it never crosses the wire in raw form, and the client component receives exactly the shape it needs.
useOptimistic for Updates That Feel Instant
useOptimistic gives you a temporary "optimistic" version of a state value that's overridden by the real value once the server action resolves:
"use client";
import { useOptimistic, useTransition } from "react";
import { toggleProjectFavorite } from "./actions";
export function FavoriteButton({ project }: { project: Project }) {
const [isPending, startTransition] = useTransition();
const [optimisticFavorite, setOptimisticFavorite] = useOptimistic(
project.isFavorite,
(_current, newValue: boolean) => newValue
);
function handleToggle() {
startTransition(async () => {
setOptimisticFavorite(!optimisticFavorite);
await toggleProjectFavorite(project.id);
});
}
return (
<button onClick={handleToggle} aria-pressed={optimisticFavorite}>
{optimisticFavorite ? "Unfavorite" : "Favorite"}
</button>
);
}
useOptimistic takes the current server value and an update function. Inside a transition, calling setOptimisticFavorite immediately shows the assumed result. When the server action completes, React discards the optimistic value and uses the real one. If the action fails, the optimistic value reverts automatically.
The design implication: rollback is not your problem. Before useOptimistic, managing rollback on failure required storing the previous state before mutation and restoring it in the catch block. useOptimistic manages this automatically — the optimistic value is only shown inside a pending transition. Once the transition ends (success or failure), the real value takes over.
Where I Now Draw the Line
The boundary I use:
Server (Server Actions, server components, server-side data fetching functions):
- Database access and mutations
- Input validation that requires server context (checking uniqueness against a DB, verifying permissions)
- Data transformation — normalizing API responses, computing derived values, filtering sensitive fields
- Cache invalidation (
revalidatePath,revalidateTag)
Client (Client Components with useFormStatus, useOptimistic, useTransition):
- Visual state — which element is focused, hover/active states, animation state
- Optimistic UI — the immediate assumed result of a mutation
- Form submission status — pending indicators, disabled states
- User interactions that haven't been committed yet
What's no longer in the client: the fetch call itself (for mutations), the loading state for form submissions (managed by useFormStatus), and the rollback logic for failed optimistic updates (managed by useOptimistic).
What's still in the client: anything that depends on browser APIs, user gesture state, or visual-only UI state that doesn't need to survive a navigation.
The contracts between these layers are cleaner than they were. Server Actions have explicit signatures. use() makes the promise handoff visible. useFormStatus makes submission state readable without threading props. The components that remain in the client are smaller — they receive more processed data and manage less machinery around fetching it.
React 19 didn't solve every data-fetching question. But it did give the right primitives to the right layer, and that changes the API decisions that belong in component design.