Documentation Index Fetch the complete documentation index at: https://mintlify.com/remix-run/react-router/llms.txt
Use this file to discover all available pages before exploring further.
action
A server-side function that handles data mutations (form submissions, API calls) for a route.
Signature
export function action ( args : ActionFunctionArgs ) : Promise < Response | Data > | Response | Data
args
ActionFunctionArgs
required
Arguments passed to the action function A Fetch Request instance containing the form submission or request data
Dynamic route params for the current route // For route: /posts/:postId/edit
params . postId // string
The context from your server adapter’s getLoadContext() function
The un-interpolated route pattern (e.g., /posts/:postId/edit)
Can return:
A Response object (redirect, json, etc.)
Plain data (accessible via useActionData())
A Promise resolving to either
Basic Example
// app/routes/projects.new.tsx
import { Form , redirect , useActionData } from "react-router" ;
export async function action ({ request } : Route . ActionArgs ) {
const formData = await request . formData ();
const project = await createProject ({
name: formData . get ( "name" ),
description: formData . get ( "description" )
});
return redirect ( `/projects/ ${ project . id } ` );
}
export default function NewProject () {
return (
< Form method = "post" >
< input name = "name" required />
< textarea name = "description" />
< button type = "submit" > Create Project </ button >
</ Form >
);
}
export async function action ({ request } : Route . ActionArgs ) {
const formData = await request . formData ();
// Get individual fields
const title = formData . get ( "title" );
const published = formData . get ( "published" ) === "on" ;
// Get all values for a multi-select
const tags = formData . getAll ( "tags" );
// Convert to object
const data = Object . fromEntries ( formData );
await updatePost ( data );
return { success: true };
}
Validation and Error Handling
import { useActionData } from "react-router" ;
type ActionData = {
errors ?: {
email ?: string ;
password ?: string ;
};
};
export async function action ({ request } : Route . ActionArgs ) {
const formData = await request . formData ();
const email = formData . get ( "email" );
const password = formData . get ( "password" );
const errors : ActionData [ "errors" ] = {};
if ( ! email ?. includes ( "@" )) {
errors . email = "Invalid email address" ;
}
if ( ! password || password . length < 8 ) {
errors . password = "Password must be at least 8 characters" ;
}
if ( Object . keys ( errors ). length > 0 ) {
return { errors };
}
await createUser ({ email , password });
return redirect ( "/dashboard" );
}
export default function Signup () {
const actionData = useActionData < typeof action >();
return (
< Form method = "post" >
< div >
< input name = "email" type = "email" />
{ actionData ?. errors ?. email && (
< span className = "error" > { actionData . errors . email } </ span >
) }
</ div >
< div >
< input name = "password" type = "password" />
{ actionData ?. errors ?. password && (
< span className = "error" > { actionData . errors . password } </ span >
) }
</ div >
< button type = "submit" > Sign Up </ button >
</ Form >
);
}
JSON Submissions
export async function action ({ request } : Route . ActionArgs ) {
const data = await request . json ();
const result = await updateSettings ( data );
return Response . json ({ result }, { status: 200 });
}
// From the client
fetch ( "/settings" , {
method: "POST" ,
headers: { "Content-Type" : "application/json" },
body: JSON . stringify ({ theme: "dark" })
});
Multiple Actions with Intent
export async function action ({ request } : Route . ActionArgs ) {
const formData = await request . formData ();
const intent = formData . get ( "intent" );
switch ( intent ) {
case "delete" : {
const id = formData . get ( "id" );
await deletePost ( id );
return { deleted: id };
}
case "publish" : {
const id = formData . get ( "id" );
await publishPost ( id );
return { published: id };
}
default : {
throw new Error ( `Unknown intent: ${ intent } ` );
}
}
}
export default function Post () {
return (
<>
< Form method = "post" >
< input type = "hidden" name = "intent" value = "publish" />
< input type = "hidden" name = "id" value = { post . id } />
< button type = "submit" > Publish </ button >
</ Form >
< Form method = "post" >
< input type = "hidden" name = "intent" value = "delete" />
< input type = "hidden" name = "id" value = { post . id } />
< button type = "submit" > Delete </ button >
</ Form >
</>
);
}
File Uploads
export async function action ({ request } : Route . ActionArgs ) {
const formData = await request . formData ();
const file = formData . get ( "avatar" ) as File ;
if ( ! file || file . size === 0 ) {
return { error: "Please select a file" };
}
if ( ! file . type . startsWith ( "image/" )) {
return { error: "File must be an image" };
}
const buffer = await file . arrayBuffer ();
const url = await uploadToStorage ( buffer , file . type );
await updateUserAvatar ( url );
return { success: true };
}
Best Practices
Always revalidate after mutations
By default, loaders are automatically revalidated after actions. This ensures your UI stays in sync: export async function loader ({ params } : Route . LoaderArgs ) {
return { post: await getPost ( params . id ) };
}
export async function action ({ params , request } : Route . ActionArgs ) {
const formData = await request . formData ();
await updatePost ( params . id , Object . fromEntries ( formData ));
// Loader automatically revalidates, UI updates
return { success: true };
}
Return data for optimistic UI
Return data instead of redirecting to enable optimistic updates: export async function action ({ request } : Route . ActionArgs ) {
const formData = await request . formData ();
const comment = await createComment ( Object . fromEntries ( formData ));
// Return data so UI can update immediately
return { comment };
}
Use redirect after successful mutations
For create/delete operations, redirect to the appropriate page: import { redirect } from "react-router" ;
export async function action ({ request } : Route . ActionArgs ) {
const formData = await request . formData ();
const post = await createPost ( Object . fromEntries ( formData ));
// Redirect to the new post
return redirect ( `/posts/ ${ post . id } ` );
}
Handle errors appropriately
Throw responses for error boundaries or return errors for inline display: export async function action ({ request } : Route . ActionArgs ) {
try {
const formData = await request . formData ();
await processPayment ( formData );
return redirect ( "/success" );
} catch ( error ) {
if ( error . code === "CARD_DECLINED" ) {
// Return error for inline display
return { error: "Card was declined" };
}
// Throw for error boundary
throw error ;
}
}
See Also