FUNSTACK Router

HomeGetting StartedLearnAPI ReferenceExamplesFAQ
GitHub

Built with @funstack/router — A modern React router based on the Navigation API

Learn

Navigation APINested RoutesType SafetyForm ActionsHow Loaders RunError HandlingTransitionsSSRHow SSR WorksStatic Site GenerationSSR with LoadersRSCReact Server ComponentsRSC with Route Features

Form Actions

FUNSTACK Router can intercept <form> submissions and run an action function on the client before navigation occurs. This guide explains how actions work, when to use them, and important considerations for progressive enhancement.

Important: Progressive Enhancement

A <form method="post"> should work even before JavaScript has loaded. The browser natively submits POST forms to the server, so your server must be prepared to handle these requests. The router’s action function is a client-side shortcut that runs only after hydration — it does not replace server-side form handling.

If your server cannot handle the POST request, users on slow connections, users with JavaScript disabled, or users who submit the form before hydration completes will experience a broken form. Always ensure your server handles POST submissions for the same URL as a baseline.

How It Works

When a <form method="post"> is submitted, the router matches the form’s destination URL against the route definitions. If a matched route defines an action, the router intercepts the submission via the Navigation API instead of letting the browser send it to the server. The flow is:

  1. User submits a form with method="post"
  2. The Navigation API fires a navigate event with formData
  3. The router finds the deepest matched route that has an action
  4. The action function runs with the form data wrapped in a Request
  5. The action’s return value is passed to the route’s loader as actionResult
  6. The loader runs and the UI updates with fresh data

If the matched route does not define an action, the router does not intercept the submission and the browser sends it to the server as a normal POST request.

Defining an Action

Add an action function to your route definition. It receives an ActionArgs object with the route params, a Request, and an AbortSignal:

import { route } from "@funstack/router";

const editRoute = route({
  path: "/posts/:postId/edit",
  action: async ({ params, request, signal }) => {
    const formData = await request.formData();
    const title = formData.get("title") as string;
    const body = formData.get("body") as string;

    const res = await fetch(`/api/posts/${params.postId}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title, body }),
      signal,
    });
    return res.json();
  },
  loader: async ({ params, actionResult, signal }) => {
    // After a successful action, actionResult contains
    // the return value. On normal navigations it is undefined.
    const res = await fetch(`/api/posts/${params.postId}`, { signal });
    return res.json();
  },
  component: EditPostPage,
});

The Form

Use a standard HTML <form> element with method="post". There is no special form component needed — the router hooks into the Navigation API which intercepts native form submissions:

function EditPostPage({ data, params }: EditPostProps) {
  return (
    <form method="post" action={`/posts/${params.postId}/edit`}>
      <input name="title" defaultValue={data.title} />
      <textarea name="body" defaultValue={data.body} />
      <button type="submit">Save</button>
    </form>
  );
}

Note that the form’s action attribute points to the same URL that the route matches. This is essential for progressive enhancement: before hydration, the browser will POST to this URL on the server.

Progressive Enhancement in Detail

The action feature is designed as an enhancement layer. The baseline behavior of a POST form is a server round-trip, and the router’s action provides a faster, client-side alternative once hydration is complete. This means:

  • Before hydration — The browser submits the form to the server as a normal POST request. Your server must handle it and return an appropriate response (typically a redirect or a re-rendered page).
  • After hydration — The router intercepts the submission, runs your action function on the client, and updates the UI without a full page reload.

Both paths should produce the same end result for the user. The client action is a shortcut, not a replacement.

When your server cannot handle POST requests

If you are building a purely client-side application (e.g. a SPA with no server-side form handling), consider using React 19’s <form action={fn}> pattern instead. When a form action is a function rather than a URL, the browser will not attempt a server round-trip on submission. Note that in a client-only app the form will not work until React hydrates, since the function only exists in the JavaScript bundle.

In contrast, FUNSTACK Router’s action intercepts URL-based form submissions. If the client has not hydrated yet, the browser will POST to the URL, which will fail without server handling.

Action Result and Loader

When a route defines both an action and a loader, the loader runs after the action completes. The action’s return value is passed to the loader via the actionResult parameter:

action: async ({ request }) => {
  const formData = await request.formData();
  // ... process form
  return { success: true, message: "Saved!" };
},
loader: async ({ params, actionResult, signal }) => {
  // actionResult is { success: true, message: "Saved!" }
  // after the action, or undefined on normal navigation
  const data = await fetchData(params.id, signal);
  return { ...data, actionResult };
},

This lets your UI display feedback from the action (e.g. success messages or validation errors) alongside the refreshed data.

Summary

  • action intercepts POST form submissions on the client after hydration
  • Your server must handle the same POST endpoint for progressive enhancement
  • The action’s return value flows to the loader as actionResult
  • For SPAs without server-side form handling, prefer React 19’s <form action={fn}> pattern