@rsc-kit/mcp 0.14.0 → 0.16.0
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/dist/answers.d.ts +7 -0
- package/dist/answers.js +28 -0
- package/dist/answers.js.map +1 -1
- package/dist/bundleGuides.d.ts +27 -0
- package/dist/bundleGuides.js +138 -0
- package/dist/bundleGuides.js.map +1 -0
- package/dist/index.js +21 -1
- package/dist/index.js.map +1 -1
- package/dist/recipes.js +138 -17
- package/dist/recipes.js.map +1 -1
- package/dist/report.d.ts +13 -0
- package/dist/report.js +1 -1
- package/dist/report.js.map +1 -1
- package/guides/api-routes.md +168 -0
- package/guides/authorization.md +288 -0
- package/guides/caching.md +57 -0
- package/guides/coming-from-next.md +151 -0
- package/guides/connection.md +98 -0
- package/guides/edge-caching.md +159 -0
- package/guides/errors.md +109 -0
- package/guides/file-uploads.md +119 -0
- package/guides/fonts.md +117 -0
- package/guides/forms.md +528 -0
- package/guides/getting-started.md +132 -0
- package/guides/images.md +83 -0
- package/guides/index.json +187 -0
- package/guides/installation.md +338 -0
- package/guides/introduction.md +119 -0
- package/guides/mcp.md +113 -0
- package/guides/metadata.md +289 -0
- package/guides/navigation.md +84 -0
- package/guides/no-javascript.md +76 -0
- package/guides/offline.md +215 -0
- package/guides/ppr.md +181 -0
- package/guides/pwa.md +260 -0
- package/guides/queries.md +340 -0
- package/guides/quick-start.md +99 -0
- package/guides/react-compiler.md +153 -0
- package/guides/redirects.md +143 -0
- package/guides/response-headers.md +66 -0
- package/guides/route-interception.md +206 -0
- package/guides/routing.md +458 -0
- package/guides/sections.md +74 -0
- package/guides/server-actions.md +444 -0
- package/guides/static-generation.md +347 -0
- package/guides/testing.md +158 -0
- package/guides/third-party-scripts.md +105 -0
- package/guides/typed-routes.md +139 -0
- package/guides/url-validation.md +143 -0
- package/guides/validation.md +175 -0
- package/guides/view-transitions.md +120 -0
- package/package.json +4 -3
package/guides/forms.md
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
# Forms
|
|
2
|
+
|
|
3
|
+
> Progressive forms, pending state and validation errors.
|
|
4
|
+
|
|
5
|
+
`<Form>` submits to a server action: a component
|
|
6
|
+
that handles the state for you, and a hook for when you want to hold it
|
|
7
|
+
yourself. Both cover validation errors, pending state, optimistic updates and
|
|
8
|
+
GET-form navigation.
|
|
9
|
+
|
|
10
|
+
## The `<Form>` component
|
|
11
|
+
|
|
12
|
+
The simplest way to handle forms. Works without any hooks — just pass a server action and use the render-prop for pending state and errors.
|
|
13
|
+
|
|
14
|
+
```tsx title="TodoForm.tsx"
|
|
15
|
+
"use client";
|
|
16
|
+
|
|
17
|
+
import { Form } from "@rsc-kit/core/form";
|
|
18
|
+
import { addTodo } from "./actions";
|
|
19
|
+
|
|
20
|
+
type FormValues = { title: string };
|
|
21
|
+
|
|
22
|
+
export default function TodoForm() {
|
|
23
|
+
return (
|
|
24
|
+
<Form<FormValues> action={addTodo}>
|
|
25
|
+
{({ pending, error }) => (
|
|
26
|
+
<>
|
|
27
|
+
<input name="title" placeholder="What needs to be done?" />
|
|
28
|
+
{error('title') && <span className="text-red-500">{error('title')}</span>}
|
|
29
|
+
<button disabled={pending}>
|
|
30
|
+
{pending ? 'Adding...' : 'Add Todo'}
|
|
31
|
+
</button>
|
|
32
|
+
</>
|
|
33
|
+
)}
|
|
34
|
+
</Form>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The generic type parameter `<FormValues>` gives you autocomplete on `error()` and typed form data throughout the component.
|
|
40
|
+
|
|
41
|
+
### Props
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
action — server action function (POST) or URL string (GET)
|
|
45
|
+
method — "get" | "post" (defaults to "post" for functions, "get" for strings)
|
|
46
|
+
resetOnSuccess — auto-reset form on success (default: true)
|
|
47
|
+
optimistic — callback for optimistic updates, called inside the transition
|
|
48
|
+
onSuccess — called with the action result on success
|
|
49
|
+
onError — called with validation errors on 422
|
|
50
|
+
onSubmit — called before submit, return false to cancel
|
|
51
|
+
prefetch — "hover" (default) | "mount" | "none" (GET forms only)
|
|
52
|
+
replace — replace history state (GET forms)
|
|
53
|
+
preserveScroll — keep scroll position (GET forms)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### useFormStatus
|
|
57
|
+
|
|
58
|
+
Nested components can access form state via context, without prop drilling:
|
|
59
|
+
|
|
60
|
+
```tsx title="SubmitButton.tsx"
|
|
61
|
+
"use client";
|
|
62
|
+
|
|
63
|
+
import { useFormStatus } from "@rsc-kit/core/form";
|
|
64
|
+
|
|
65
|
+
export function SubmitButton() {
|
|
66
|
+
const { pending } = useFormStatus();
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<button type="submit" disabled={pending}>
|
|
70
|
+
{pending ? 'Saving...' : 'Save'}
|
|
71
|
+
</button>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Validation errors
|
|
79
|
+
|
|
80
|
+
When a submit fails validation, `<Form>` fills `errors`
|
|
81
|
+
themselves — there is no `try`/`catch` to write, and no state to hold. `errors`
|
|
82
|
+
is `Partial<Record<keyof T, string[]>>`: each field maps to its messages, and
|
|
83
|
+
`error('title')` returns the first one.
|
|
84
|
+
|
|
85
|
+
Where those errors come from depends on the host, and the components do not
|
|
86
|
+
care. See [Validation](/guides/validation).
|
|
87
|
+
|
|
88
|
+
An action can also name a field itself, for a refusal no schema could know
|
|
89
|
+
about. Through the [action client](/guides/server-actions#failing-on-something-a-schema-cannot-know)
|
|
90
|
+
`fieldErrors` arrives with the handler's arguments, typed to its input. A plain
|
|
91
|
+
`"use server"` function has no input type to draw on, so it imports the same
|
|
92
|
+
thing untyped:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
'use server';
|
|
96
|
+
|
|
97
|
+
import { fieldErrors } from '@rsc-kit/core/action';
|
|
98
|
+
|
|
99
|
+
export async function addTodo(formData: FormData) {
|
|
100
|
+
const title = String(formData.get('title'));
|
|
101
|
+
|
|
102
|
+
if (await exists(title)) return fieldErrors({ title: 'Already on the list' });
|
|
103
|
+
|
|
104
|
+
await save(title);
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Either way, write `return fieldErrors(…)`. The form renders it under the field
|
|
109
|
+
you named, exactly as it would a schema failure.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Optimistic updates
|
|
114
|
+
|
|
115
|
+
Optimistic updates go through React's `useOptimistic`. The callback runs inside the transition, so React reverts it automatically on error.
|
|
116
|
+
|
|
117
|
+
```tsx title="TodoList.tsx"
|
|
118
|
+
"use client";
|
|
119
|
+
|
|
120
|
+
import { useOptimistic } from "react";
|
|
121
|
+
import { Form } from "@rsc-kit/core/form";
|
|
122
|
+
import { addTodo } from "./actions";
|
|
123
|
+
|
|
124
|
+
type Todo = { id: number; title: string; done: boolean };
|
|
125
|
+
|
|
126
|
+
export default function TodoList({ todos }: { todos: Todo[] }) {
|
|
127
|
+
const [optimisticTodos, addOptimistic] = useOptimistic(
|
|
128
|
+
todos,
|
|
129
|
+
(state, newTodo: Todo) => [...state, newTodo]
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div>
|
|
134
|
+
<ul>
|
|
135
|
+
{optimisticTodos.map((todo) => (
|
|
136
|
+
<li key={todo.id}>{todo.title}</li>
|
|
137
|
+
))}
|
|
138
|
+
</ul>
|
|
139
|
+
|
|
140
|
+
<Form
|
|
141
|
+
action={addTodo}
|
|
142
|
+
optimistic={(data) =>
|
|
143
|
+
addOptimistic({ id: Date.now(), title: data.title as string, done: false })
|
|
144
|
+
}
|
|
145
|
+
>
|
|
146
|
+
<input name="title" />
|
|
147
|
+
<button>Add</button>
|
|
148
|
+
</Form>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Search and filter forms
|
|
155
|
+
|
|
156
|
+
When `action` is a URL string, the form navigates via RSC instead of doing a full page reload. Form fields are serialized as query parameters. Supports prefetching for instant navigation.
|
|
157
|
+
|
|
158
|
+
```tsx title="SearchForm.tsx"
|
|
159
|
+
"use client";
|
|
160
|
+
|
|
161
|
+
import { Form } from "@rsc-kit/core/form";
|
|
162
|
+
|
|
163
|
+
export default function SearchForm() {
|
|
164
|
+
return (
|
|
165
|
+
<Form action="/search" method="get" prefetch="hover">
|
|
166
|
+
<input name="q" placeholder="Search..." />
|
|
167
|
+
<select name="sort">
|
|
168
|
+
<option value="relevance">Relevance</option>
|
|
169
|
+
<option value="date">Date</option>
|
|
170
|
+
</select>
|
|
171
|
+
<button>Search</button>
|
|
172
|
+
</Form>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
This navigates to `/search?q=hello&sort=date` via SPA navigation. The nearest Suspense boundary streams in the results. With `prefetch="hover"`, hovering the submit button pre-warms the base URL for instant feedback.
|
|
178
|
+
|
|
179
|
+
## Using it with shadcn/ui
|
|
180
|
+
|
|
181
|
+
It works, and mostly by doing nothing. `<Form>` reads a native `FormData`, so
|
|
182
|
+
any component that ends up rendering a real form control is already compatible:
|
|
183
|
+
|
|
184
|
+
```tsx
|
|
185
|
+
<Form action={createPost} schema={schema}>
|
|
186
|
+
{({ pending, errors }) => (
|
|
187
|
+
<>
|
|
188
|
+
<Label htmlFor="title">Title</Label>
|
|
189
|
+
<Input id="title" name="title" />
|
|
190
|
+
{errors.title?.[0] && <p className="text-destructive">{errors.title[0]}</p>}
|
|
191
|
+
|
|
192
|
+
<Select name="kind">
|
|
193
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
194
|
+
<SelectContent>
|
|
195
|
+
<SelectItem value="post">Post</SelectItem>
|
|
196
|
+
</SelectContent>
|
|
197
|
+
</Select>
|
|
198
|
+
|
|
199
|
+
<Checkbox name="draft" />
|
|
200
|
+
|
|
201
|
+
<Button disabled={pending}>{pending ? 'Saving…' : 'Save'}</Button>
|
|
202
|
+
</>
|
|
203
|
+
)}
|
|
204
|
+
</Form>
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`Input`, `Textarea`, `Button` and `Label` are styled native elements, so `name`
|
|
208
|
+
does what it always does.
|
|
209
|
+
|
|
210
|
+
**`Select`, `Checkbox`, `Switch` and `RadioGroup` also work** — they are Radix
|
|
211
|
+
underneath, and Radix renders a hidden native control whenever you give it a
|
|
212
|
+
`name`, for exactly this. Omit the `name` and it is invisible to the form; that
|
|
213
|
+
is the only thing to remember.
|
|
214
|
+
|
|
215
|
+
:::caution[Not shadcn's own `<Form>`]
|
|
216
|
+
shadcn's `<Form>`, `<FormField>` and `<FormControl>` are wrappers around
|
|
217
|
+
[react-hook-form](https://react-hook-form.com), which is a different system for
|
|
218
|
+
the same job — its own state, its own validation, its own submit. Use one or
|
|
219
|
+
the other, not both.
|
|
220
|
+
|
|
221
|
+
Ours gives you the field errors the *server* returned, which is the half a
|
|
222
|
+
client-side library cannot do.
|
|
223
|
+
:::
|
|
224
|
+
|
|
225
|
+
### shadcn's `Field` components
|
|
226
|
+
|
|
227
|
+
The newer `Field`, `FieldLabel`, `FieldError` and `FieldGroup` are plain
|
|
228
|
+
presentational components — they take props rather than reading a form
|
|
229
|
+
library's context, which is what the older `<FormField>` did. So they work here
|
|
230
|
+
directly:
|
|
231
|
+
|
|
232
|
+
```tsx
|
|
233
|
+
<Form action={reportBug} schema={formSchema}>
|
|
234
|
+
{({ pending, errors }) => (
|
|
235
|
+
<FieldGroup>
|
|
236
|
+
<Field data-invalid={!!errors.title}>
|
|
237
|
+
<FieldLabel htmlFor="title">Bug title</FieldLabel>
|
|
238
|
+
<Input id="title" name="title" aria-invalid={!!errors.title} />
|
|
239
|
+
<FieldDescription>Keep it short and specific.</FieldDescription>
|
|
240
|
+
<FieldError errors={errors.title?.map((message) => ({ message }))} />
|
|
241
|
+
</Field>
|
|
242
|
+
|
|
243
|
+
<Button type="submit" disabled={pending}>
|
|
244
|
+
{pending ? 'Sending…' : 'Submit'}
|
|
245
|
+
</Button>
|
|
246
|
+
</FieldGroup>
|
|
247
|
+
)}
|
|
248
|
+
</Form>
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`FieldError` takes `Array<{ message?: string }>`, and our `errors` are
|
|
252
|
+
`string[]` per field — hence the one `map`. Everything else is the same markup
|
|
253
|
+
you would write with any other form library.
|
|
254
|
+
|
|
255
|
+
The difference is where the errors came from. With TanStack Form or
|
|
256
|
+
react-hook-form those are the *client's* validation; here they are the client's
|
|
257
|
+
**and** whatever the server sent back, in the same object, because a refused
|
|
258
|
+
action returns its fields rather than throwing them away.
|
|
259
|
+
|
|
260
|
+
### Setting the values
|
|
261
|
+
|
|
262
|
+
The fields are uncontrolled, so an initial value is `defaultValue` — React's
|
|
263
|
+
own, nothing of ours:
|
|
264
|
+
|
|
265
|
+
```tsx
|
|
266
|
+
<Input id="title" name="title" defaultValue={post.title} />
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
After a refused submit the values are still there, because the DOM kept them:
|
|
270
|
+
nothing re-rendered the inputs, so nobody typed twice. That is the upside of
|
|
271
|
+
not owning the value.
|
|
272
|
+
|
|
273
|
+
The exception is a submit that happened **before hydration**, where the page
|
|
274
|
+
genuinely reloads. Then the server renders the page again, and putting the
|
|
275
|
+
values back is the server's job — return them from the action and render them
|
|
276
|
+
as `defaultValue`.
|
|
277
|
+
|
|
278
|
+
### Lists of values
|
|
279
|
+
|
|
280
|
+
A repeated name is an array:
|
|
281
|
+
|
|
282
|
+
```tsx
|
|
283
|
+
<Checkbox name="tags" value="react" />
|
|
284
|
+
<Checkbox name="tags" value="vite" />
|
|
285
|
+
// → { tags: ['react', 'vite'] }
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
With one ticked that is `'react'`, a string — which no `z.array()` will accept.
|
|
289
|
+
So for anything that is a list by nature, end the name in `[]` and it is always
|
|
290
|
+
an array:
|
|
291
|
+
|
|
292
|
+
```tsx
|
|
293
|
+
<Checkbox name="tags[]" value="react" />
|
|
294
|
+
// → { tags: ['react'] }
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
The brackets are dropped from the key, and it is the same spelling a value is
|
|
298
|
+
serialised back into — so a list survives a round trip.
|
|
299
|
+
|
|
300
|
+
### Nested and repeating groups
|
|
301
|
+
|
|
302
|
+
Names that describe a shape build it:
|
|
303
|
+
|
|
304
|
+
```tsx
|
|
305
|
+
<input name="address.city" /> // → { address: { city } }
|
|
306
|
+
<input name="items[0].name" /> // → { items: [{ name }] }
|
|
307
|
+
<input name="items[0][name]" /> // the same field, other spelling
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Which is the shape your schema was written against — and the shape whose errors
|
|
311
|
+
come back keyed the same way, because Standard Schema issue paths join with
|
|
312
|
+
dots too. A refused `address.city` is `errors['address.city']`.
|
|
313
|
+
|
|
314
|
+
Rows you add and remove are ordinary state; only the *names* have to line up:
|
|
315
|
+
|
|
316
|
+
```tsx
|
|
317
|
+
{rows.map((row, i) => (
|
|
318
|
+
<input key={row.id} name={`items[${i}].name`} defaultValue={row.name} />
|
|
319
|
+
))}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### Controlling one field
|
|
323
|
+
|
|
324
|
+
Most fields need nothing — the DOM holds the value and it is read back on
|
|
325
|
+
submit. Two cases need more: a control with no native element behind it, and a
|
|
326
|
+
value you want to show *as it is typed*.
|
|
327
|
+
|
|
328
|
+
`field(name)` is for both. Spread it, the same way you would spread
|
|
329
|
+
react-hook-form's `<Controller>` render props:
|
|
330
|
+
|
|
331
|
+
```tsx
|
|
332
|
+
<Form action={reportBug} schema={formSchema} defaultValues={{ description: '' }}>
|
|
333
|
+
{({ field, pending, errors }) => (
|
|
334
|
+
<Field data-invalid={!!errors.description}>
|
|
335
|
+
<FieldLabel htmlFor="description">Description</FieldLabel>
|
|
336
|
+
|
|
337
|
+
<InputGroup>
|
|
338
|
+
<InputGroupTextarea id="description" {...field('description')} rows={6} />
|
|
339
|
+
<InputGroupAddon align="block-end">
|
|
340
|
+
<InputGroupText>{field('description').value.length}/100 characters</InputGroupText>
|
|
341
|
+
</InputGroupAddon>
|
|
342
|
+
</InputGroup>
|
|
343
|
+
|
|
344
|
+
<FieldError errors={errors.description?.map((message) => ({ message }))} />
|
|
345
|
+
</Field>
|
|
346
|
+
)}
|
|
347
|
+
</Form>
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
It gives you `{ name, value, onChange, onBlur }` — the same four things
|
|
351
|
+
`<Controller>` does, for the same reason.
|
|
352
|
+
|
|
353
|
+
`onChange` takes either a DOM event or a bare value, so a native input and a
|
|
354
|
+
Radix `Select` both work without a wrapper. A bound field is still an ordinary
|
|
355
|
+
named input, so it arrives in `FormData` with everything else: there is one
|
|
356
|
+
source of truth, and nothing merges.
|
|
357
|
+
|
|
358
|
+
Mix freely. Bind the one field that needs a character count and leave the rest
|
|
359
|
+
alone.
|
|
360
|
+
|
|
361
|
+
### How a field is doing
|
|
362
|
+
|
|
363
|
+
`fieldState(name)` is the other half — what is *known* about a field, as
|
|
364
|
+
opposed to what is spread onto it:
|
|
365
|
+
|
|
366
|
+
```tsx
|
|
367
|
+
{({ field, fieldState }) => {
|
|
368
|
+
const title = fieldState('title')
|
|
369
|
+
|
|
370
|
+
return (
|
|
371
|
+
<Field data-invalid={title.invalid}>
|
|
372
|
+
<FieldLabel htmlFor="title">Bug title</FieldLabel>
|
|
373
|
+
<Input id="title" {...field('title')} aria-invalid={title.invalid} />
|
|
374
|
+
<FieldError errors={title.errors.map((message) => ({ message }))} />
|
|
375
|
+
</Field>
|
|
376
|
+
)
|
|
377
|
+
}}
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Two objects rather than one, which is react-hook-form's split and it is right
|
|
381
|
+
for a mechanical reason: `touched` and `invalid` are not DOM attributes, so a
|
|
382
|
+
single spreadable object would put them on the element and React would warn
|
|
383
|
+
about every one.
|
|
384
|
+
|
|
385
|
+
**A field is checked when it is left, not as it is typed.** An error that
|
|
386
|
+
appears while someone is halfway through an email address is a form arguing
|
|
387
|
+
with them; leaving the field is the moment they have finished saying what they
|
|
388
|
+
meant. `touched` is what separates "not filled in yet" from "filled in
|
|
389
|
+
wrongly".
|
|
390
|
+
|
|
391
|
+
It works on ordinary uncontrolled fields too — the form listens for `focusout`
|
|
392
|
+
rather than each field listening for `blur`, so `<Input name="title" />` is
|
|
393
|
+
covered without being bound to anything.
|
|
394
|
+
|
|
395
|
+
### After a successful submit
|
|
396
|
+
|
|
397
|
+
```tsx
|
|
398
|
+
{({ succeeded, recentlySucceeded }) => (
|
|
399
|
+
<Button type="submit">{recentlySucceeded ? 'Saved ✓' : 'Save'}</Button>
|
|
400
|
+
)}
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
`recentlySucceeded` is the same thing for two seconds — the tick that appears
|
|
404
|
+
and fades. It is state rather than a timer in every form that wants one,
|
|
405
|
+
because the timer has to be cleared when the component goes away and that is
|
|
406
|
+
the part people forget.
|
|
407
|
+
|
|
408
|
+
### Why not a `<Field>` component
|
|
409
|
+
|
|
410
|
+
TanStack Form and react-hook-form both hand you a field through a render prop —
|
|
411
|
+
`<form.Field name="title" children={…}>`, `<Controller render={…}>`. It looks
|
|
412
|
+
like the more capable design, and the reason they need it is worth being precise
|
|
413
|
+
about, because the three of us are not in the same position.
|
|
414
|
+
|
|
415
|
+
**TanStack Form is controlled-first.** Every value lives in form state, so
|
|
416
|
+
without per-field subscriptions one keystroke would re-render every field. The
|
|
417
|
+
render prop is what scopes that, and the verbosity is the price of it.
|
|
418
|
+
|
|
419
|
+
**react-hook-form is uncontrolled-first**, like this. Its `register` is refs, not
|
|
420
|
+
state, so typing re-renders nothing — and `<Controller>` is the opt-in for the
|
|
421
|
+
fields that cannot work that way. The render prop there is doing something
|
|
422
|
+
narrower: it scopes the re-render of a *controlled* field to that field alone.
|
|
423
|
+
|
|
424
|
+
So the architecture here is react-hook-form's. The difference is what the
|
|
425
|
+
controlled opt-in costs: `field()` is a function call rather than a render prop,
|
|
426
|
+
which keeps the markup flat and means a bound field re-renders this component
|
|
427
|
+
rather than only itself.
|
|
428
|
+
|
|
429
|
+
That is the right trade for the number of controlled fields a form usually has —
|
|
430
|
+
one or two, for a character count or a control with no native element.
|
|
431
|
+
|
|
432
|
+
When it is not, put the field in its own component and subscribe there:
|
|
433
|
+
|
|
434
|
+
```tsx
|
|
435
|
+
import { useField } from '@rsc-kit/core/Form'
|
|
436
|
+
|
|
437
|
+
function Title() {
|
|
438
|
+
const { field, invalid, errors, ...bound } = useField('title')
|
|
439
|
+
|
|
440
|
+
return <Input {...bound} aria-invalid={invalid} />
|
|
441
|
+
}
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
`useField` re-renders **that component and nothing else** — not the form, not
|
|
445
|
+
its siblings. Which is what `<Controller>` achieves with a render prop, except
|
|
446
|
+
that the component you were going to write anyway is the boundary.
|
|
447
|
+
|
|
448
|
+
So the scoping is there when a form is large enough to need it, and the flat
|
|
449
|
+
markup is there when it is not. What their design also gives is per-field meta,
|
|
450
|
+
and that needed no render prop either: it is `fieldState()`.
|
|
451
|
+
|
|
452
|
+
### Reading the values from elsewhere
|
|
453
|
+
|
|
454
|
+
`useFormValues()` reads them from anywhere inside the form — a preview, a
|
|
455
|
+
summary, a count of what has changed:
|
|
456
|
+
|
|
457
|
+
```tsx
|
|
458
|
+
function Preview() {
|
|
459
|
+
const { title } = useFormValues<{ title: string }>()
|
|
460
|
+
|
|
461
|
+
return <h2>{title || 'Untitled'}</h2>
|
|
462
|
+
}
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
Only **bound** values are here. An uncontrolled input's value belongs to the
|
|
466
|
+
DOM, and nothing can know it changed without listening to it — bind a field with
|
|
467
|
+
`field()` or `useField` and it appears.
|
|
468
|
+
|
|
469
|
+
:::note[Inside the form, not outside it]
|
|
470
|
+
Both hooks read a context, so they work anywhere below `<Form>`. That is usually
|
|
471
|
+
enough, because the `<form>` element can wrap as much of the page as you like,
|
|
472
|
+
and a submit button outside it is `form="the-id"`.
|
|
473
|
+
|
|
474
|
+
Worth knowing what the alternatives do here, because it is not as different as
|
|
475
|
+
it looks. Reaching a form from another component is a context in all three:
|
|
476
|
+
react-hook-form has `<FormProvider>` and `useFormContext()`, and TanStack Form
|
|
477
|
+
has `createFormHookContexts()` with `useFormContext()` — which its own
|
|
478
|
+
documentation calls a bridge for integration constraints, to be avoided when
|
|
479
|
+
passing the form as a prop is possible. Ours needs no extra provider only
|
|
480
|
+
because `<Form>` already is one.
|
|
481
|
+
|
|
482
|
+
Where they are genuinely more flexible is *where the state is created*: their
|
|
483
|
+
`useForm()` is called by you, so it can be hoisted as far up as you like. For
|
|
484
|
+
the case where that matters — something that is not a descendant — create the
|
|
485
|
+
store yourself and hand it to the form:
|
|
486
|
+
|
|
487
|
+
```tsx
|
|
488
|
+
function Page() {
|
|
489
|
+
const store = useFormStore<{ title: string }>({ title: '' })
|
|
490
|
+
|
|
491
|
+
return (
|
|
492
|
+
<>
|
|
493
|
+
<TopBar store={store} /> {/* not inside the form */}
|
|
494
|
+
<Form action={save} store={store}>…</Form>
|
|
495
|
+
</>
|
|
496
|
+
)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function TopBar({ store }) {
|
|
500
|
+
const { title } = useFormValues<{ title: string }>(store)
|
|
501
|
+
|
|
502
|
+
return <h1>{title || 'Untitled'}</h1>
|
|
503
|
+
}
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
`useFormStore` is the values and nothing else — no submit, no errors, no
|
|
507
|
+
optimistic updates. Creating it does not subscribe to it, so the component
|
|
508
|
+
holding it does not re-render on every keystroke and take the whole subtree
|
|
509
|
+
with it.
|
|
510
|
+
|
|
511
|
+
`useField(name, store)` and `useFormValues(store)` take one explicitly;
|
|
512
|
+
without one they read the context, which is what almost every form wants.
|
|
513
|
+
:::
|
|
514
|
+
|
|
515
|
+
## It works before hydration
|
|
516
|
+
|
|
517
|
+
The action goes on the `<form>` element as well as into the submit handler, so
|
|
518
|
+
the markup is submittable on its own. Someone who hits enter before the
|
|
519
|
+
javascript arrives still reaches the server; the page reloads with the result
|
|
520
|
+
instead of updating in place.
|
|
521
|
+
|
|
522
|
+
The two do not fight. The handler calls `preventDefault()` first, and React does
|
|
523
|
+
not run a form action for a submit that was cancelled — so the enhanced path
|
|
524
|
+
wins whenever there is one, and the native path is what is left when there is
|
|
525
|
+
not.
|
|
526
|
+
|
|
527
|
+
Nothing to turn on. It is why the fields are real `name` attributes rather than
|
|
528
|
+
controlled state: a browser can read them without help.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Getting started
|
|
2
|
+
|
|
3
|
+
> Serve React Server Components from any JavaScript backend.
|
|
4
|
+
|
|
5
|
+
A host is a `Request` in and a `Response` out. You do not normally write the
|
|
6
|
+
server that calls it: [Nitro](https://nitro.build) builds one around your route
|
|
7
|
+
tree, and where it runs is a preset in `vite.config.ts`.
|
|
8
|
+
|
|
9
|
+
## Building
|
|
10
|
+
|
|
11
|
+
Three steps, and only the first is required:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm run dev # vite — serves from source, no build step
|
|
15
|
+
npm run build # bundles, renders every route once, then Nitro assembles .output/
|
|
16
|
+
npm run start # runs .output/server/index.mjs
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`dev` is Vite's own dev server, and edits reach the browser without a reload:
|
|
20
|
+
|
|
21
|
+
| you edit | what happens |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
| a client component | Fast Refresh — the code updates and its state survives |
|
|
24
|
+
| a page or layout | the payload is re-fetched and the tree re-rendered |
|
|
25
|
+
| adding or deleting a page | the server restarts to pick up the new route table |
|
|
26
|
+
|
|
27
|
+
The browser never holds a server component, so Vite cannot hot-swap it. Instead
|
|
28
|
+
the client re-fetches the page — which remounts any client component below, so
|
|
29
|
+
their state resets.
|
|
30
|
+
|
|
31
|
+
Edit a client component directly and its state does survive; that is Fast
|
|
32
|
+
Refresh.
|
|
33
|
+
|
|
34
|
+
The restart on a new page is because the route tree is read when the server
|
|
35
|
+
starts: a page that appears later would otherwise 404 while sitting right there
|
|
36
|
+
on disk.
|
|
37
|
+
|
|
38
|
+
`build` ends by rendering every route once and storing what it can, which is
|
|
39
|
+
what turns a route into a file on disk instead of a render per request.
|
|
40
|
+
|
|
41
|
+
It is part of `build` rather than a separate command because forgetting it costs
|
|
42
|
+
you everything and looks like nothing — every page still works, each one just
|
|
43
|
+
renders again for every visitor.
|
|
44
|
+
|
|
45
|
+
Turn it off with `rscKit({ prerender: false })` when the build machine
|
|
46
|
+
cannot do what the pages need. See [Static
|
|
47
|
+
generation](/guides/static-generation).
|
|
48
|
+
|
|
49
|
+
<Aside type="note" title="There is no NODE_ENV to set">
|
|
50
|
+
React picks its build from it, and getting it wrong gives you a page that
|
|
51
|
+
renders perfectly and never hydrates. The build stamps the mode it ran in
|
|
52
|
+
into the server bundles, so a server is production because it was built that
|
|
53
|
+
way — `npm run start` needs no environment at all.
|
|
54
|
+
</Aside>
|
|
55
|
+
|
|
56
|
+
## Compiling to a single binary
|
|
57
|
+
|
|
58
|
+
With the Bun preset the whole application ends up in one file:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npm run compile # builds, then bun build --compile
|
|
62
|
+
./dist/app
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
That works because the generated config sets `serveStatic: 'inline'`. Without
|
|
66
|
+
it the binary compiles, starts, serves pages, and 404s every asset — the static
|
|
67
|
+
path resolves into Bun's virtual filesystem, where the files on disk are not.
|
|
68
|
+
|
|
69
|
+
### What it costs
|
|
70
|
+
|
|
71
|
+
Measured on the example — eleven routes, 240 KB of assets, 164 KB of stored
|
|
72
|
+
pages:
|
|
73
|
+
|
|
74
|
+
| | compiled | run from `.output/` |
|
|
75
|
+
| --- | --- | --- |
|
|
76
|
+
| Cold start | 45 ms | 56–65 ms |
|
|
77
|
+
| Resident, idle | 18 MB | 19 MB |
|
|
78
|
+
| Resident, after 300 requests | 27 MB | 32 MB |
|
|
79
|
+
| On disk | 62 MB | 240 KB + a runtime |
|
|
80
|
+
|
|
81
|
+
Memory is flat between the two: the binary holds its assets as `Response`
|
|
82
|
+
objects built at boot, which for 240 KB is nothing, and both settle in the high
|
|
83
|
+
twenties once React has warmed up. A live render is what moves it — 40 MB after
|
|
84
|
+
200 of them — and that is the renderer, not the packaging.
|
|
85
|
+
|
|
86
|
+
<Aside type="note" title="What compiling is actually for">
|
|
87
|
+
Deployment shape, not speed. One file, no `node_modules`, no runtime to
|
|
88
|
+
install. Throughput between the two is inside the noise of any benchmark I
|
|
89
|
+
could run on one machine — the only differences I can defend are the ~15 ms
|
|
90
|
+
of cold start and the shape of what you ship.
|
|
91
|
+
</Aside>
|
|
92
|
+
|
|
93
|
+
<Aside type="caution" title="What embedding costs">
|
|
94
|
+
Every asset goes in whole. Three files is nothing; a media-heavy app is
|
|
95
|
+
hundreds of megabytes of executable, with no CDN in front and no streaming
|
|
96
|
+
from disk. Keep large or rarely-read files outside the binary and serve them
|
|
97
|
+
from wherever they already live.
|
|
98
|
+
|
|
99
|
+
Frozen pages are not embedded either. The build writes them to
|
|
100
|
+
`.output/server/rsc-static` and the server reads them from beside itself; a
|
|
101
|
+
binary has no filesystem to read, so it renders those pages live. Everything
|
|
102
|
+
still answers — what you lose is the stored render, not the page.
|
|
103
|
+
</Aside>
|
|
104
|
+
|
|
105
|
+
## If you work with an AI agent
|
|
106
|
+
|
|
107
|
+
The scaffold writes an `AGENTS.md` beside your `README.md`. Claude Code, Cursor
|
|
108
|
+
and the rest read it, and it covers the things an agent otherwise gets wrong
|
|
109
|
+
from React or Next habits — `"use client"` versus `"use server"`, where an
|
|
110
|
+
authorisation check belongs, which props are async, and that the build output is
|
|
111
|
+
worth reading rather than ignoring.
|
|
112
|
+
|
|
113
|
+
Beside it is `.mcp.json`, which connects the [MCP server](/guides/mcp): Claude
|
|
114
|
+
Code asks you to approve it on first use, and from then on an agent can read
|
|
115
|
+
what your last build actually did instead of guessing.
|
|
116
|
+
|
|
117
|
+
And there is a test to extend, `tests/app.test.ts`, which goes through the
|
|
118
|
+
real build: `bun run check` runs typecheck, lint and tests together, and is
|
|
119
|
+
the one command an agent — or you — runs before calling something done.
|
|
120
|
+
|
|
121
|
+
Edit it as your project grows. It is yours; nothing regenerates it.
|
|
122
|
+
|
|
123
|
+
There is also an MCP server, which answers from your actual build rather than
|
|
124
|
+
from memory:
|
|
125
|
+
|
|
126
|
+
```sh
|
|
127
|
+
claude mcp add rsc-kit -- npx -y @rsc-kit/mcp
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
It can say why a particular page is not static, what each route ships to the
|
|
131
|
+
browser, and how to build a form or an api route the way this framework
|
|
132
|
+
expects. See [Working with an AI agent](/guides/mcp/).
|