Building a Multi-Page SPA with React RouterEntry 15 of 20
Mutations with action, <Form>, and redirect
A route's action handles form submissions. React Router's <Form> collects the inputs and sends them to the action, which usually returns redirect().
Notes
Setup steps:
- Replace
<form>with<Form method="post">. Every input needs aname. - Add an
actionfunction to the page file. Read inputs withawait request.formData()andformData.get('title'). - Register it on the route:
action: newEventAction. - Return
redirect('/events')to navigate after success.
Behavior:
<Form>prevents the browser's default request and calls the route's action instead. NoonSubmit, nouseStateper field.- After the action runs, React Router revalidates loaders, so lists refresh automatically.
<Form action="/other-path">sends the submission to a different route's action.methodcan bepost,put,patch, ordelete, read in the action viarequest.method.- Client-side validation (
required,minLength) can be bypassed in DevTools. Always validate on the server too (Entry 17).
What / Why / When
- What
Route-level form handling.
- Why
Submission, navigation, and data refresh are handled by the router.
- When
Forms that create, update, or delete data and then navigate.
Code compare
// services/events.tsexport type SaveResult = { errors?: Record<string, string> };​export async function saveEvent(event: EventInput, id?: string): Promise<SaveResult> { const response = await fetch(id ? `${API}/events/${id}` : `${API}/events`, { method: id ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(event), });​ if (response.status === 422) return response.json(); // validation errors if (!response.ok) throw new Error("Could not save event."); return {};}// components/EventForm.tsximport { Form } from "react-router";​type Props = { method: "post" | "patch"; event?: EventItem };​export default function EventForm({ method, event }: Props) { return ( <Form method={method}> <label htmlFor="title">Title</label> <input id="title" name="title" type="text" required defaultValue={event?.title ?? ""} />​ <label htmlFor="image">Image</label> <input id="image" name="image" type="url" required defaultValue={event?.image ?? ""} />​ <label htmlFor="date">Date</label> <input id="date" name="date" type="date" required defaultValue={event?.date ?? ""} />​ <label htmlFor="description">Description</label> <textarea id="description" name="description" rows={5} required defaultValue={event?.description ?? ""} />​ <button type="submit">Save</button> </Form> );}// actions/event-form-action.ts (shared by new and edit routes)import { redirect, type ActionFunctionArgs } from "react-router";import { saveEvent } from "../services/events";​export async function action({ request, params }: ActionFunctionArgs) { const formData = await request.formData();​ const event = { title: String(formData.get("title")), image: String(formData.get("image")), date: String(formData.get("date")), description: String(formData.get("description")), };​ const result = await saveEvent(event, request.method === "PATCH" ? params.eventId : undefined); if (result.errors) return result; // returned to useActionData (Entry 17)​ return redirect("/events");}// App.tsximport { action as manipulateEventAction } from './actions/event-form-action';​{ path: 'new', element: <NewEventPage />, action: manipulateEventAction },{ path: ':eventId', id: 'event-detail', loader: eventDetailLoader, children: [ { index: true, element: <EventDetailPage /> }, { path: 'edit', element: <EditEventPage />, action: manipulateEventAction }, ],}Angular has no route actions. A reactive form submits through a service, then the component navigates.
// event-form.component.ts@Component({ selector: "app-event-form", imports: [ReactiveFormsModule], template: ` <form [formGroup]="form" (ngSubmit)="onSubmit()"> <label for="title">Title</label> <input id="title" formControlName="title" />​ <label for="image">Image</label> <input id="image" type="url" formControlName="image" />​ <label for="date">Date</label> <input id="date" type="date" formControlName="date" />​ <label for="description">Description</label> <textarea id="description" rows="5" formControlName="description"></textarea>​ <button type="submit" [disabled]="form.invalid">Save</button> </form> `,})export class EventFormComponent { private eventsService = inject(EventsService); private router = inject(Router);​ event = input<EventItem>();​ protected form = new FormGroup({ title: new FormControl("", { nonNullable: true, validators: Validators.required }), image: new FormControl("", { nonNullable: true, validators: Validators.required }), date: new FormControl("", { nonNullable: true, validators: Validators.required }), description: new FormControl("", { nonNullable: true, validators: Validators.required }), });​ constructor() { effect(() => { const event = this.event(); if (event) this.form.patchValue(event); }); }​ onSubmit() { if (this.form.invalid) return;​ this.eventsService.saveEvent(this.form.getRawValue(), this.event()?.id).subscribe(() => { this.router.navigate(["/events"]); // like return redirect('/events') }); }}Differences and gotchas
- React Router's
<Form>uses uncontrolled inputs read fromFormData; Angular uses aFormGroupmodel. - React revalidates loaders automatically after an action. Angular resolvers re-run on navigation; with
runGuardsAndResolvers: 'always'they also re-run when navigating to the same URL. - Use
redirect()in actions and loaders;useNavigateis for components.
Quick reference
| Task | React | Angular |
|---|---|---|
| Form element | <Form method="post"> | <form [formGroup] (ngSubmit)> |
| Read values | formData.get('title') | form.getRawValue() |
| Submit handler | Route action | Component method + service |
| Navigate after | return redirect('/events') | router.navigate(['/events']) |
| Refresh data | Automatic revalidation | Re-run resolver / refetch |