@rangojs/router 0.0.0-experimental.96 → 0.0.0-experimental.97
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -0
- package/dist/vite/index.js +1 -1
- package/package.json +14 -15
- package/skills/hooks/SKILL.md +5 -0
- package/skills/links/SKILL.md +2 -0
- package/skills/loader/SKILL.md +4 -1
- package/skills/middleware/SKILL.md +2 -0
- package/skills/migrate-nextjs/SKILL.md +3 -1
- package/skills/migrate-react-router/SKILL.md +4 -0
- package/skills/rango/SKILL.md +1 -0
- package/skills/server-actions/SKILL.md +739 -0
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@ Named-route RSC router with structural composability and type-safe partial rende
|
|
|
10
10
|
- **Structural composability** — Attach routes, loaders, middleware, handles, caching, prerendering, and static generation without hiding the route tree
|
|
11
11
|
- **Composable URL patterns** — Django-style `urls()` DSL with `path`, `layout`, `include`
|
|
12
12
|
- **Data loaders** — `createLoader()` with automatic streaming and Suspense integration
|
|
13
|
+
- **Server actions** — `"use server"` mutations with `useActionState`, `useOptimistic`, and per-segment + per-loader `revalidate()` rules
|
|
13
14
|
- **Live data layer** — Pre-render or cache the UI shell while loaders stay live by default at request time
|
|
14
15
|
- **Layouts & nesting** — Nested layouts with `<Outlet />` and parallel routes
|
|
15
16
|
- **Segment-level caching** — `cache()` DSL with TTL/SWR and pluggable cache stores
|
|
@@ -482,6 +483,70 @@ const urlpatterns = urls(({ path, loader }) => [
|
|
|
482
483
|
]);
|
|
483
484
|
```
|
|
484
485
|
|
|
486
|
+
## Server Actions
|
|
487
|
+
|
|
488
|
+
Server actions are React's RSC mutation primitive. Define them with the
|
|
489
|
+
`"use server"` directive — Rango uses standard React 19 hooks
|
|
490
|
+
(`useActionState`, `useFormStatus`, `useOptimistic`) with no framework wrapper.
|
|
491
|
+
|
|
492
|
+
```tsx
|
|
493
|
+
// app/actions/cart.ts
|
|
494
|
+
"use server";
|
|
495
|
+
|
|
496
|
+
import { getRequestContext } from "@rangojs/router";
|
|
497
|
+
|
|
498
|
+
export async function addToCart(productId: string): Promise<void> {
|
|
499
|
+
const ctx = getRequestContext();
|
|
500
|
+
const userId = ctx.get("user").id;
|
|
501
|
+
await db.cart.insert({ userId, productId });
|
|
502
|
+
}
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
```tsx
|
|
506
|
+
// Client form with progressive enhancement + pending state
|
|
507
|
+
"use client";
|
|
508
|
+
import { useActionState } from "react";
|
|
509
|
+
import { saveProfile } from "../actions/profile";
|
|
510
|
+
|
|
511
|
+
export function ProfileForm() {
|
|
512
|
+
const [state, action, pending] = useActionState(saveProfile, null);
|
|
513
|
+
return (
|
|
514
|
+
<form action={action}>
|
|
515
|
+
<input name="name" defaultValue={state?.values?.name} />
|
|
516
|
+
{state?.errors?.name && <p role="alert">{state.errors.name}</p>}
|
|
517
|
+
<button disabled={pending}>{pending ? "Saving…" : "Save"}</button>
|
|
518
|
+
</form>
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
After an action runs, matched route segments (path/layout/parallel/intercept)
|
|
524
|
+
and loaders can re-render/re-resolve so the UI reflects the new state.
|
|
525
|
+
Attach a `revalidate(({ actionId }) => ...)` rule on any segment or loader
|
|
526
|
+
that owns data the action touched:
|
|
527
|
+
|
|
528
|
+
```tsx
|
|
529
|
+
urls(({ path, loader, revalidate }) => [
|
|
530
|
+
// Segment-level: re-render the cart page handler after cart actions.
|
|
531
|
+
// Nest loaders that belong to this route inside the same path() so the
|
|
532
|
+
// segment owns its data dependencies.
|
|
533
|
+
path("/cart", CartPage, { name: "cart" }, () => [
|
|
534
|
+
revalidate(
|
|
535
|
+
({ actionId }) => actionId?.startsWith("src/actions/cart.ts#") ?? false,
|
|
536
|
+
),
|
|
537
|
+
loader(CartLoader, () => [
|
|
538
|
+
revalidate(
|
|
539
|
+
({ actionId }) => actionId?.startsWith("src/actions/cart.ts#") ?? false,
|
|
540
|
+
),
|
|
541
|
+
]),
|
|
542
|
+
]),
|
|
543
|
+
]);
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
For the full guide — validation with Zod, error handling, file uploads,
|
|
547
|
+
`useOptimistic`, redirects, and progressive enhancement — see the
|
|
548
|
+
`/server-actions` skill.
|
|
549
|
+
|
|
485
550
|
## Navigation & Links
|
|
486
551
|
|
|
487
552
|
### Named Routes with `ctx.reverse()` (Server)
|
package/dist/vite/index.js
CHANGED
|
@@ -2040,7 +2040,7 @@ import { resolve } from "node:path";
|
|
|
2040
2040
|
// package.json
|
|
2041
2041
|
var package_default = {
|
|
2042
2042
|
name: "@rangojs/router",
|
|
2043
|
-
version: "0.0.0-experimental.
|
|
2043
|
+
version: "0.0.0-experimental.97",
|
|
2044
2044
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
2045
2045
|
keywords: [
|
|
2046
2046
|
"react",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rangojs/router",
|
|
3
|
-
"version": "0.0.0-experimental.
|
|
3
|
+
"version": "0.0.0-experimental.97",
|
|
4
4
|
"description": "Django-inspired RSC router with composable URL patterns",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -132,15 +132,6 @@
|
|
|
132
132
|
"access": "public",
|
|
133
133
|
"tag": "experimental"
|
|
134
134
|
},
|
|
135
|
-
"scripts": {
|
|
136
|
-
"build": "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm dlx esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
|
|
137
|
-
"prepublishOnly": "pnpm build",
|
|
138
|
-
"typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit",
|
|
139
|
-
"test": "playwright test",
|
|
140
|
-
"test:ui": "playwright test --ui",
|
|
141
|
-
"test:unit": "vitest run",
|
|
142
|
-
"test:unit:watch": "vitest"
|
|
143
|
-
},
|
|
144
135
|
"dependencies": {
|
|
145
136
|
"@types/debug": "^4.1.12",
|
|
146
137
|
"@vitejs/plugin-rsc": "^0.5.23",
|
|
@@ -152,12 +143,12 @@
|
|
|
152
143
|
"devDependencies": {
|
|
153
144
|
"@playwright/test": "^1.49.1",
|
|
154
145
|
"@types/node": "^24.10.1",
|
|
155
|
-
"@types/react": "
|
|
156
|
-
"@types/react-dom": "
|
|
146
|
+
"@types/react": "^19.2.7",
|
|
147
|
+
"@types/react-dom": "^19.2.3",
|
|
157
148
|
"esbuild": "^0.27.0",
|
|
158
149
|
"jiti": "^2.6.1",
|
|
159
|
-
"react": "
|
|
160
|
-
"react-dom": "
|
|
150
|
+
"react": "^19.2.4",
|
|
151
|
+
"react-dom": "^19.2.4",
|
|
161
152
|
"tinyexec": "^0.3.2",
|
|
162
153
|
"typescript": "^5.3.0",
|
|
163
154
|
"vitest": "^4.0.0"
|
|
@@ -175,5 +166,13 @@
|
|
|
175
166
|
"vite": {
|
|
176
167
|
"optional": true
|
|
177
168
|
}
|
|
169
|
+
},
|
|
170
|
+
"scripts": {
|
|
171
|
+
"build": "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm dlx esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
|
|
172
|
+
"typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit",
|
|
173
|
+
"test": "playwright test",
|
|
174
|
+
"test:ui": "playwright test --ui",
|
|
175
|
+
"test:unit": "vitest run",
|
|
176
|
+
"test:unit:watch": "vitest"
|
|
178
177
|
}
|
|
179
|
-
}
|
|
178
|
+
}
|
package/skills/hooks/SKILL.md
CHANGED
|
@@ -323,6 +323,11 @@ RSC serialization strips the `collect` function via `toJSON()`. On the client,
|
|
|
323
323
|
|
|
324
324
|
## Action Hooks
|
|
325
325
|
|
|
326
|
+
For the full server-action guide (defining actions, `useActionState`,
|
|
327
|
+
`useOptimistic`, validation, revalidation, error handling, file uploads),
|
|
328
|
+
see `/server-actions`. `useAction()` below is a Rango-specific hook for
|
|
329
|
+
tracking actions called outside a `<form action={...}>` flow.
|
|
330
|
+
|
|
326
331
|
### useAction()
|
|
327
332
|
|
|
328
333
|
Track state of server action invocations:
|
package/skills/links/SKILL.md
CHANGED
|
@@ -187,6 +187,8 @@ export async function getProductUrl(slug: string) {
|
|
|
187
187
|
}
|
|
188
188
|
```
|
|
189
189
|
|
|
190
|
+
See `/server-actions` for the full action surface (`getRequestContext()` is the same context middleware and handlers use).
|
|
191
|
+
|
|
190
192
|
For static path strings (not named routes), client components can use `href()` — see below.
|
|
191
193
|
|
|
192
194
|
## Client: href()
|
package/skills/loader/SKILL.md
CHANGED
|
@@ -6,7 +6,10 @@ argument-hint: [loader]
|
|
|
6
6
|
|
|
7
7
|
# Data Loaders with loader()
|
|
8
8
|
|
|
9
|
-
Loaders fetch data on the server and stream it to the client.
|
|
9
|
+
Loaders fetch data on the server and stream it to the client. For mutations
|
|
10
|
+
(writes triggered by forms or buttons), use server actions instead — see
|
|
11
|
+
`/server-actions`. Loaders re-resolve after an action runs, so the typical
|
|
12
|
+
flow is _action mutates → loader re-reads → UI updates_.
|
|
10
13
|
|
|
11
14
|
## Creating a Loader
|
|
12
15
|
|
|
@@ -32,6 +32,8 @@ When the router has a `basename`, pattern-scoped `.use()` patterns are automatic
|
|
|
32
32
|
|
|
33
33
|
Registered inside `urls()` callback. Wraps **rendering only** -- it does NOT wrap server action execution. Actions run before route middleware, so when route middleware executes during post-action revalidation, it can observe state that the action set (cookies, context variables, headers).
|
|
34
34
|
|
|
35
|
+
> **Implication for auth:** route middleware cannot guard server actions. Use `router.use("/admin/*", requireAuth)` (global, scoped) for action protection, or check inside the action body. See `/server-actions` for action-side auth patterns.
|
|
36
|
+
|
|
35
37
|
```
|
|
36
38
|
Request flow (with action):
|
|
37
39
|
global mw -> action executes -> route mw -> layout -> handler -> loaders
|
|
@@ -225,7 +225,7 @@ Loaders are Rango's live data layer. Use them when you need:
|
|
|
225
225
|
|
|
226
226
|
- **Client-side data refresh** — `useLoader()` in client components for reactive data
|
|
227
227
|
- **Per-loader caching** — opt in with `loader(MyLoader, () => [cache({ ttl: 60 })])`; loaders stay live by default
|
|
228
|
-
- **Revalidation control** — `revalidate()` targets specific loaders after actions
|
|
228
|
+
- **Revalidation control** — `revalidate()` targets specific segments and loaders after actions
|
|
229
229
|
- **Loading skeletons** — `loading()` shows a Suspense fallback while loaders resolve
|
|
230
230
|
|
|
231
231
|
```typescript
|
|
@@ -463,6 +463,8 @@ Server actions work the same way — `"use server"` directive, `useActionState`,
|
|
|
463
463
|
|
|
464
464
|
Key difference: in Rango, route middleware does NOT wrap action execution. Actions only see global middleware context. Use `getRequestContext()` in actions to access `ctx.set()`/`ctx.get()`.
|
|
465
465
|
|
|
466
|
+
Next.js's `revalidatePath()` / `revalidateTag()` have no direct equivalent — Rango partially re-renders matched route segments (path/layout/parallel/intercept) and re-resolves their loaders, and you scope re-runs by attaching a `revalidate(({ actionId }) => ...)` rule to any segment or loader registration. See `/server-actions` for the full pattern (validation, error handling, file uploads) and `/loader` for revalidation rule semantics.
|
|
467
|
+
|
|
466
468
|
## 8. Metadata / Head
|
|
467
469
|
|
|
468
470
|
Rango uses the `Meta` handle + `<MetaTags />` client component:
|
|
@@ -483,6 +483,10 @@ Since Rango uses RSC server actions, all React action patterns work:
|
|
|
483
483
|
`useActionState`, `useOptimistic`, `useTransition`, `startTransition`,
|
|
484
484
|
and plain `<form action={serverAction}>`. No framework-specific hook needed.
|
|
485
485
|
|
|
486
|
+
For the full guide — defining actions, validation with Zod, error handling,
|
|
487
|
+
revalidation rules, file uploads, and progressive enhancement — see
|
|
488
|
+
`/server-actions`.
|
|
489
|
+
|
|
486
490
|
### clientLoader / clientAction (framework mode)
|
|
487
491
|
|
|
488
492
|
RR7 framework mode's `clientLoader` and `clientAction` run in the browser.
|
package/skills/rango/SKILL.md
CHANGED
|
@@ -16,6 +16,7 @@ Django-inspired RSC router with composable URL patterns, type-safe href, and ser
|
|
|
16
16
|
| `/route` | Define routes with `urls()` and `path()` |
|
|
17
17
|
| `/layout` | Layouts that wrap child routes |
|
|
18
18
|
| `/loader` | Data loaders with `createLoader()` |
|
|
19
|
+
| `/server-actions` | Mutations with `"use server"`, useActionState, validation, revalidation |
|
|
19
20
|
| `/middleware` | Request processing and authentication |
|
|
20
21
|
| `/intercept` | Modal/slide-over patterns for soft navigation |
|
|
21
22
|
| `/parallel` | Multi-column layouts and sidebars |
|
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: server-actions
|
|
3
|
+
description: Define and call server actions (`"use server"`) — forms, useActionState, useOptimistic, validation, error handling, redirects, revalidation
|
|
4
|
+
argument-hint: "[action]"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Server Actions with `"use server"`
|
|
8
|
+
|
|
9
|
+
Server actions are async functions that run on the server and are callable from
|
|
10
|
+
the client. They are React's RSC mutation primitive — Rango uses them as-is
|
|
11
|
+
with no framework wrapper. All standard React hooks (`useActionState`,
|
|
12
|
+
`useFormStatus`, `useOptimistic`, `useTransition`) work directly.
|
|
13
|
+
|
|
14
|
+
## When to Use Actions vs Loaders
|
|
15
|
+
|
|
16
|
+
| Need | Use |
|
|
17
|
+
| ---------------------------------- | -------------------------------------------- |
|
|
18
|
+
| Mutate state and revalidate UI | Server action |
|
|
19
|
+
| Read live data on every navigation | `createLoader()` + `useLoader()` |
|
|
20
|
+
| Read on demand from the client | Fetchable loader + `useFetchLoader()` |
|
|
21
|
+
| Submit a form and show the result | Action + `useActionState` |
|
|
22
|
+
| File upload | Action with `FormData` (or fetchable loader) |
|
|
23
|
+
|
|
24
|
+
Use loaders and route handlers for reads. Use actions for writes. After an
|
|
25
|
+
action runs, the matched route tree can partially re-render so handlers and
|
|
26
|
+
loaders that opt into revalidation see the new state — see "Revalidation"
|
|
27
|
+
below.
|
|
28
|
+
|
|
29
|
+
## Revalidation Model
|
|
30
|
+
|
|
31
|
+
Actions mutate state; route handlers and loaders read the latest state. After
|
|
32
|
+
an action finishes, Rango performs a server-side revalidation render for the
|
|
33
|
+
matched route so the UI receives fresh segment output and loader data.
|
|
34
|
+
|
|
35
|
+
The main control point is `revalidate(({ actionId }) => ...)` on the segment
|
|
36
|
+
that owns the data. This applies to `path()` handlers, `layout()` handlers,
|
|
37
|
+
`parallel()` slots, `intercept()` routes, and loader registrations:
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
// urls.tsx — path/layout/parallel/intercept/loader/revalidate are passed in by urls()
|
|
41
|
+
import { urls } from "@rangojs/router";
|
|
42
|
+
|
|
43
|
+
export const urlpatterns = urls(({ path, loader, revalidate }) => [
|
|
44
|
+
// The loader belongs to the route that consumes its data — nest it inside
|
|
45
|
+
// the owning path() so the segment owns its data dependency.
|
|
46
|
+
path("/cart", CartPage, { name: "cart" }, () => [
|
|
47
|
+
revalidate(
|
|
48
|
+
({ actionId }) => actionId?.startsWith("src/actions/cart.ts#") ?? false,
|
|
49
|
+
),
|
|
50
|
+
loader(CartLoader, () => [
|
|
51
|
+
revalidate(
|
|
52
|
+
({ actionId }) => actionId?.startsWith("src/actions/cart.ts#") ?? false,
|
|
53
|
+
),
|
|
54
|
+
]),
|
|
55
|
+
]),
|
|
56
|
+
]);
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
For module-level `"use server"` files, the `actionId` passed to every
|
|
60
|
+
server-side `revalidate()` predicate is path-bearing in the server/RSC
|
|
61
|
+
environment in both dev and production: `src/actions/cart.ts#addToCart`. This
|
|
62
|
+
is intentional so path, layout, parallel, intercept, and loader revalidation
|
|
63
|
+
predicates can filter by action file, directory, or export name.
|
|
64
|
+
|
|
65
|
+
Actions and the follow-up revalidation render share one request context.
|
|
66
|
+
Values written in the action with `ctx.set(MyVar, value)` or `ctx.set("key",
|
|
67
|
+
value)` are visible to downstream route middleware, handlers, loaders, and
|
|
68
|
+
`revalidate()` callbacks through `context.get(MyVar)` / `context.get("key")`:
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
// app/context.ts
|
|
72
|
+
import { createVar } from "@rangojs/router";
|
|
73
|
+
|
|
74
|
+
export const ChangedTenant = createVar<string>();
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
// app/actions/tenant.ts
|
|
79
|
+
"use server";
|
|
80
|
+
|
|
81
|
+
import { getRequestContext } from "@rangojs/router";
|
|
82
|
+
import { ChangedTenant } from "../context";
|
|
83
|
+
|
|
84
|
+
export async function switchTenant(tenantId: string) {
|
|
85
|
+
const ctx = getRequestContext();
|
|
86
|
+
ctx.set(ChangedTenant, tenantId);
|
|
87
|
+
await db.tenants.touch(tenantId);
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
// urls.tsx
|
|
93
|
+
import { urls } from "@rangojs/router";
|
|
94
|
+
import { ChangedTenant } from "./context";
|
|
95
|
+
|
|
96
|
+
export const urlpatterns = urls(({ path, revalidate }) => [
|
|
97
|
+
path("/dashboard/:tenantId", DashboardPage, { name: "dashboard" }, () => [
|
|
98
|
+
revalidate(
|
|
99
|
+
({ actionId, context }) =>
|
|
100
|
+
actionId?.startsWith("src/actions/tenant.ts#") &&
|
|
101
|
+
context.get(ChangedTenant) === context.params.tenantId,
|
|
102
|
+
),
|
|
103
|
+
]),
|
|
104
|
+
]);
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Client and SSR-facing action references keep hashed IDs in production for
|
|
108
|
+
security and hydration compatibility. Do path matching inside server-side
|
|
109
|
+
`revalidate()` predicates, not from client action metadata. Inline actions
|
|
110
|
+
declared inside RSC components also keep hashed IDs; if you need path-based
|
|
111
|
+
revalidation, export the action from a module-level `"use server"` file.
|
|
112
|
+
|
|
113
|
+
## Defining an Action
|
|
114
|
+
|
|
115
|
+
Two equivalent patterns. Prefer the file-level form for anything reusable.
|
|
116
|
+
|
|
117
|
+
### File-level (`"use server"` at the top of a module)
|
|
118
|
+
|
|
119
|
+
Every exported async function becomes a callable server action.
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
// app/actions/cart.ts
|
|
123
|
+
"use server";
|
|
124
|
+
|
|
125
|
+
import { cookies } from "@rangojs/router";
|
|
126
|
+
|
|
127
|
+
export async function addToCart(productId: string): Promise<void> {
|
|
128
|
+
const userId = cookies().get("user-id")?.value;
|
|
129
|
+
await db.cart.insert({ userId, productId });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function removeFromCart(productId: string): Promise<void> {
|
|
133
|
+
const userId = cookies().get("user-id")?.value;
|
|
134
|
+
await db.cart.delete({ userId, productId });
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Inline (`"use server"` inside a server component)
|
|
139
|
+
|
|
140
|
+
Define a one-off action where it is used. Captured variables are serialized,
|
|
141
|
+
so keep them small and serializable.
|
|
142
|
+
|
|
143
|
+
```tsx
|
|
144
|
+
// Server component (handler)
|
|
145
|
+
import type { Handler } from "@rangojs/router";
|
|
146
|
+
|
|
147
|
+
const SettingsPage: Handler<"settings"> = (ctx) => {
|
|
148
|
+
const userId = ctx.get("user").id;
|
|
149
|
+
|
|
150
|
+
async function updateName(formData: FormData) {
|
|
151
|
+
"use server";
|
|
152
|
+
await db.users.update(userId, { name: formData.get("name") as string });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<form action={updateName}>
|
|
157
|
+
<input name="name" />
|
|
158
|
+
<button type="submit">Save</button>
|
|
159
|
+
</form>
|
|
160
|
+
);
|
|
161
|
+
};
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Calling Actions from the Client
|
|
165
|
+
|
|
166
|
+
Three patterns, in order of how much state you need to preserve:
|
|
167
|
+
|
|
168
|
+
### 1. Plain `<form action={...}>` — fire-and-forget
|
|
169
|
+
|
|
170
|
+
Submits as `FormData`. Works without JavaScript (progressive enhancement).
|
|
171
|
+
The page revalidates after the action returns.
|
|
172
|
+
|
|
173
|
+
```tsx
|
|
174
|
+
"use client";
|
|
175
|
+
import { addToCart } from "../actions/cart";
|
|
176
|
+
|
|
177
|
+
export function AddToCartForm({ productId }: { productId: string }) {
|
|
178
|
+
return (
|
|
179
|
+
<form action={addToCart.bind(null, productId)}>
|
|
180
|
+
<button type="submit">Add to cart</button>
|
|
181
|
+
</form>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### 2. `useActionState` — preserve return value + pending state
|
|
187
|
+
|
|
188
|
+
Standard React 19 hook. The action receives `(prevState, formData)` and its
|
|
189
|
+
return value becomes the new `state`. The form input values are preserved by
|
|
190
|
+
the browser on validation errors as long as you re-render the same form
|
|
191
|
+
element with `defaultValue` (not `value`).
|
|
192
|
+
|
|
193
|
+
Define the state shape next to the action so the client and server share
|
|
194
|
+
one type:
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
// app/actions/profile.ts
|
|
198
|
+
"use server";
|
|
199
|
+
|
|
200
|
+
export type ProfileFormState = {
|
|
201
|
+
errors?: Record<string, string>;
|
|
202
|
+
values?: { name: string };
|
|
203
|
+
} | null;
|
|
204
|
+
|
|
205
|
+
export async function saveProfile(
|
|
206
|
+
_prev: ProfileFormState,
|
|
207
|
+
formData: FormData,
|
|
208
|
+
): Promise<ProfileFormState> {
|
|
209
|
+
const name = (formData.get("name") as string)?.trim() ?? "";
|
|
210
|
+
if (!name) {
|
|
211
|
+
return { errors: { name: "Name is required" }, values: { name } };
|
|
212
|
+
}
|
|
213
|
+
await db.users.update({ name });
|
|
214
|
+
return null; // success
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
```tsx
|
|
219
|
+
"use client";
|
|
220
|
+
import { useActionState } from "react";
|
|
221
|
+
import { saveProfile, type ProfileFormState } from "../actions/profile";
|
|
222
|
+
|
|
223
|
+
export function ProfileForm({ initial }: { initial: { name: string } }) {
|
|
224
|
+
const [state, formAction, isPending] = useActionState<
|
|
225
|
+
ProfileFormState,
|
|
226
|
+
FormData
|
|
227
|
+
>(saveProfile, null);
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<form action={formAction}>
|
|
231
|
+
<input
|
|
232
|
+
name="name"
|
|
233
|
+
defaultValue={state?.values?.name ?? initial.name}
|
|
234
|
+
aria-invalid={!!state?.errors?.name}
|
|
235
|
+
/>
|
|
236
|
+
{state?.errors?.name && <p role="alert">{state.errors.name}</p>}
|
|
237
|
+
<button type="submit" disabled={isPending}>
|
|
238
|
+
{isPending ? "Saving…" : "Save"}
|
|
239
|
+
</button>
|
|
240
|
+
</form>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
**Why `defaultValue`, not `value`** — on validation error the form re-renders.
|
|
246
|
+
With `value` the inputs reset; with `defaultValue` (and a stable form key) the
|
|
247
|
+
browser keeps user input. Re-echo the submitted values in `state.values` so
|
|
248
|
+
they survive a full no-JS re-render too.
|
|
249
|
+
|
|
250
|
+
### 3. `useOptimistic` — instant UI before the action settles
|
|
251
|
+
|
|
252
|
+
For reactive UI like quantity controls, like buttons, or todo toggles.
|
|
253
|
+
Combine with `startTransition` so the optimistic update is part of the same
|
|
254
|
+
transition as the action call.
|
|
255
|
+
|
|
256
|
+
```tsx
|
|
257
|
+
"use client";
|
|
258
|
+
import { useOptimistic, startTransition } from "react";
|
|
259
|
+
import { updateQuantity } from "../actions/cart";
|
|
260
|
+
|
|
261
|
+
export function QuantityControl({
|
|
262
|
+
productId,
|
|
263
|
+
initialQuantity,
|
|
264
|
+
}: {
|
|
265
|
+
productId: string;
|
|
266
|
+
initialQuantity: number;
|
|
267
|
+
}) {
|
|
268
|
+
const [optimistic, setOptimistic] = useOptimistic(
|
|
269
|
+
initialQuantity,
|
|
270
|
+
(_current, next: number) => next,
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
function change(delta: number) {
|
|
274
|
+
const next = Math.max(0, optimistic + delta);
|
|
275
|
+
startTransition(async () => {
|
|
276
|
+
setOptimistic(next);
|
|
277
|
+
await updateQuantity(productId, delta);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return (
|
|
282
|
+
<div>
|
|
283
|
+
<button onClick={() => change(-1)}>-</button>
|
|
284
|
+
<span>{optimistic}</span>
|
|
285
|
+
<button onClick={() => change(1)}>+</button>
|
|
286
|
+
</div>
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
`useOptimistic` resets to the real value once the surrounding transition
|
|
292
|
+
settles and React re-renders with the post-action loader data.
|
|
293
|
+
|
|
294
|
+
### `useFormStatus` — pending state in nested children
|
|
295
|
+
|
|
296
|
+
When the submit button is in a separate component from the form, use
|
|
297
|
+
`useFormStatus()` instead of threading `isPending` down via props.
|
|
298
|
+
|
|
299
|
+
```tsx
|
|
300
|
+
"use client";
|
|
301
|
+
import { useFormStatus } from "react-dom";
|
|
302
|
+
|
|
303
|
+
export function SubmitButton({ children }: { children: React.ReactNode }) {
|
|
304
|
+
const { pending } = useFormStatus();
|
|
305
|
+
return (
|
|
306
|
+
<button type="submit" disabled={pending}>
|
|
307
|
+
{pending ? "Working…" : children}
|
|
308
|
+
</button>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
`useFormStatus` only reports the form it is rendered inside — it does not
|
|
314
|
+
observe other forms.
|
|
315
|
+
|
|
316
|
+
## Validation with Zod
|
|
317
|
+
|
|
318
|
+
Validate on the server, return structured errors via `useActionState`. Keep
|
|
319
|
+
the schema next to the action so client and server agree on the shape.
|
|
320
|
+
|
|
321
|
+
```typescript
|
|
322
|
+
// app/actions/signup.ts
|
|
323
|
+
"use server";
|
|
324
|
+
|
|
325
|
+
import { z } from "zod";
|
|
326
|
+
import { redirect } from "@rangojs/router";
|
|
327
|
+
|
|
328
|
+
const SignupSchema = z.object({
|
|
329
|
+
email: z.string().email("Enter a valid email"),
|
|
330
|
+
password: z.string().min(8, "At least 8 characters"),
|
|
331
|
+
name: z.string().min(1, "Required"),
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
export type SignupState = {
|
|
335
|
+
errors?: Partial<Record<keyof z.infer<typeof SignupSchema>, string>>;
|
|
336
|
+
values?: Partial<z.infer<typeof SignupSchema>>;
|
|
337
|
+
} | null;
|
|
338
|
+
|
|
339
|
+
export async function signup(
|
|
340
|
+
_prev: SignupState,
|
|
341
|
+
formData: FormData,
|
|
342
|
+
): Promise<SignupState> {
|
|
343
|
+
const raw = {
|
|
344
|
+
email: formData.get("email") as string,
|
|
345
|
+
password: formData.get("password") as string,
|
|
346
|
+
name: formData.get("name") as string,
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const parsed = SignupSchema.safeParse(raw);
|
|
350
|
+
if (!parsed.success) {
|
|
351
|
+
const errors: Record<string, string> = {};
|
|
352
|
+
for (const issue of parsed.error.issues) {
|
|
353
|
+
const key = String(issue.path[0]);
|
|
354
|
+
errors[key] ??= issue.message; // first error per field
|
|
355
|
+
}
|
|
356
|
+
// Echo back values (omit password) so the form preserves user input.
|
|
357
|
+
const { password: _drop, ...safeValues } = raw;
|
|
358
|
+
return { errors, values: safeValues };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
await db.users.create(parsed.data);
|
|
362
|
+
throw redirect("/welcome");
|
|
363
|
+
}
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
The form reads `state.errors` field-by-field and re-uses `state.values` as
|
|
367
|
+
`defaultValue`s (see `useActionState` example above). Never echo back
|
|
368
|
+
secrets like passwords.
|
|
369
|
+
|
|
370
|
+
For schemas shared with a `useFetchLoader()` JSON body, parse the same way:
|
|
371
|
+
|
|
372
|
+
```typescript
|
|
373
|
+
const parsed = SignupSchema.safeParse(ctx.body);
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
## Revalidation After an Action
|
|
377
|
+
|
|
378
|
+
When an action mutates data, the matched route tree may need to partially
|
|
379
|
+
re-render so the UI updates. Rango runs the action, then evaluates
|
|
380
|
+
`revalidate()` on matched segments and loaders. Each path, layout, parallel,
|
|
381
|
+
intercept, or loader rule decides whether that piece re-renders/re-resolves.
|
|
382
|
+
|
|
383
|
+
The `actionId` arrives as part of the revalidation context — match it to
|
|
384
|
+
scope re-runs to specific actions.
|
|
385
|
+
|
|
386
|
+
```typescript
|
|
387
|
+
// urls.tsx — inside the urls() callback. Nest each loader inside the path(),
|
|
388
|
+
// layout(), or parallel() that owns its data so the route tree mirrors the
|
|
389
|
+
// data dependencies.
|
|
390
|
+
urls(({ path, loader, revalidate }) => [
|
|
391
|
+
path("/", HomePage, { name: "home" }, () => [
|
|
392
|
+
// Loader data re-runs by default after any action. Opt out with revalidate(() => false).
|
|
393
|
+
loader(StaticHomepageLoader, () => [revalidate(() => false)]),
|
|
394
|
+
]),
|
|
395
|
+
|
|
396
|
+
// Re-render the cart page handler AND re-resolve its loader after cart actions
|
|
397
|
+
path("/cart", CartPage, { name: "cart" }, () => [
|
|
398
|
+
revalidate(
|
|
399
|
+
({ actionId }) => actionId?.startsWith("src/actions/cart.ts#") ?? false,
|
|
400
|
+
),
|
|
401
|
+
loader(CartLoader, () => [
|
|
402
|
+
revalidate(
|
|
403
|
+
({ actionId }) => actionId?.startsWith("src/actions/cart.ts#") ?? false,
|
|
404
|
+
),
|
|
405
|
+
]),
|
|
406
|
+
]),
|
|
407
|
+
|
|
408
|
+
// Re-run after any action under src/actions/account/
|
|
409
|
+
path("/account", AccountPage, { name: "account" }, () => [
|
|
410
|
+
loader(AccountLoader, () => [
|
|
411
|
+
revalidate(
|
|
412
|
+
({ actionId }) => actionId?.startsWith("src/actions/account/") ?? false,
|
|
413
|
+
),
|
|
414
|
+
]),
|
|
415
|
+
]),
|
|
416
|
+
]);
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
`actionId` is stable per action. For actions exported from a module-level
|
|
420
|
+
`"use server"` file, the ID is prefixed with the source file path
|
|
421
|
+
(`src/actions/cart.ts#addToCart`), so substring matching by file path is the
|
|
422
|
+
recommended scope. **Inline `"use server"` actions** (declared inside an RSC
|
|
423
|
+
component) intentionally keep their hashed IDs — file paths are withheld
|
|
424
|
+
from the client for security. If you need file-path-based revalidation
|
|
425
|
+
predicates, define the action in a module-level `"use server"` file rather
|
|
426
|
+
than inline. See `/loader` for the full revalidation contract (deferred
|
|
427
|
+
returns, soft suggestions).
|
|
428
|
+
|
|
429
|
+
### Cross-segment dependencies
|
|
430
|
+
|
|
431
|
+
If a loader reads `ctx.get()` data set by an outer layout/handler, that
|
|
432
|
+
outer segment must also re-run after the action — otherwise the loader sees
|
|
433
|
+
stale context. Share the same `revalidate` predicate on both producer and
|
|
434
|
+
consumer:
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
const revalidateCart = ({ actionId }) =>
|
|
438
|
+
actionId?.startsWith("src/actions/cart.ts#") ?? false;
|
|
439
|
+
|
|
440
|
+
urls(({ path, layout, loader, revalidate }) => [
|
|
441
|
+
layout(CartLayout, () => [
|
|
442
|
+
revalidate(revalidateCart), // producer reruns
|
|
443
|
+
path("/cart", CartPage, { name: "cart" }, () => [
|
|
444
|
+
loader(CartItemsLoader, () => [revalidate(revalidateCart)]), // consumer reruns
|
|
445
|
+
]),
|
|
446
|
+
]),
|
|
447
|
+
]);
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
See `/middleware` for the full cross-segment revalidation contract.
|
|
451
|
+
|
|
452
|
+
## Redirects
|
|
453
|
+
|
|
454
|
+
`redirect()` works inside actions. Both `return redirect(...)` and
|
|
455
|
+
`throw redirect(...)` are supported and behave the same way for the
|
|
456
|
+
client. Throwing is clearer when the redirect is conditional.
|
|
457
|
+
|
|
458
|
+
```typescript
|
|
459
|
+
"use server";
|
|
460
|
+
|
|
461
|
+
import { redirect, cookies } from "@rangojs/router";
|
|
462
|
+
import { FlashMessage } from "../location-states";
|
|
463
|
+
|
|
464
|
+
export async function login(_prev: unknown, formData: FormData) {
|
|
465
|
+
const email = formData.get("email") as string;
|
|
466
|
+
const session = await authenticate(email);
|
|
467
|
+
if (!session) return { error: "Invalid credentials" };
|
|
468
|
+
|
|
469
|
+
cookies().set("session", session.token, { httpOnly: true, path: "/" });
|
|
470
|
+
throw redirect("/dashboard", {
|
|
471
|
+
state: FlashMessage({ text: "Welcome back!" }),
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
Redirects from actions render the **target** route tree's matched segments
|
|
477
|
+
(paths, layouts, parallels, intercepts) and re-resolve its loaders, not the
|
|
478
|
+
source page's — the target is what the user sees next. See `/hooks
|
|
479
|
+
useLocationState` for reading flash state on the target page.
|
|
480
|
+
|
|
481
|
+
## Error Handling
|
|
482
|
+
|
|
483
|
+
### Validation errors — return them as state
|
|
484
|
+
|
|
485
|
+
Recoverable errors (form validation, business rules) should be returned via
|
|
486
|
+
`useActionState`. The form re-renders with the error and the user can fix
|
|
487
|
+
it. Throwing for a validation error escalates to an error boundary, which
|
|
488
|
+
is usually too aggressive.
|
|
489
|
+
|
|
490
|
+
### Unexpected errors — let them throw
|
|
491
|
+
|
|
492
|
+
Throw for genuinely exceptional conditions (network failure, DB outage,
|
|
493
|
+
auth violation). The nearest `errorBoundary()` in the route tree catches
|
|
494
|
+
them.
|
|
495
|
+
|
|
496
|
+
```typescript
|
|
497
|
+
import { errorBoundary } from "@rangojs/router";
|
|
498
|
+
|
|
499
|
+
layout(CheckoutLayout, () => [
|
|
500
|
+
errorBoundary(({ error, reset }) => (
|
|
501
|
+
<div>
|
|
502
|
+
<p>Checkout failed: {error.message}</p>
|
|
503
|
+
<button onClick={reset}>Try again</button>
|
|
504
|
+
</div>
|
|
505
|
+
)),
|
|
506
|
+
path("/checkout", CheckoutPage, { name: "checkout" }),
|
|
507
|
+
]);
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
### Not found from an action
|
|
511
|
+
|
|
512
|
+
```typescript
|
|
513
|
+
import { notFound } from "@rangojs/router";
|
|
514
|
+
|
|
515
|
+
export async function deletePost(id: string): Promise<void> {
|
|
516
|
+
"use server";
|
|
517
|
+
const post = await db.posts.find(id);
|
|
518
|
+
if (!post) notFound("Post not found"); // hits notFoundBoundary
|
|
519
|
+
await db.posts.delete(id);
|
|
520
|
+
}
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
### Authorization in actions
|
|
524
|
+
|
|
525
|
+
Route middleware does **not** wrap action execution — only global
|
|
526
|
+
middleware (`router.use()`) does. Auth checks must therefore live in
|
|
527
|
+
`router.use()` or inside the action itself. Don't rely on a route-level
|
|
528
|
+
`middleware()` to gate action access.
|
|
529
|
+
|
|
530
|
+
```typescript
|
|
531
|
+
// router.tsx — global guard wraps action + render
|
|
532
|
+
const router = createRouter()
|
|
533
|
+
.use(authInit)
|
|
534
|
+
.use("/admin/*", requireAdmin) // protects actions on /admin too
|
|
535
|
+
.routes(urlpatterns);
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
```typescript
|
|
539
|
+
// Or check inside the action body
|
|
540
|
+
"use server";
|
|
541
|
+
import { getRequestContext, redirect, notFound } from "@rangojs/router";
|
|
542
|
+
|
|
543
|
+
export class ForbiddenError extends Error {
|
|
544
|
+
constructor() {
|
|
545
|
+
super("You do not have permission to perform this action.");
|
|
546
|
+
this.name = "ForbiddenError";
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export async function deleteOrder(orderId: string) {
|
|
551
|
+
const ctx = getRequestContext();
|
|
552
|
+
const user = ctx.get("user");
|
|
553
|
+
if (!user) throw redirect("/login"); // unauthenticated → bounce to login
|
|
554
|
+
|
|
555
|
+
const order = await db.orders.get(orderId);
|
|
556
|
+
if (!order) notFound("Order not found"); // → notFoundBoundary
|
|
557
|
+
if (order.userId !== user.id) throw new ForbiddenError(); // → errorBoundary
|
|
558
|
+
|
|
559
|
+
await db.orders.delete(orderId);
|
|
560
|
+
}
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
> **Don't `throw new Response("Unauthorized", { status: 401 })`** — non-redirect
|
|
564
|
+
> Responses thrown from actions are treated as errors and routed to the nearest
|
|
565
|
+
> `errorBoundary()`, not returned as real HTTP responses (the dev build warns
|
|
566
|
+
> when you do this). Use `redirect()` to send unauthenticated users to a login
|
|
567
|
+
> page, `notFound()` for missing resources, and a domain error class for
|
|
568
|
+
> forbidden access so the boundary can render an appropriate UI. For
|
|
569
|
+
> recoverable cases, return `{ error: "..." }` via `useActionState` instead of
|
|
570
|
+
> throwing.
|
|
571
|
+
|
|
572
|
+
## Action Context
|
|
573
|
+
|
|
574
|
+
Actions can read the request context with `getRequestContext()`. This gives
|
|
575
|
+
the same context-variable, header, and reverse APIs that handlers and
|
|
576
|
+
middleware use.
|
|
577
|
+
|
|
578
|
+
```typescript
|
|
579
|
+
"use server";
|
|
580
|
+
import { getRequestContext, cookies, headers } from "@rangojs/router";
|
|
581
|
+
|
|
582
|
+
export async function trackEvent(name: string) {
|
|
583
|
+
const ctx = getRequestContext();
|
|
584
|
+
const user = ctx.get("user"); // set by global middleware
|
|
585
|
+
const ua = headers().get("user-agent");
|
|
586
|
+
const url = ctx.reverse("dashboard"); // type-safe URL by route name
|
|
587
|
+
await analytics.record({ name, userId: user?.id, ua, url });
|
|
588
|
+
}
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
State written via `ctx.set(...)` or `cookies().set(...)` during an action
|
|
592
|
+
is visible to downstream route middleware, segment handlers (path/layout/
|
|
593
|
+
parallel/intercept), loaders, and `revalidate()` callbacks during the
|
|
594
|
+
post-action revalidation render — actions and revalidation share the same
|
|
595
|
+
request scope.
|
|
596
|
+
|
|
597
|
+
### Constraints
|
|
598
|
+
|
|
599
|
+
| Constraint | Why |
|
|
600
|
+
| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
601
|
+
| Actions cannot return or throw a non-redirect `Response` | Return values go through RSC Flight serialization. A thrown non-redirect `Response` is treated as a regular error and hits the nearest `errorBoundary()` (dev warns). Use `redirect()`, `notFound()`, or domain errors. |
|
|
602
|
+
| Route DSL `middleware()` does not wrap actions | Actions execute before route middleware. Only global `router.use()` middleware (and its scoped variants) wrap action execution. |
|
|
603
|
+
| `useFetchLoader()` is for reads, not writes | Actions are the mutation primitive; loaders are for data fetching. |
|
|
604
|
+
|
|
605
|
+
Cookies/headers set in **global** `router.use()` middleware DO propagate to
|
|
606
|
+
action responses (the same merge path as a normal render). The constraint
|
|
607
|
+
specific to **per-fetchable-loader** middleware (`createLoader(fn, {
|
|
608
|
+
middleware })` on a POST request) is that it cannot set cookies — set them
|
|
609
|
+
in the loader body instead. See `/middleware` for the full middleware
|
|
610
|
+
contract.
|
|
611
|
+
|
|
612
|
+
## File Uploads
|
|
613
|
+
|
|
614
|
+
Forms with `enctype="multipart/form-data"` (or any file input) submit to
|
|
615
|
+
actions as `FormData`. Stream the file directly — don't buffer if the
|
|
616
|
+
runtime supports streaming.
|
|
617
|
+
|
|
618
|
+
Write the action with the `(prevState, formData) => newState` signature so
|
|
619
|
+
it can be passed straight to `<form action={uploadAvatar}>` (PE-compatible)
|
|
620
|
+
**and** to `useActionState` without a client-side wrapper:
|
|
621
|
+
|
|
622
|
+
```typescript
|
|
623
|
+
// app/actions/avatar.ts
|
|
624
|
+
"use server";
|
|
625
|
+
|
|
626
|
+
import { getRequestContext } from "@rangojs/router";
|
|
627
|
+
|
|
628
|
+
export type AvatarUploadState = { url?: string; error?: string } | null;
|
|
629
|
+
|
|
630
|
+
export async function uploadAvatar(
|
|
631
|
+
_prev: AvatarUploadState,
|
|
632
|
+
formData: FormData,
|
|
633
|
+
): Promise<AvatarUploadState> {
|
|
634
|
+
const file = formData.get("avatar") as File | null;
|
|
635
|
+
if (!file || file.size === 0) return { error: "No file selected" };
|
|
636
|
+
if (file.size > 5_000_000) return { error: "File too large (max 5MB)" };
|
|
637
|
+
|
|
638
|
+
const ctx = getRequestContext();
|
|
639
|
+
const key = `avatars/${crypto.randomUUID()}-${file.name}`;
|
|
640
|
+
await ctx.env.BUCKET.put(key, file.stream());
|
|
641
|
+
return { url: `/r2/${key}` };
|
|
642
|
+
}
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
```tsx
|
|
646
|
+
"use client";
|
|
647
|
+
import { useActionState } from "react";
|
|
648
|
+
import { uploadAvatar, type AvatarUploadState } from "../actions/avatar";
|
|
649
|
+
|
|
650
|
+
export function AvatarUpload() {
|
|
651
|
+
const [state, action, pending] = useActionState<AvatarUploadState, FormData>(
|
|
652
|
+
uploadAvatar,
|
|
653
|
+
null,
|
|
654
|
+
);
|
|
655
|
+
return (
|
|
656
|
+
<form action={action}>
|
|
657
|
+
<input type="file" name="avatar" accept="image/*" />
|
|
658
|
+
<button disabled={pending}>{pending ? "Uploading…" : "Upload"}</button>
|
|
659
|
+
{state?.error && <p role="alert">{state.error}</p>}
|
|
660
|
+
{state?.url && <img src={state.url} alt="" />}
|
|
661
|
+
</form>
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
Wrapping the action in a client-side inline function (`useActionState(async
|
|
667
|
+
(_prev, fd) => uploadAvatar(fd), null)`) breaks PE: that closure isn't a
|
|
668
|
+
server reference, so the form has no real `action` URL when JS hasn't
|
|
669
|
+
loaded. Keep the action's signature `(_prev, formData)` and pass it
|
|
670
|
+
directly.
|
|
671
|
+
|
|
672
|
+
For client-side upload progress or cancellation, use a fetchable loader
|
|
673
|
+
with `useFetchLoader()` instead — see `/hooks useFetchLoader`.
|
|
674
|
+
|
|
675
|
+
## Tracking Action State Without `useActionState`
|
|
676
|
+
|
|
677
|
+
Use `useAction()` from `@rangojs/router/client` to track an action's
|
|
678
|
+
state from outside a form (e.g. an action triggered by `onClick`).
|
|
679
|
+
|
|
680
|
+
```tsx
|
|
681
|
+
"use client";
|
|
682
|
+
import { useAction } from "@rangojs/router/client";
|
|
683
|
+
import { addToCart } from "../actions/cart";
|
|
684
|
+
|
|
685
|
+
function AddButton({ productId }: { productId: string }) {
|
|
686
|
+
const { state, error } = useAction(addToCart);
|
|
687
|
+
return (
|
|
688
|
+
<>
|
|
689
|
+
<button
|
|
690
|
+
onClick={() => addToCart(productId)}
|
|
691
|
+
disabled={state === "loading"}
|
|
692
|
+
>
|
|
693
|
+
{state === "loading" ? "Adding…" : "Add"}
|
|
694
|
+
</button>
|
|
695
|
+
{error && <p role="alert">{error.message}</p>}
|
|
696
|
+
</>
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
`useActionState` and `useAction` are complementary — use `useActionState`
|
|
702
|
+
for `<form action={...}>` flows, `useAction` for imperative button clicks
|
|
703
|
+
or to observe an action triggered elsewhere on the page.
|
|
704
|
+
|
|
705
|
+
## Progressive Enhancement
|
|
706
|
+
|
|
707
|
+
`<form action={serverAction}>` works without JavaScript: the form posts as a
|
|
708
|
+
normal HTTP request, the action runs, and the page re-renders server-side.
|
|
709
|
+
For PE to work, write actions that accept `FormData` directly (not curried
|
|
710
|
+
or wrapped):
|
|
711
|
+
|
|
712
|
+
```tsx
|
|
713
|
+
// Works with no-JS submission
|
|
714
|
+
<form action={submitName}>
|
|
715
|
+
<input name="name" />
|
|
716
|
+
<button>Submit</button>
|
|
717
|
+
</form>
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
```typescript
|
|
721
|
+
"use server";
|
|
722
|
+
export async function submitName(formData: FormData) {
|
|
723
|
+
const name = formData.get("name") as string;
|
|
724
|
+
await db.entries.add({ name });
|
|
725
|
+
}
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
`useActionState` and `useOptimistic` only enhance the experience once JS is
|
|
729
|
+
loaded — without JS, the underlying action still runs and the page still
|
|
730
|
+
re-renders. Don't rely on client-only state for required form behavior.
|
|
731
|
+
|
|
732
|
+
## Cross-references
|
|
733
|
+
|
|
734
|
+
- `/hooks` — `useAction`, `useFetchLoader`, `useLocationState` (flash state)
|
|
735
|
+
- `/loader` — read patterns, fetchable loaders, revalidation rule semantics
|
|
736
|
+
- `/middleware` — action vs render scope, revalidation contracts
|
|
737
|
+
- `/links` — `ctx.reverse()` and `getRequestContext().reverse()` from actions
|
|
738
|
+
- `/migrate-react-router` — `action()` → `"use server"` mapping
|
|
739
|
+
- `/migrate-nextjs` — Next.js server action parity
|