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
24 changes: 24 additions & 0 deletions docs/guide/pages-routing/actions-and-forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,30 @@ const form = useForm('/users/:id', { name: '' }, { params: { id: '42' } });

Create forms often need empty fields again after a successful save. Either pass `resetOnSuccess: true` in the third argument, or call `form.reset({ initial: true })` when `form.wasSuccessful` becomes `true`.

### React submission callbacks

In React, pass `onSuccess` and `onError` to `useForm()` to respond to a submission:

```tsx
const form = useForm(
'/users',
{ name: '', email: '' },
{
onSuccess: () => setDialogOpen(false),
onError: (error) => {
// A field-error map or VoidActionError for a handled action failure.
console.error(error);
},
},
);
```

Each submission uses the callbacks from when it was dispatched. Callbacks run inside the React Action, before the updated UI commits. Use an effect or ref callback to focus an element that appears after submission.

Callbacks can return `Promise<void>`. The Action awaits them, and `form.pending` stays true until they complete. Errors thrown or rejected by callbacks reach the nearest React error boundary. Aborted submissions invoke neither callback.

Vue, Svelte, and Solid submissions return a promise instead; use `await form.post()` to run work after a submission.

## Named Actions

When a page needs multiple mutations, such as updating and deleting a user on the same page, export `actions` (plural) instead of `action`:
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/api/pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ Imported from `"@void/react"`.

React `Link` GET `data` is merged into the rendered `href` query string. Primitive values are serialized with `String(value)`, arrays become repeated keys, `null` and `undefined` are omitted, and nested objects throw. `prefetch` and `reloadDocument` are GET-only and throw for mutation links.

React `useForm()` options accept `onSuccess?: () => void | Promise<void>` and `onError?: (error: Record<string, string> | VoidActionError) => void | Promise<void>`. Each submission captures its callbacks, runs them before the updated UI commits, and awaits any returned promise. See [submission callbacks](../../guide/pages-routing/actions-and-forms.md#react-submission-callbacks).

React deferred props returned from `defer()` are Suspense resources. Read them
with React's `use()` inside a `<Suspense>` boundary. React Pages requires React
19 and uses streaming SSR for the initial deferred shell. Rejections throw from
Expand Down
1 change: 1 addition & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@types/node": "catalog:",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"happy-dom": "catalog:",
"pathslash": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
Expand Down
14 changes: 12 additions & 2 deletions packages/react/src/runtime/use-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,18 @@ type UntypedForm = {
clearError(): void;
};

type FormOptions = {
params?: Record<string, string>;
resetOnSuccess?: boolean;
onSuccess?: () => void | Promise<void>;
onError?: (error: Record<string, string> | VoidActionError) => void | Promise<void>;
};

// Typed overload — full inference from PageActionMap (requires codegen)
export function useForm<U extends ActionUrl & string>(
url: U,
defaults: ResolveActionBody<U>,
options?: ActionFormOptions<U>,
options?: ActionFormOptions<U> & Pick<FormOptions, 'onSuccess' | 'onError'>,
): {
data: ResolveActionBody<U>;
setData: <K extends keyof ResolveActionBody<U> & string>(
Expand Down Expand Up @@ -76,7 +83,7 @@ export function useForm<U extends ActionUrl & string>(
export function useForm(
url: string,
defaults: Record<string, unknown>,
options?: { params?: Record<string, string>; resetOnSuccess?: boolean },
options?: FormOptions,
): UntypedForm {
const router = useContext(RouterContext);
const [data, setDataState] = useState<Record<string, unknown>>(() => cloneFormValue(defaults));
Expand Down Expand Up @@ -186,8 +193,10 @@ export function useForm(
const validationErrors = getValidationErrors(result.error.body);
if (validationErrors) {
setErrors(validationErrors);
await options?.onError?.(validationErrors);
} else {
setActionError(result.error);
await options?.onError?.(result.error);
}
return previousSuccessVersion;
}
Expand All @@ -205,6 +214,7 @@ export function useForm(
setRecentlySuccessful(false);
successTimeoutRef.current = null;
}, 2000);
await options?.onSuccess?.();
return previousSuccessVersion + 1;
},
0,
Expand Down
Loading
Loading