Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions packages/docs/src/pages/ApiTypesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -289,15 +289,42 @@ routeState<{ tab: string }>()({
});`}</CodeBlock>
</article>

<article className="api-item">
<h3>
<code>ActionArgs</code>
</h3>
<p>
Arguments passed to route action functions. The <code>request</code>{" "}
carries the POST method and <code>FormData</code> body from the form
submission.
</p>
<CodeBlock language="typescript">{`interface ActionArgs<Params> {
params: Params;
request: Request; // method: "POST", body: FormData
signal: AbortSignal;
}`}</CodeBlock>
</article>

<article className="api-item">
<h3>
<code>LoaderArgs</code>
</h3>
<CodeBlock language="typescript">{`interface LoaderArgs {
params: Record<string, string>;
<p>
Arguments passed to route loader functions. The optional{" "}
<code>actionResult</code> parameter contains the return value of the
route's action when the loader runs after a form submission.
</p>
<CodeBlock language="typescript">{`interface LoaderArgs<Params, ActionResult = undefined> {
params: Params;
request: Request;
signal: AbortSignal;
actionResult: ActionResult | undefined;
}`}</CodeBlock>
<p>
On normal navigations, <code>actionResult</code> is{" "}
<code>undefined</code>. After a form submission, it contains the
action's return value (awaited if the action is async).
</p>
</article>

<article className="api-item">
Expand Down
19 changes: 17 additions & 2 deletions packages/docs/src/pages/ApiUtilitiesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,20 @@ const myRoute = route({
(and <code>data</code> prop if loader is defined)
</td>
</tr>
<tr>
<td>
<code>action</code>
</td>
<td>
<code>(args: ActionArgs) =&gt; T</code>
</td>
<td>
Function to handle form submissions (POST navigations). Receives
a <code>Request</code> with <code>FormData</code> body. The
return value is passed to the loader as{" "}
<code>actionResult</code>.
</td>
</tr>
<tr>
<td>
<code>loader</code>
Expand Down Expand Up @@ -281,8 +295,9 @@ const routes = [
<code>routeState</code> - Route definition helper with typed state
</li>
<li>
Types: <code>LoaderArgs</code>, <code>RouteDefinition</code>,{" "}
<code>PathParams</code>, <code>RouteComponentProps</code>,{" "}
Types: <code>ActionArgs</code>, <code>LoaderArgs</code>,{" "}
<code>RouteDefinition</code>, <code>PathParams</code>,{" "}
<code>RouteComponentProps</code>,{" "}
<code>RouteComponentPropsWithData</code>
</li>
</ul>
Expand Down
84 changes: 84 additions & 0 deletions packages/docs/src/pages/ExamplesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,90 @@ const userPostsRoute = route({
});`}</CodeBlock>
</section>

<section>
<h2>Form Submissions</h2>
<p>
Handle form POST submissions with route actions. The action receives
the form data, and its return value flows to the loader via{" "}
<code>actionResult</code>. Native <code>&lt;form&gt;</code> elements
work out of the box — no wrapper component needed.
</p>
<CodeBlock language="tsx">{`import { route, type RouteComponentPropsOf } from "@funstack/router";

// Define a route with both action and loader
const editUserRoute = route({
id: "editUser",
path: "/users/:userId/edit",
action: async ({ request, params, signal }) => {
const formData = await request.formData();
const response = await fetch(\`/api/users/\${params.userId}\`, {
method: "PUT",
body: formData,
signal,
});
return response.json() as Promise<{ success: boolean; error?: string }>;
},
loader: async ({ params, signal, actionResult }) => {
const user = await fetchUser(params.userId, signal);
return {
user,
// actionResult is undefined on normal navigation,
// contains the action's return value after form submission
updateResult: actionResult ?? null,
};
},
component: EditUserPage,
});

// Component receives data from the loader (which includes the action result)
function EditUserPage({ data, isPending }: RouteComponentPropsOf<typeof editUserRoute>) {
return (
<form method="post">
{data.updateResult?.error && (
<p className="error">{data.updateResult.error}</p>
)}
{data.updateResult?.success && (
<p className="success">User updated successfully!</p>
)}
<input name="name" defaultValue={data.user.name} />
<input name="email" defaultValue={data.user.email} />
<button type="submit" disabled={isPending}>
{isPending ? "Saving..." : "Save"}
</button>
</form>
);
}

// Route with action only (pure side effect, no data for component)
const deleteRoute = route({
path: "/users/:userId/delete",
action: async ({ params, signal }) => {
await fetch(\`/api/users/\${params.userId}\`, {
method: "DELETE",
signal,
});
},
component: DeleteConfirmation,
});`}</CodeBlock>
<p>Key behaviors:</p>
<ul>
<li>
Actions handle POST form submissions; loaders handle GET navigations
</li>
<li>Action results are never cached — each submission runs fresh</li>
<li>
After an action completes, all matched loaders are revalidated
</li>
<li>
POST submissions to routes without an action are not intercepted
(browser handles normally)
</li>
<li>
The deepest matched route with an action handles the submission
</li>
</ul>
</section>

<section>
<h2>Search Parameters</h2>
<p>Work with URL query parameters:</p>
Expand Down
1 change: 1 addition & 0 deletions packages/router/src/__tests__/NavigationAPIAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ describe("setupInterception", () => {
expect(onNavigate).toHaveBeenCalledWith(event, {
matches: [],
intercepting: false,
formData: null,
});
});

Expand Down
Loading