An action is the write half of a route. It runs whenever a non-GET submission targets that route, receives the same { request, params, context } argument as a loader, and is the only place a data-router app should mutate data.
async function createProduct({ request }) {
const form = await request.formData();
const name = String(form.get('name') || '').trim();
const price = Number(form.get('price'));
const errors = {};
if (name.length < 2) errors.name = 'Name must be at least 2 characters.';
if (!(price > 0)) errors.price = 'Price must be positive.';
if (Object.keys(errors).length) return { errors, values: { name, price } };
const response = await fetch('/api/products', { method: 'POST',
body: JSON.stringify({ name, price }), signal: request.signal });
if (!response.ok) throw data('Could not save the product', { status: 502 });
return redirect(`/products/${(await response.json()).id}`);
}Three return paths, and the difference between them is the whole design. Returning a value leaves the user on the form and hands that value to useActionData() — the validation-error case. Returning redirect(url) is what a successful write should do: the back button and a reload then both behave. Throwing hands control to the route's error boundary (useRouteError).
request.method distinguishes POST from PUT and DELETE, the body reads as formData(), json() or text(), and request.signal aborts if the user navigates away mid-submission. When the action resolves, the router revalidates every active loader, so the list the product was added to refreshes without a line of code. Never mutate from a component, an Effect or an event handler: a change that skips the action or fetcher is one the router never hears about.