Skip to main content
Technology & EngineeringRemix232 lines

Actions

Server-side form mutations with action functions, progressive enhancement, and optimistic UI in Remix

Quick Summary15 lines
You are an expert in Remix form mutations, including action functions, `<Form>`, `useFetcher`, progressive enhancement, and optimistic UI patterns.

## Key Points

- Always validate form data on the server — client-side validation is supplementary, not a substitute.
- Return `json({ errors })` with a 400 status for validation errors instead of throwing.
- Use `redirect()` after successful mutations to avoid resubmission on refresh (Post/Redirect/Get pattern).
- Use `useFetcher` for mutations that should not trigger full-page navigation (inline actions, background saves).
- Assign unique `name`/`value` pairs to submit buttons to differentiate multiple actions in one form.
- Forgetting `encType="multipart/form-data"` for file uploads — without it, the file is sent as a filename string.
- Catching `redirect()` in a try/catch — like `json()`, `redirect()` throws a Response. Let it propagate.
- Not disabling the submit button during submission — this leads to double submissions on slow connections.
- Returning data from an action but forgetting to handle the `undefined` case in the component before the first submission.
skilldb get remix-skills/ActionsFull skill: 232 lines
Paste into your CLAUDE.md or agent config

Actions — Remix

You are an expert in Remix form mutations, including action functions, <Form>, useFetcher, progressive enhancement, and optimistic UI patterns.

Overview

Remix actions handle form submissions and non-GET requests on the server. Every route can export an action function that processes POST, PUT, PATCH, and DELETE requests. The <Form> component and useFetcher hook submit data to actions, and Remix automatically revalidates all loaders on the page after an action completes, keeping the UI in sync with the server.

Core Concepts

Basic Action

import type { ActionFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form } from "@remix-run/react";

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const title = formData.get("title") as string;
  const body = formData.get("body") as string;

  const errors: Record<string, string> = {};
  if (!title) errors.title = "Title is required";
  if (!body) errors.body = "Body is required";

  if (Object.keys(errors).length > 0) {
    return json({ errors }, { status: 400 });
  }

  const post = await createPost({ title, body });
  return redirect(`/posts/${post.id}`);
}

export default function NewPost() {
  const actionData = useActionData<typeof action>();

  return (
    <Form method="post">
      <div>
        <label htmlFor="title">Title</label>
        <input id="title" name="title" />
        {actionData?.errors?.title && <p>{actionData.errors.title}</p>}
      </div>
      <div>
        <label htmlFor="body">Body</label>
        <textarea id="body" name="body" />
        {actionData?.errors?.body && <p>{actionData.errors.body}</p>}
      </div>
      <button type="submit">Create Post</button>
    </Form>
  );
}

Form Component vs Native Form

Remix's <Form> component intercepts the submission and uses fetch under the hood for a client-side navigation experience. Without JavaScript, it falls back to a standard browser form submission — progressive enhancement built in.

useActionData

useActionData<typeof action>() returns the most recent action response for the current route. It is undefined until an action runs.

Implementation Patterns

Multiple Actions in One Route

Use a hidden input or button name/value to distinguish intents:

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const intent = formData.get("intent");

  switch (intent) {
    case "update":
      return handleUpdate(formData);
    case "delete":
      return handleDelete(formData);
    default:
      throw new Response("Invalid intent", { status: 400 });
  }
}

export default function Item() {
  return (
    <div>
      <Form method="post">
        <input type="hidden" name="intent" value="update" />
        <input name="name" />
        <button type="submit">Save</button>
      </Form>
      <Form method="post">
        <button type="submit" name="intent" value="delete">
          Delete
        </button>
      </Form>
    </div>
  );
}

useFetcher for Non-Navigation Submissions

useFetcher submits to an action without triggering a page navigation. Ideal for inline edits, toggles, and components that appear in many routes:

import { useFetcher } from "@remix-run/react";

function ToggleFavorite({ articleId, isFavorite }: Props) {
  const fetcher = useFetcher();
  const optimisticFavorite = fetcher.formData
    ? fetcher.formData.get("favorite") === "true"
    : isFavorite;

  return (
    <fetcher.Form method="post" action="/api/favorites">
      <input type="hidden" name="articleId" value={articleId} />
      <input
        type="hidden"
        name="favorite"
        value={String(!optimisticFavorite)}
      />
      <button type="submit">
        {optimisticFavorite ? "Unfavorite" : "Favorite"}
      </button>
    </fetcher.Form>
  );
}

Optimistic UI with useNavigation

import { useNavigation } from "@remix-run/react";

export default function NewComment() {
  const navigation = useNavigation();
  const isSubmitting = navigation.state === "submitting";

  return (
    <Form method="post">
      <textarea name="comment" disabled={isSubmitting} />
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Posting..." : "Post Comment"}
      </button>
    </Form>
  );
}

File Uploads

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const file = formData.get("avatar") as File;

  if (!file || file.size === 0) {
    return json({ error: "No file uploaded" }, { status: 400 });
  }

  const buffer = Buffer.from(await file.arrayBuffer());
  await uploadToStorage(buffer, file.name);

  return redirect("/profile");
}

export default function UploadAvatar() {
  return (
    <Form method="post" encType="multipart/form-data">
      <input type="file" name="avatar" accept="image/*" />
      <button type="submit">Upload</button>
    </Form>
  );
}

Resource Routes (API Endpoints)

A route that exports an action (or loader) but no default component is a resource route:

// app/routes/api.webhooks.tsx
export async function action({ request }: ActionFunctionArgs) {
  const payload = await request.json();
  await processWebhook(payload);
  return json({ success: true });
}

Core Philosophy

Remix actions treat the server as the single authority for data mutations. Every form submission is processed by a server-side function that validates input, performs the mutation, and decides the response. This model eliminates the complexity of client-side state management for mutations and ensures that validation logic is never duplicated or out of sync between client and server.

The <Form> component and useFetcher hook embody Remix's commitment to progressive enhancement. When JavaScript is available, form submissions happen via fetch with automatic revalidation. When JavaScript is not available, the same form works as a standard browser submission. This dual capability is not an afterthought but a design constraint that shapes how you build: every action must produce a sensible response for both modes.

Automatic revalidation after actions is one of Remix's most powerful features. After any action completes, Remix re-runs all loaders on the page, ensuring the UI reflects the latest server state. This eliminates cache invalidation bugs and manual state synchronization. You mutate on the server, and the UI updates itself. The trade-off is that expensive loaders re-run on every mutation, so you must design loaders to be fast or cacheable.

Anti-Patterns

  • Performing mutations via useEffect and fetch instead of actions. Bypassing Remix's action system to make direct API calls from effects loses automatic revalidation, progressive enhancement, and the built-in error handling. Use <Form> or useFetcher for all mutations.

  • Catching redirect() in a try/catch block. redirect() throws a Response to exit the action. Wrapping it in try/catch swallows the redirect and causes the action to return undefined. Let it propagate naturally.

  • Returning data from an action without handling the undefined case in the component. useActionData is undefined until the first action runs. Accessing nested properties without checking for undefined causes runtime errors on initial render.

  • Using a single large action for unrelated mutations. An action that handles create, update, delete, and export in one function with a complex switch statement becomes hard to maintain. Split into multiple routes or use clear intent patterns with focused handler functions.

  • Not disabling the submit button during submission. Without useNavigation or hx-disabled-elt to disable the button, users on slow connections can trigger duplicate submissions by clicking multiple times.

Best Practices

  • Always validate form data on the server — client-side validation is supplementary, not a substitute.
  • Return json({ errors }) with a 400 status for validation errors instead of throwing.
  • Use redirect() after successful mutations to avoid resubmission on refresh (Post/Redirect/Get pattern).
  • Use useFetcher for mutations that should not trigger full-page navigation (inline actions, background saves).
  • Assign unique name/value pairs to submit buttons to differentiate multiple actions in one form.

Common Pitfalls

  • Placing an action in a layout route but submitting from a child — Remix sends the submission to the deepest matching route with the form. Use <Form action="/explicit/path"> or useFetcher with an explicit action prop if the target route differs.
  • Forgetting encType="multipart/form-data" for file uploads — without it, the file is sent as a filename string.
  • Catching redirect() in a try/catch — like json(), redirect() throws a Response. Let it propagate.
  • Not disabling the submit button during submission — this leads to double submissions on slow connections.
  • Returning data from an action but forgetting to handle the undefined case in the component before the first submission.

Install this skill directly: skilldb add remix-skills

Get CLI access →