Skip to content
All essays
CraftJanuary 15, 202614 min

Remix Actions: Handling Form Submissions and Mutations

Comprehensive guide to Remix actions for form handling, mutations, validation, and server-side data processing

Ü
Ümit Uz
Mobile & Full Stack Developer

Actions in Remix provide a powerful way to handle form submissions and mutations on the server. They work seamlessly with Remix's progressive enhancement, ensuring your forms work even without JavaScript. This guide covers everything you need to know about actions.

What is an Action?

An action is an async function that handles form submissions and mutations:

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

export const action = async ({ request }: ActionFunctionArgs) => {
  const formData = await request.formData();
  const name = formData.get('name');

  // Process form data
  await createUser({ name });

  // Redirect after success
  return redirect('/users');
};

export default function CreateUser() {
  return (
    <Form method="post">
      <input name="name" type="text" />
      <button type="submit">Create</button>
    </Form>
  );
}

Basic Form Handling

Simple Form Submission

tsx
import { json, redirect } from '@remix-run/node';
import { Form } from '@remix-run/react';
import { db } from '~/utils/db.server';

export const action = async ({ request }: ActionFunctionArgs) => {
  const formData = await request.formData();
  const name = formData.get('name');
  const email = formData.get('email');

  // Create user
  const user = await db.user.create({
    data: { name: String(name), email: String(email) }
  });

  return redirect(`/users/${user.id}`);
};

export default function NewUser() {
  return (
    <Form method="post">
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" type="text" required />
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" required />
      </div>

      <button type="submit">Create User</button>
    </Form>
  );
}

Validation

Client and Server Validation

tsx
import { json, redirect } from '@remix-run/node';
import { Form, useActionData, useNavigation } from '@remix-run/react';

function validateName(name: unknown) {
  if (typeof name !== 'string' || name.length < 3) {
    return 'Name must be at least 3 characters';
  }
}

function validateEmail(email: unknown) {
  if (typeof email !== 'string' || !email.includes('@')) {
    return 'Invalid email address';
  }
}

export const action = async ({ request }: ActionFunctionArgs) => {
  const formData = await request.formData();
  const name = formData.get('name');
  const email = formData.get('email');

  const errors = {
    name: validateName(name),
    email: validateEmail(email)
  };

  if (errors.name || errors.email) {
    return json({ errors }, { status: 400 });
  }

  await createUser({
    name: String(name),
    email: String(email)
  });

  return redirect('/users');
};

export default function NewUser() {
  const actionData = useActionData<typeof action>();
  const navigation = useNavigation();
  const isSubmitting = navigation.state === 'submitting';

  return (
    <Form method="post">
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" type="text" />
        {actionData?.errors?.name && (
          <p className="error">{actionData.errors.name}</p>
        )}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" />
        {actionData?.errors?.email && (
          <p className="error">{actionData.errors.email}</p>
        )}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Creating...' : 'Create User'}
      </button>
    </Form>
  );
}

Using Zod for Validation

tsx
import { z } from 'zod';

const schema = z.object({
  name: z.string().min(3),
  email: z.string().email(),
  age: z.number().min(18).optional()
});

export const action = async ({ request }: ActionFunctionArgs) => {
  const formData = await request.formData();
  const rawData = Object.fromEntries(formData);

  const result = schema.safeParse(rawData);

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

  await createUser(result.data);

  return redirect('/users');
};

File Uploads

Single File Upload

tsx
import { json } from '@remix-run/node';
import { Form } from '@remix-run/react';

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

  if (!file) {
    return json({ error: 'No file uploaded' }, { status: 400 });
  }

  // Save file
  const arrayBuffer = await file.arrayBuffer();
  const buffer = Buffer.from(arrayBuffer);
  await fs.writeFile(`uploads/${file.name}`, buffer);

  return json({ success: true });
};

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

Multiple File Uploads

tsx
export const action = async ({ request }: ActionFunctionArgs) => {
  const formData = await request.formData();
  const files = formData.getAll('files') as File[];

  for (const file of files) {
    if (file.size === 0) continue;

    const buffer = Buffer.from(await file.arrayBuffer());
    await fs.writeFile(`uploads/${file.name}`, buffer);
  }

  return json({ success: true, count: files.length });
};

export default function UploadMultiple() {
  return (
    <Form method="post" encType="multipart/form-data">
      <input name="files" type="file" multiple />
      <button type="submit">Upload Files</button>
    </Form>
  );
}

Handling Different HTTP Methods

tsx
export const action = async ({ request }: ActionFunctionArgs) => {
  const method = request.method;
  const formData = await request.formData();

  switch (method) {
    case 'POST':
      return handleCreate(formData);
    case 'PUT':
    case 'PATCH':
      return handleUpdate(formData);
    case 'DELETE':
      return handleDelete(formData);
    default:
      return json({ error: 'Method not allowed' }, { status: 405 });
  }
};

async function handleCreate(formData: FormData) {
  const name = formData.get('name');
  await createUser({ name: String(name) });
  return redirect('/users');
}

async function handleUpdate(formData: FormData) {
  const id = formData.get('id');
  const name = formData.get('name');
  await updateUser(String(id), { name: String(name) });
  return redirect(`/users/${id}`);
}

async function handleDelete(formData: FormData) {
  const id = formData.get('id');
  await deleteUser(String(id));
  return redirect('/users');
}

Error Handling

Returning Errors

tsx
export const action = async ({ request }: ActionFunctionArgs) => {
  try {
    const formData = await request.formData();
    const email = formData.get('email');
    const password = formData.get('password');

    const user = await authenticateUser({
      email: String(email),
      password: String(password)
    });

    if (!user) {
      return json(
        { error: 'Invalid credentials' },
        { status: 401 }
      );
    }

    return redirect('/dashboard');
  } catch (error) {
    return json(
      { error: 'Authentication failed' },
      { status: 500 }
    );
  }
};

Displaying Errors

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

  return (
    <Form method="post">
      {actionData?.error && (
        <div className="error">{actionData.error}</div>
      )}

      <input name="email" type="email" />
      <input name="password" type="password" />
      <button type="submit">Login</button>
    </Form>
  );
}

Working with Sessions

Setting Session Data

tsx
import { createCookieSessionStorage } from '@remix-run/node';

const { getSession, commitSession, destroySession } =
  createCookieSessionStorage({
    cookie: {
      name: 'session',
      secrets: [process.env.SECRET!],
    },
  });

export const action = async ({ request }: ActionFunctionArgs) => {
  const formData = await request.formData();
  const email = formData.get('email');
  const password = formData.get('password');

  const user = await authenticateUser({ email, password });

  if (!user) {
    return json({ error: 'Invalid credentials' }, { status: 401 });
  }

  const session = await getSession(request.headers.get('Cookie'));
  session.set('userId', user.id);

  return redirect('/dashboard', {
    headers: {
      'Set-Cookie': await commitSession(session)
    }
  });
};

Clearing Session

tsx
export const action = async ({ request }: ActionFunctionArgs) => {
  const session = await getSession(request.headers.get('Cookie'));

  return redirect('/login', {
    headers: {
      'Set-Cookie': await destroySession(session)
    }
  });
};

Progress Indication

Using Navigation State

tsx
export default function ContactForm() {
  const navigation = useNavigation();
  const isSubmitting = navigation.state === 'submitting';

  return (
    <Form method="post">
      <input name="name" type="text" />
      <textarea name="message" />
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Sending...' : 'Send Message'}
      </button>
    </Form>
  );
}

Using Optimistic UI

tsx
export default function LikeButton({ postId, initialLikes }: { postId: string, initialLikes: number }) {
  const fetcher = useFetcher();
  const isLiked = fetcher.formData?.get('intent') === 'like';
  const likes = fetcher.formData ? initialLikes + 1 : initialLikes;

  return (
    <fetcher.Form method="post">
      <input type="hidden" name="intent" value="like" />
      <input type="hidden" name="postId" value={postId} />
      <button type="submit">
        {isLiked ? '♥' : '♡'} {likes}
      </button>
    </fetcher.Form>
  );
}

Multiple Actions Per Route

tsx
export const action = async ({ request }: ActionFunctionArgs) => {
  const formData = await request.formData();
  const intent = formData.get('intent');

  switch (intent) {
    case 'create':
      return handleCreate(formData);
    case 'update':
      return handleUpdate(formData);
    case 'delete':
      return handleDelete(formData);
    default:
      return json({ error: 'Invalid intent' }, { status: 400 });
  }
};

export default function Actions() {
  return (
    <div>
      <Form method="post">
        <input type="hidden" name="intent" value="create" />
        <button type="submit">Create</button>
      </Form>

      <Form method="post">
        <input type="hidden" name="intent" value="update" />
        <button type="submit">Update</button>
      </Form>

      <Form method="post">
        <input type="hidden" name="intent" value="delete" />
        <button type="submit">Delete</button>
      </Form>
    </div>
  );
}

CSRF Protection

tsx
import { createCookieSessionStorage } from '@remix-run/node';

const sessionStorage = createCookieSessionStorage({
  cookie: {
    name: 'session',
    secrets: [process.env.SECRET!],
  },
});

async function getSession(request: Request) {
  const session = await sessionStorage.getSession(
    request.headers.get('Cookie')
  );

  return session;
}

async function csrf(request: Request) {
  const session = await getSession(request);

  if (!session.has('csrf')) {
    session.set('csrf', crypto.randomUUID());
  }

  return { csrf: session.get('csrf') as string };
}

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const { csrf } = await csrf(request);
  return json({ csrf });
};

export const action = async ({ request }: ActionFunctionArgs) => {
  const formData = await request.formData();
  const csrfToken = formData.get('csrf');
  const session = await getSession(request);

  if (csrfToken !== session.get('csrf')) {
    return json({ error: 'Invalid CSRF token' }, { status: 403 });
  }

  // Process form
  return redirect('/success');
};

export default function Form() {
  const { csrf } = useLoaderData<typeof loader>();

  return (
    <Form method="post">
      <input type="hidden" name="csrf" value={csrf} />
      <input name="data" />
      <button type="submit">Submit</button>
    </Form>
  );
}

Best Practices

1. Always Redirect After Success

tsx
export const action = async ({ request }: ActionFunctionArgs) => {
  await createResource(formData);
  return redirect('/resources'); // Don't return json
};

2. Validate Input

tsx
function validate(input: unknown) {
  if (!input || typeof input !== 'string') {
    throw new Response('Invalid input', { status: 400 });
  }
}

3. Handle Errors Gracefully

tsx
try {
  await processFormData(formData);
} catch (error) {
  return json(
    { error: 'Processing failed' },
    { status: 500 }
  );
}

4. Use Types

tsx
import { type ActionFunctionArgs } from '@remix-run/node';

export const action = async ({ request }: ActionFunctionArgs) => {
  const formData = await request.formData();
  // Fully typed
};

Testing Actions

tsx
import { action } from './route';
import { json } from '@remix-run/node';

test('creates user', async () => {
  const request = new Request('http://localhost:3000/users', {
    method: 'POST',
    body: new URLSearchParams({
      name: 'John Doe',
      email: 'john@example.com'
    })
  });

  const response = await action({ request, params: {}, context: {} });

  expect(response.status).toBe(302); // Redirect
});

Conclusion

Actions in Remix provide a clean, powerful way to handle form submissions and mutations. They work seamlessly with progressive enhancement, ensuring your forms work without JavaScript while providing a great experience when it's available.

The key is to keep actions focused on server-side processing: validation, mutations, and redirects. Let your components handle presentation and user feedback. This separation leads to more maintainable, testable code.

Enjoyed this essay? Share it.

Related essays

Next essay
Remix Framework: Getting Started with Modern Full-Stack React