Next.js Interview Prep
Data Fetching

Server Actions for Mutations

Server Actions for Mutations

LinkedIn Hook

You build a form in Next.js. You reach for fetch("POST", "/api/...") out of habit. You create an API route. You wire up loading state. You handle errors. You manually revalidate the cache.

That was the old way. Server Actions delete half of that code.

With "use server", your form submits directly to a function on the server — no API route, no client-side fetch, no manual endpoint wiring. The form works even with JavaScript disabled. React 19 gives you useFormStatus for pending states, useActionState for form-level feedback, and useOptimistic for instant UI updates before the server responds.

But most developers get the mental model wrong. They treat server actions like RPC calls and forget about progressive enhancement, revalidation, and error boundaries. In interviews, this is where you either sound like someone who has shipped real Next.js apps or someone who only read the docs.

In this lesson, I break down the full server action workflow — from basic form submission to optimistic updates with rollback — with every pattern you need for production and interviews.

Read the full lesson -> [link]

#NextJS #React #ServerActions #JavaScript #InterviewPrep #Frontend #WebDev #100DaysOfCode


Server Actions for Mutations thumbnail


What You'll Learn

  • What server actions are and how "use server" works
  • How to handle form submissions without API routes
  • Using useFormStatus to show pending/loading states during submission
  • Using useActionState (React 19) to manage form state and validation feedback
  • Implementing optimistic updates with useOptimistic for instant UI
  • Revalidating cached data after a mutation completes
  • Error handling patterns — try/catch, returning error objects, and error boundaries

The Concept — What Are Server Actions?

Analogy: The Drive-Through Window

Imagine you are at a restaurant. In the old model, you had to:

  1. Walk up to the front desk (your React component)
  2. Write your order on a slip of paper (construct a fetch request)
  3. Hand it to a middleman standing at the counter (your /api/ route handler)
  4. The middleman walks it to the kitchen (your database/service layer)
  5. The middleman walks back with the food and hands it to you

Server Actions are like a drive-through window that opens directly into the kitchen. You hand your order through the window, the chef takes it, cooks it, and hands you the food — no middleman needed. The form talks directly to the server function.

But here is the critical part: if the restaurant's intercom breaks (JavaScript fails to load), you can still shout your order through the window. This is progressive enhancement — server actions work even without JavaScript because the form uses a standard HTML action attribute.

OLD WAY (API Routes):
  Form --> fetch() --> /api/route.ts --> DB --> Response --> setState

SERVER ACTIONS:
  Form --> action={serverFunction} --> DB --> Revalidate --> Updated UI

  No API route. No manual fetch. No client-side response handling.

Server Actions — The Basics

Defining a Server Action

A server action is an async function marked with "use server". It runs exclusively on the server — the function body is never sent to the client.

// app/actions.ts
// This file contains server actions — all exports are server functions
"use server";

import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";

// This function runs on the server when the form submits
export async function createTodo(formData: FormData) {
  const title = formData.get("title") as string;

  // Direct database access — this code never reaches the browser
  await db.todo.create({
    data: { title, completed: false },
  });

  // Tell Next.js to refresh the cached data for this path
  revalidatePath("/todos");
}

Using a Server Action in a Form

// app/todos/page.tsx
// This is a Server Component — no "use client" needed
import { createTodo } from "@/app/actions";
import { db } from "@/lib/db";

export default async function TodosPage() {
  const todos = await db.todo.findMany();

  return (
    <main>
      <h1>My Todos</h1>

      {/* The form submits directly to the server action */}
      {/* No JavaScript required — this works as a standard HTML form */}
      <form action={createTodo}>
        <input type="text" name="title" placeholder="Add a todo..." required />
        <button type="submit">Add</button>
      </form>

      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
    </main>
  );
}

What Happens Under the Hood

  1. The user fills in the form and clicks "Add"
  2. The browser sends a POST request to the current URL with the form data
  3. Next.js intercepts this request and routes it to the createTodo function
  4. The function runs on the server — it has full access to databases, file systems, environment variables
  5. After revalidatePath("/todos"), Next.js re-renders the page with fresh data
  6. The updated HTML is sent to the client — the new todo appears in the list
  7. If JavaScript is disabled, this still works as a full-page form submission

Inline Server Actions

You can also define server actions inline inside Server Components:

// app/feedback/page.tsx
export default function FeedbackPage() {
  // Inline server action — defined right where it is used
  async function submitFeedback(formData: FormData) {
    "use server";
    const message = formData.get("message") as string;
    await db.feedback.create({ data: { message } });
    revalidatePath("/feedback");
  }

  return (
    <form action={submitFeedback}>
      <textarea name="message" placeholder="Your feedback..." />
      <button type="submit">Send</button>
    </form>
  );
}

This is convenient for one-off actions that only one component uses. For shared actions, put them in a separate actions.ts file.


useFormStatus — Showing Pending State

When a form is submitting, you want to show a loading indicator and disable the submit button. React provides useFormStatus for exactly this.

Critical rule: useFormStatus must be called from a component that is a child of the <form>. It does not work if called in the same component that renders the <form> tag.

// components/SubmitButton.tsx
"use client";

import { useFormStatus } from "react-dom";

// This component MUST be rendered inside a <form>
export function SubmitButton() {
  // pending is true while the parent form's action is executing
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? "Saving..." : "Save"}
    </button>
  );
}
// app/todos/page.tsx
import { createTodo } from "@/app/actions";
import { SubmitButton } from "@/components/SubmitButton";

export default async function TodosPage() {
  const todos = await db.todo.findMany();

  return (
    <form action={createTodo}>
      <input type="text" name="title" placeholder="Add a todo..." required />
      {/* SubmitButton is a CHILD of the form — useFormStatus works */}
      <SubmitButton />
    </form>
  );
}

What useFormStatus Returns

const { pending, data, method, action } = useFormStatus();

// pending: boolean — true while the form action is running
// data: FormData | null — the form data being submitted
// method: string — the HTTP method (usually "post")
// action: function — reference to the server action being called

Common Mistake: Using useFormStatus Outside the Form

"use client";

import { useFormStatus } from "react-dom";

// BUG: This component renders the form AND tries to read status
// useFormStatus reads from the PARENT form — there is no parent here
export function BrokenForm() {
  const { pending } = useFormStatus(); // Always returns pending: false

  return (
    <form action={someAction}>
      <input name="title" />
      <button disabled={pending}>Save</button> {/* Never disables */}
    </form>
  );
}

The fix is always the same: extract the submit button (or any element that needs the status) into its own component and render it as a child of the form.


useActionState — Managing Form State and Validation

useActionState (called useFormState in earlier React versions) gives you a way to track the return value of a server action. This is how you show success messages, validation errors, or any other server-side feedback.

// app/actions.ts
"use server";

// The first parameter is the previous state (from useActionState)
// The second parameter is the form data
export async function createAccount(prevState: any, formData: FormData) {
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;

  // Server-side validation
  if (!email.includes("@")) {
    return { error: "Invalid email address", success: false };
  }

  if (password.length < 8) {
    return { error: "Password must be at least 8 characters", success: false };
  }

  try {
    await db.user.create({ data: { email, password: hashPassword(password) } });
    return { error: null, success: true };
  } catch (e) {
    return { error: "Email already exists", success: false };
  }
}
// components/SignupForm.tsx
"use client";

import { useActionState } from "react";
import { createAccount } from "@/app/actions";
import { SubmitButton } from "./SubmitButton";

// Initial state — what the form shows before any submission
const initialState = { error: null, success: false };

export function SignupForm() {
  // state: the latest return value from the server action
  // formAction: a wrapped version of createAccount to pass to <form action>
  const [state, formAction] = useActionState(createAccount, initialState);

  return (
    <form action={formAction}>
      <input type="email" name="email" placeholder="Email" required />
      <input type="password" name="password" placeholder="Password" required />

      {/* Show validation error returned from the server */}
      {state.error && <p style={{ color: "red" }}>{state.error}</p>}

      {/* Show success message */}
      {state.success && <p style={{ color: "green" }}>Account created!</p>}

      <SubmitButton />
    </form>
  );
}

How useActionState Works

  1. You define a server action that accepts (prevState, formData) and returns a state object
  2. useActionState wraps that action and manages the state lifecycle
  3. When the form submits, the action runs on the server and returns a new state
  4. The component re-renders with the updated state — showing errors, success, etc.
  5. The prevState parameter lets you access the previous return value (useful for chaining)

useFormState vs useActionState

React 18:  useFormState (from "react-dom")   — deprecated name
React 19:  useActionState (from "react")     — current name

They do the same thing. The rename happened because the hook works with
any async action, not just forms. Use useActionState in new code.

useOptimistic — Instant UI Updates

The user clicks "Add Todo." The server action takes 500ms. Without optimistic updates, the user stares at a spinner for half a second. With useOptimistic, the todo appears instantly in the list, and if the server fails, it rolls back.

// components/TodoList.tsx
"use client";

import { useOptimistic } from "react";
import { addTodo } from "@/app/actions";

type Todo = { id: string; title: string; completed: boolean };

export function TodoList({ todos }: { todos: Todo[] }) {
  // optimisticTodos: the list to render (includes optimistic additions)
  // addOptimisticTodo: function to immediately add a todo to the UI
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    // This reducer merges the optimistic update into the current state
    (currentTodos: Todo[], newTodoTitle: string) => [
      ...currentTodos,
      {
        id: "temp-" + Date.now(), // Temporary ID — replaced after server responds
        title: newTodoTitle,
        completed: false,
      },
    ]
  );

  async function handleSubmit(formData: FormData) {
    const title = formData.get("title") as string;

    // Step 1: Immediately update the UI (optimistic)
    addOptimisticTodo(title);

    // Step 2: Actually send to the server
    // If this fails, React will revert to the real 'todos' prop
    await addTodo(formData);
  }

  return (
    <div>
      <form action={handleSubmit}>
        <input type="text" name="title" placeholder="New todo..." required />
        <button type="submit">Add</button>
      </form>

      <ul>
        {optimisticTodos.map((todo) => (
          <li key={todo.id} style={{
            // Visual hint: optimistic items are slightly transparent
            opacity: todo.id.startsWith("temp-") ? 0.6 : 1,
          }}>
            {todo.title}
            {todo.id.startsWith("temp-") && " (saving...)"}
          </li>
        ))}
      </ul>
    </div>
  );
}

How useOptimistic Works

1. User submits form
2. addOptimisticTodo("Buy milk") runs synchronously
   → optimisticTodos now includes the new item → UI updates INSTANTLY
3. Server action runs asynchronously (addTodo)
4a. SUCCESS: Parent re-renders with fresh data from server
    → useOptimistic sees new 'todos' prop → replaces optimistic state with real data
4b. FAILURE: Server action throws or the parent re-renders without the new item
    → useOptimistic reverts to the real 'todos' prop → optimistic item disappears

The rollback is automatic. You do not need to manually undo the optimistic update on error — React handles this by comparing the optimistic state against the real prop value when the parent re-renders.


Revalidation After Mutations

After a server action modifies data, the cached pages and data are stale. You need to tell Next.js to refresh. There are three approaches:

1. revalidatePath — Refresh a Specific Route

"use server";

import { revalidatePath } from "next/cache";

export async function deleteTodo(todoId: string) {
  await db.todo.delete({ where: { id: todoId } });

  // Refresh the /todos page — Next.js will re-render it with fresh data
  revalidatePath("/todos");
}

2. revalidateTag — Refresh by Cache Tag

"use server";

import { revalidateTag } from "next/cache";

export async function updateProduct(formData: FormData) {
  const id = formData.get("id") as string;
  const price = parseFloat(formData.get("price") as string);

  await db.product.update({ where: { id }, data: { price } });

  // Refresh ALL cached fetches tagged with this product
  // This is more granular than revalidatePath
  revalidateTag(`product-${id}`);
}

// The corresponding fetch that uses this tag:
// fetch(`/api/products/${id}`, { next: { tags: [`product-${id}`] } })

3. redirect — Navigate After Mutation

"use server";

import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";

export async function createPost(formData: FormData) {
  const title = formData.get("title") as string;
  const content = formData.get("content") as string;

  const post = await db.post.create({ data: { title, content } });

  // Refresh the posts list page
  revalidatePath("/posts");

  // Redirect to the newly created post
  // redirect() must be called OUTSIDE of try/catch (it throws internally)
  redirect(`/posts/${post.id}`);
}

Important: redirect works by throwing a special error internally. If you wrap it in a try/catch, the catch block will swallow the redirect. Always call redirect outside of try/catch blocks, or re-throw it explicitly.


Error Handling Patterns

Instead of throwing errors, return structured error objects. This works seamlessly with useActionState.

// app/actions.ts
"use server";

type ActionResult = {
  success: boolean;
  error: string | null;
  fieldErrors?: Record<string, string>;
};

export async function updateProfile(
  prevState: ActionResult,
  formData: FormData
): Promise<ActionResult> {
  const name = formData.get("name") as string;
  const bio = formData.get("bio") as string;

  // Field-level validation
  const fieldErrors: Record<string, string> = {};
  if (name.length < 2) fieldErrors.name = "Name must be at least 2 characters";
  if (bio.length > 500) fieldErrors.bio = "Bio must be under 500 characters";

  if (Object.keys(fieldErrors).length > 0) {
    return { success: false, error: "Validation failed", fieldErrors };
  }

  try {
    await db.user.update({
      where: { id: getCurrentUserId() },
      data: { name, bio },
    });
    revalidatePath("/profile");
    return { success: true, error: null };
  } catch (e) {
    // Log the real error on the server, return a safe message to the client
    console.error("Profile update failed:", e);
    return { success: false, error: "Something went wrong. Please try again." };
  }
}
// components/ProfileForm.tsx
"use client";

import { useActionState } from "react";
import { updateProfile } from "@/app/actions";

const initialState = { success: false, error: null, fieldErrors: {} };

export function ProfileForm({ user }) {
  const [state, formAction] = useActionState(updateProfile, initialState);

  return (
    <form action={formAction}>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" defaultValue={user.name} />
        {/* Show field-specific error */}
        {state.fieldErrors?.name && (
          <span style={{ color: "red" }}>{state.fieldErrors.name}</span>
        )}
      </div>

      <div>
        <label htmlFor="bio">Bio</label>
        <textarea id="bio" name="bio" defaultValue={user.bio} />
        {state.fieldErrors?.bio && (
          <span style={{ color: "red" }}>{state.fieldErrors.bio}</span>
        )}
      </div>

      {/* Show general error */}
      {state.error && !state.fieldErrors && (
        <p style={{ color: "red" }}>{state.error}</p>
      )}

      {state.success && (
        <p style={{ color: "green" }}>Profile updated successfully!</p>
      )}

      <SubmitButton />
    </form>
  );
}

Pattern 2: Throw Errors for Error Boundaries

If a server action throws an error, the nearest error.tsx boundary catches it. Use this for unexpected/fatal errors.

// app/actions.ts
"use server";

export async function deleteAccount() {
  const userId = getCurrentUserId();
  if (!userId) {
    // This error will be caught by the nearest error.tsx
    throw new Error("You must be logged in to delete your account");
  }

  await db.user.delete({ where: { id: userId } });
  redirect("/");
}
// app/settings/error.tsx
"use client";

// This catches errors thrown by server actions within /settings
export default function SettingsError({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div>
      <h2>Something went wrong</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

When to Use Which Pattern

Return error objects:  Form validation, expected failures, user-facing messages.
                       Works with useActionState. User stays on the form.

Throw errors:          Unexpected failures, auth failures, server crashes.
                       Caught by error.tsx. Shows a full error UI.

redirect():            After successful mutations that should navigate.
                       Must be called outside try/catch.

Full Example: Complete Mutation Workflow

Here is a complete example that ties together every concept — server action, useActionState, useFormStatus, useOptimistic, revalidation, and error handling.

// app/actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";

type CommentState = {
  success: boolean;
  error: string | null;
};

export async function addComment(
  prevState: CommentState,
  formData: FormData
): Promise<CommentState> {
  const postId = formData.get("postId") as string;
  const body = formData.get("body") as string;

  if (!body || body.trim().length === 0) {
    return { success: false, error: "Comment cannot be empty" };
  }

  if (body.length > 1000) {
    return { success: false, error: "Comment must be under 1000 characters" };
  }

  try {
    await db.comment.create({
      data: { postId, body: body.trim(), authorId: getCurrentUserId() },
    });
    revalidatePath(`/posts/${postId}`);
    return { success: true, error: null };
  } catch (e) {
    console.error("Failed to add comment:", e);
    return { success: false, error: "Failed to post comment. Please try again." };
  }
}
// components/CommentSection.tsx
"use client";

import { useActionState, useOptimistic, useRef } from "react";
import { useFormStatus } from "react-dom";
import { addComment } from "@/app/actions";

type Comment = { id: string; body: string; author: string };

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "Posting..." : "Post Comment"}
    </button>
  );
}

export function CommentSection({
  postId,
  comments,
}: {
  postId: string;
  comments: Comment[];
}) {
  const formRef = useRef<HTMLFormElement>(null);

  // Optimistic state: show comment immediately before server confirms
  const [optimisticComments, addOptimisticComment] = useOptimistic(
    comments,
    (current: Comment[], newBody: string) => [
      ...current,
      { id: "temp-" + Date.now(), body: newBody, author: "You" },
    ]
  );

  // Action state: track server response for error display
  const [state, formAction] = useActionState(
    async (prevState: any, formData: FormData) => {
      const body = formData.get("body") as string;
      addOptimisticComment(body);
      formRef.current?.reset(); // Clear the input immediately
      return addComment(prevState, formData);
    },
    { success: false, error: null }
  );

  return (
    <div>
      <form ref={formRef} action={formAction}>
        <input type="hidden" name="postId" value={postId} />
        <textarea name="body" placeholder="Write a comment..." required />
        {state.error && <p style={{ color: "red" }}>{state.error}</p>}
        <SubmitButton />
      </form>

      <ul>
        {optimisticComments.map((comment) => (
          <li
            key={comment.id}
            style={{ opacity: comment.id.startsWith("temp-") ? 0.5 : 1 }}
          >
            <strong>{comment.author}</strong>: {comment.body}
          </li>
        ))}
      </ul>
    </div>
  );
}

Server Actions for Mutations visual 1


Common Mistakes

Mistake 1: Calling useFormStatus in the Same Component as the Form

useFormStatus reads from the nearest parent <form>. If you call it in the component that renders the <form>, there is no parent form — so pending is always false. Extract the button into a child component.

Mistake 2: Wrapping redirect in Try/Catch

redirect() works by throwing a special Next.js error. If you wrap it in try/catch, the catch block swallows the redirect and it never happens. Call redirect after your try/catch block, or re-throw errors that match the redirect pattern.

Mistake 3: Forgetting to Revalidate After Mutations

Your server action updates the database, but the UI does not change. Why? Because Next.js is still serving the cached version of the page. You must call revalidatePath or revalidateTag to tell Next.js the data changed.

Mistake 4: Using Server Actions for Data Fetching

Server actions are for mutations (create, update, delete). For reading data, use Server Components with async/await or Route Handlers. Server actions send a POST request — using them to fetch data breaks caching, bookmarking, and browser back/forward behavior.

Mistake 5: Not Validating Input on the Server

Client-side validation is a convenience for the user. Server-side validation is a security requirement. A server action receives raw FormData from the network — an attacker can send anything. Always validate and sanitize inputs inside the server action, regardless of what the client validates.

Mistake 6: Exposing Sensitive Error Details to the Client

When a database query fails, the error might contain table names, column names, or connection strings. Never return error.message directly to the client. Log the real error on the server and return a generic message.


Interview Questions

Q1: What are server actions in Next.js, and how do they differ from API routes?

Server actions are async functions marked with "use server" that run exclusively on the server. They can be passed directly to a form's action attribute, so the form submits to the function without needing a separate API route or client-side fetch call. The key differences: server actions integrate with React's form handling (progressive enhancement, useActionState, useFormStatus), they automatically work without JavaScript, and they can call revalidatePath/revalidateTag directly. API routes are better for external webhooks, third-party integrations, or when you need a traditional REST/GraphQL endpoint. Server actions are specifically designed for mutations triggered by user interactions in the UI.

Q2: How does useFormStatus work, and what is the most common mistake developers make with it?

useFormStatus is a React DOM hook that returns the submission status of the nearest parent <form>. It provides { pending, data, method, action } where pending is true while the form's action is executing. The most common mistake is calling useFormStatus in the same component that renders the <form> tag. Since the hook reads from the parent form, and the form is rendered by the same component, there is no parent — so pending is always false. The fix is to extract the submit button into a separate child component that is rendered inside the <form>.

Q3: Explain the difference between useActionState and useOptimistic. When would you use each?

useActionState manages the return value of a server action — it tracks what the server sent back (success, error messages, validation feedback). Use it when you need to display server-side validation errors or success confirmations after a form submission. useOptimistic manages the UI state optimistically — it lets you show an immediate update before the server responds, then automatically reverts if the server fails. Use it when you want the UI to feel instant (adding a comment, toggling a like). In practice, you often combine both: useOptimistic for the instant visual update and useActionState for handling error messages if the action fails.

Q4: A form mutation updates the database but the UI still shows old data. What went wrong?

The most likely cause is missing cache revalidation. Next.js aggressively caches rendered pages and fetch results. After a server action mutates the database, you must call revalidatePath("/the-page") to refresh the page's cached data or revalidateTag("tag-name") to invalidate specific fetch caches. Without revalidation, Next.js serves the stale cached version even though the database has new data. A secondary cause could be that the page uses client-side state (useState) that is not updated — in that case, the data might be fetched fresh but the component is rendering from stale local state.

Q5: How do you handle errors in server actions? What is the difference between returning an error object and throwing an error?

Returning an error object (e.g., return { error: "Validation failed" }) keeps the user on the form and allows you to display inline feedback via useActionState. This is ideal for expected errors like validation failures. Throwing an error (e.g., throw new Error("Unauthorized")) propagates to the nearest error.tsx boundary, which replaces the entire route segment with an error UI. This is appropriate for unexpected or fatal errors like authentication failures or server crashes. The general pattern is: return errors for form validation, throw errors for everything else. Additionally, redirect() must be called outside of try/catch because it throws internally to trigger navigation.


Quick Reference — Cheat Sheet

+------------------------------------+----------------------------------------------+
| Concept                            | Key Point                                    |
+------------------------------------+----------------------------------------------+
| "use server"                       | Marks a function as server-only. Never       |
|                                    | sent to the client. Runs on POST request.    |
+------------------------------------+----------------------------------------------+
| form action={serverAction}         | Form submits directly to server function.    |
|                                    | No API route needed. Works without JS.       |
+------------------------------------+----------------------------------------------+
| useFormStatus()                    | Returns { pending } for the parent form.     |
|                                    | MUST be in a child component of <form>.      |
+------------------------------------+----------------------------------------------+
| useActionState(action, initial)    | Tracks server action return value.           |
|                                    | Use for validation errors and success msgs.  |
+------------------------------------+----------------------------------------------+
| useOptimistic(state, reducer)      | Instant UI update before server responds.    |
|                                    | Reverts automatically on failure.            |
+------------------------------------+----------------------------------------------+
| revalidatePath("/path")            | Refreshes cached page after mutation.        |
|                                    | Without this, UI shows stale data.           |
+------------------------------------+----------------------------------------------+
| revalidateTag("tag")               | Refreshes all fetches with matching tag.     |
|                                    | More granular than revalidatePath.           |
+------------------------------------+----------------------------------------------+
| redirect("/path")                  | Navigates after mutation. Must be called     |
|                                    | OUTSIDE try/catch (throws internally).       |
+------------------------------------+----------------------------------------------+
| Error handling                     | Return objects for form errors (expected).   |
|                                    | Throw for fatal errors (caught by error.tsx).|
+------------------------------------+----------------------------------------------+

RULE: Server actions are for mutations, not data fetching.
RULE: Always validate inputs inside the server action — client validation is optional.
RULE: Always revalidate after mutations or the UI stays stale.
RULE: useFormStatus must be in a CHILD of <form>, not the same component.

Prev: Lesson 4.3 — Loading & Error States Next: Lesson 5.1 — Dynamic Routes & Catch-All Segments


This is Lesson 4.4 of the Next.js Interview Prep Course — 8 chapters, 33 lessons.

On this page