clientAction
A client-side mutation function that runs in the browser, allowing you to handle form submissions entirely on the client or augment server action behavior.Signature
export function clientAction(args: ClientActionFunctionArgs): Promise<Data> | Data
Data returned to the UI (accessible via
useActionData())Basic Example
import { Form, useActionData } from "react-router";
export async function clientAction({ request }: Route.ClientActionArgs) {
const formData = await request.formData();
const email = formData.get("email");
// Validate on client
if (!email?.includes("@")) {
return { error: "Invalid email" };
}
// Save to localStorage
localStorage.setItem("newsletter", email);
return { success: true };
}
export default function Newsletter() {
const actionData = useActionData<typeof clientAction>();
return (
<Form method="post">
<input name="email" type="email" />
{actionData?.error && <span>{actionData.error}</span>}
{actionData?.success && <span>Subscribed!</span>}
<button type="submit">Subscribe</button>
</Form>
);
}
Calling Server Action
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const comment = await db.comment.create({
data: { text: formData.get("text") }
});
return { comment };
}
export async function clientAction({
request,
serverAction
}: Route.ClientActionArgs) {
// Optimistic update
const formData = await request.formData();
const optimisticComment = {
id: "temp-" + Date.now(),
text: formData.get("text"),
status: "pending",
};
// Show immediately in UI
updateUIWithOptimisticComment(optimisticComment);
// Call server
const { comment } = await serverAction<typeof action>();
// Replace optimistic with real
replaceOptimisticComment(optimisticComment.id, comment);
return { comment };
}
Client-Only Mutations
// No server action needed
export async function clientAction({ request }: Route.ClientActionArgs) {
const formData = await request.formData();
const theme = formData.get("theme");
// Update localStorage
localStorage.setItem("theme", theme);
// Update document
document.documentElement.setAttribute("data-theme", theme);
return { theme };
}
export default function ThemeToggle() {
return (
<Form method="post">
<select name="theme">
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<button type="submit">Change Theme</button>
</Form>
);
}
Validation Before Server
export async function clientAction({
request,
serverAction
}: Route.ClientActionArgs) {
const formData = await request.formData();
// Client-side validation
const errors: Record<string, string> = {};
const email = formData.get("email");
if (!email?.includes("@")) {
errors.email = "Invalid email";
}
const password = formData.get("password");
if (password.length < 8) {
errors.password = "Password must be at least 8 characters";
}
// Return early if validation fails
if (Object.keys(errors).length > 0) {
return { errors };
}
// Call server action if valid
return await serverAction();
}
Optimistic UI Updates
import { useFetcher } from "react-router";
export async function clientAction({
request,
serverAction
}: Route.ClientActionArgs) {
const formData = await request.formData();
const text = formData.get("text");
// Create optimistic item
const optimisticItem = {
id: crypto.randomUUID(),
text,
createdAt: new Date().toISOString(),
optimistic: true,
};
// Dispatch custom event for optimistic update
window.dispatchEvent(
new CustomEvent("optimistic-add", { detail: optimisticItem })
);
try {
// Call server
const result = await serverAction<typeof action>();
// Replace optimistic with real
window.dispatchEvent(
new CustomEvent("optimistic-replace", {
detail: { optimisticId: optimisticItem.id, real: result }
})
);
return result;
} catch (error) {
// Remove optimistic on error
window.dispatchEvent(
new CustomEvent("optimistic-remove", { detail: optimisticItem.id })
);
throw error;
}
}
Offline Support
export async function clientAction({
request,
serverAction
}: Route.ClientActionArgs) {
const formData = await request.formData();
// Queue action if offline
if (!navigator.onLine) {
await queueOfflineAction(formData);
return {
queued: true,
message: "Will sync when online"
};
}
try {
return await serverAction();
} catch (error) {
// Queue if server is unreachable
await queueOfflineAction(formData);
return {
queued: true,
message: "Queued for retry"
};
}
}
// Process queue when online
window.addEventListener("online", async () => {
const queued = await getQueuedActions();
for (const action of queued) {
await processQueuedAction(action);
}
});
Client-Side Caching
const cache = new Map();
export async function clientAction({
request,
serverAction
}: Route.ClientActionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "delete") {
const id = formData.get("id");
// Remove from cache immediately
cache.delete(id);
// Update server
await serverAction();
return { deleted: id };
}
// Other intents...
}
File Upload with Progress
import { useState } from "react";
import { useFetcher } from "react-router";
export async function clientAction({ request }: Route.ClientActionArgs) {
const formData = await request.formData();
const file = formData.get("file") as File;
// Upload with progress tracking
const result = await uploadWithProgress(file, (progress) => {
// Emit progress events
window.dispatchEvent(
new CustomEvent("upload-progress", { detail: progress })
);
});
return { url: result.url };
}
export default function FileUpload() {
const [progress, setProgress] = useState(0);
const fetcher = useFetcher();
useEffect(() => {
const handler = (e: CustomEvent) => setProgress(e.detail);
window.addEventListener("upload-progress", handler);
return () => window.removeEventListener("upload-progress", handler);
}, []);
return (
<fetcher.Form method="post" encType="multipart/form-data">
<input type="file" name="file" />
<button type="submit">Upload</button>
{progress > 0 && <progress value={progress} max={100} />}
</fetcher.Form>
);
}
Analytics and Tracking
export async function clientAction({
request,
serverAction
}: Route.ClientActionArgs) {
const formData = await request.formData();
// Track event
analytics.track("form_submitted", {
form: "contact",
fields: Array.from(formData.keys()),
});
try {
const result = await serverAction();
// Track success
analytics.track("form_success", { form: "contact" });
return result;
} catch (error) {
// Track error
analytics.track("form_error", {
form: "contact",
error: error.message,
});
throw error;
}
}
Multi-Step Forms
export async function clientAction({
request,
serverAction
}: Route.ClientActionArgs) {
const formData = await request.formData();
const step = parseInt(formData.get("step") || "1");
// Save to sessionStorage
const existing = JSON.parse(sessionStorage.getItem("formData") || "{}");
const updated = { ...existing, ...Object.fromEntries(formData) };
sessionStorage.setItem("formData", JSON.stringify(updated));
if (step < 3) {
// Not final step - just return progress
return { step: step + 1, data: updated };
}
// Final step - submit to server
const result = await serverAction();
sessionStorage.removeItem("formData");
return result;
}
Best Practices
Use for client-only mutations
Use for client-only mutations
Perfect for operations that don’t need server involvement:
// ✅ Good - client-only state
export async function clientAction({ request }) {
const formData = await request.formData();
localStorage.setItem("preferences", JSON.stringify(
Object.fromEntries(formData)
));
return { updated: true };
}
// ❌ Bad - should use server action
export async function clientAction({ request }) {
const formData = await request.formData();
// This needs to be in database, use serverAction
return { saved: true };
}
Validate before calling server
Validate before calling server
Avoid unnecessary server requests with client-side validation:
export async function clientAction({ request, serverAction }) {
const formData = await request.formData();
// Quick client validations
if (!formData.get("email")?.includes("@")) {
return { error: "Invalid email" };
}
// Only call server if valid
return await serverAction();
}
Handle offline gracefully
Handle offline gracefully
Provide feedback when server is unreachable:
export async function clientAction({ request, serverAction }) {
if (!navigator.onLine) {
return { error: "You're offline. Please try again." };
}
try {
return await serverAction();
} catch (error) {
return { error: "Server unavailable. Try again later." };
}
}
Consider optimistic updates carefully
Consider optimistic updates carefully
Only use optimistic UI when failures are rare:
// ✅ Good - liking/bookmarking (low-risk)
export async function clientAction({ serverAction }) {
showOptimisticUpdate();
try {
return await serverAction();
} catch {
revertOptimisticUpdate();
throw error;
}
}
// ❌ Risky - payments (high-stakes)
export async function clientAction({ serverAction }) {
// Don't show success before server confirms!
return await serverAction();
}
See Also
- action - Server-side mutations
- clientLoader - Client-side data loading
- useActionData - Access action data
- useFetcher - Imperative mutations