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:

  1. Replace <form> with <Form method="post">. Every input needs a name.
  2. Add an action function to the page file. Read inputs with await request.formData() and formData.get('title').
  3. Register it on the route: action: newEventAction.
  4. Return redirect('/events') to navigate after success.

Behavior:

  • <Form> prevents the browser's default request and calls the route's action instead. No onSubmit, no useState per 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.
  • method can be post, put, patch, or delete, read in the action via request.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

React
// 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 {};}
React
// 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>  );}
React
// 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");}
React
// 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.

Angular
// 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 from FormData; Angular uses a FormGroup model.
  • 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; useNavigate is for components.

Quick reference

TaskReactAngular
Form element<Form method="post"><form [formGroup] (ngSubmit)>
Read valuesformData.get('title')form.getRawValue()
Submit handlerRoute actionComponent method + service
Navigate afterreturn redirect('/events')router.navigate(['/events'])
Refresh dataAutomatic revalidationRe-run resolver / refetch