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:
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
export const action = async ({ request }: ActionFunctionArgs) => {
await createResource(formData);
return redirect('/resources'); // Don't return json
};2. Validate Input
function validate(input: unknown) {
if (!input || typeof input !== 'string') {
throw new Response('Invalid input', { status: 400 });
}
}3. Handle Errors Gracefully
try {
await processFormData(formData);
} catch (error) {
return json(
{ error: 'Processing failed' },
{ status: 500 }
);
}4. Use Types
import { type ActionFunctionArgs } from '@remix-run/node';
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
// Fully typed
};Testing Actions
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.