Guides

Building Remix with open-source tools

This guide outlines the implementation of a type-safe data mutation flow in Remix (React Router v7), focusing on progressive enhancement, server-side validation, and optimistic UI updates for production-grade applications.

45 minutes5 steps
1

Define the Server-Side Action and Schema

Create a server-side action to handle incoming POST requests. Use a validation library like Zod to parse the request's formData. This ensures that the data entering your database is valid and provides a single source of truth for error types.

app/routes/tasks.tsx
import { ActionFunctionArgs, json } from '@remix-run/node';
import { z } from 'zod';

const TaskSchema = z.object({
  title: z.string().min(3, 'Title too short'),
  description: z.string().optional(),
});

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const result = TaskSchema.safeParse(Object.fromEntries(formData));

  if (!result.success) {
    return json({ errors: result.error.flatten().fieldErrors }, { status: 400 });
  }

  await db.task.create({ data: result.data });
  return json({ ok: true });
}

⚠ Common Pitfalls

  • Returning a raw Error object instead of a JSON response with status codes.
  • Forgetting to handle the 'null' case when formData keys are missing.
2

Implement the Progressively Enhanced Form

Use the Remix <Form> component instead of the native HTML <form>. This allows the framework to intercept the submission and perform a client-side transition while still falling back to a standard POST request if JavaScript fails to load.

app/routes/tasks.tsx
import { Form, useActionData } from '@remix-run/react';

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

  return (
    <Form method="post">
      <input type="text" name="title" />
      {actionData?.errors?.title && <p>{actionData.errors.title}</p>}
      <textarea name="description" />
      <button type="submit">Create Task</button>
    </Form>
  );
}

⚠ Common Pitfalls

  • Using method='GET' for mutations, which violates HTTP idempotency rules.
  • Hardcoding action URLs instead of relying on the current route context.
3

Add Optimistic UI Transitions

Improve perceived performance by using the 'useNavigation' hook. This allows you to render the 'pending' state of the UI immediately upon form submission, before the server responds.

app/routes/tasks.tsx
import { useNavigation } from '@remix-run/react';

export default function TaskRoute() {
  const navigation = useNavigation();
  const isSubmitting = navigation.state === 'submitting';
  const optimisticTitle = navigation.formData?.get('title');

  return (
    <div>
      {isSubmitting && <p>Adding: {optimisticTitle}...</p>}
      <Form method="post">
        <fieldset disabled={isSubmitting}>
          {/* inputs */}
        </fieldset>
      </Form>
    </div>
  );
}

⚠ Common Pitfalls

  • Not disabling the submit button during the 'submitting' state, leading to duplicate POST requests.
  • Relying on state variables instead of navigation.formData for optimistic values.
4

Handle Multiple Intents in One Route

When a single page has multiple actions (e.g., Delete, Complete, Edit), use a hidden 'intent' input. This allows the server-side action to switch logic based on the user's specific interaction.

app/routes/tasks.tsx
export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const intent = formData.get('intent');

  switch (intent) {
    case 'delete':
      return deleteTask(formData.get('id'));
    case 'update':
      return updateTask(formData);
    default:
      throw new Response('Invalid Intent', { status: 400 });
  }
}

⚠ Common Pitfalls

  • Using multiple <Form> components with different 'action' props when the logic belongs to the same route index.
  • Overcomplicating the action by not delegating logic to helper functions.
5

Configure Global Loading States

Implement a global progress bar or spinner in the root layout using 'useNavigation'. This provides feedback during data revalidation, which Remix triggers automatically after every action.

app/root.tsx
import { useNavigation } from '@remix-run/react';

export default function App() {
  const navigation = useNavigation();
  const isLoading = navigation.state === 'loading';

  return (
    <html>
      <body>
        {isLoading && <div className="progress-bar" />}
        <Outlet />
      </body>
    </html>
  );
}

⚠ Common Pitfalls

  • Confusing 'submitting' (POST) with 'loading' (revalidation/GET), leading to incorrect UI feedback.
  • Neglecting to handle network-level errors that prevent the loading state from resolving.

What you built

By following this sequence, you ensure that your Remix application handles data mutations with high resilience. This pattern leverages the platform's native form handling while providing the instantaneous feedback expected of modern SPAs.