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
3 changes: 3 additions & 0 deletions docs/guide/pages-routing/actions-and-forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,12 @@ The types are inferred from your action's `withValidator()` schema in the compan
| `recentlySuccessful` | `true` for 2 seconds after a successful submission. Useful for flash messages. |
| `reset(...fields)` | Reset form data to the current defaults (last submitted values after success). Field names autocomplete. Pass `{ initial: true }` first to reset to the values passed to `useForm`. |
| `resetOnSuccess` (option) | When `true`, a successful submit resets both form data and defaults to the mount-time values. Useful for create forms that should clear after save. |
| `setError(field, message)` | Set a client-side validation error on a field. |
| `clearErrors(...fields)` | Clear validation errors. Field names autocomplete. |
| `clearError()` | Clear `error`. |

Use `setError()` to display errors from your own client-side validation. Setting an error preserves errors on other fields. Changing a field clears that field's validation error automatically (`setData` in React and Solid, or assigning to `data` in Vue and Svelte).

For a native React form, use `form.post`, `form.put`, `form.patch`, or `form.delete` as a native form action. For an awaitable mutation, use `action()`. In Vue, Svelte, and Solid, form submissions return `Promise<void>`; catch thrown errors or use your framework's error handling.

For dynamic routes, pass `params` in the options:
Expand Down
8 changes: 4 additions & 4 deletions docs/reference/api/pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Imported from `"@void/vue"`.
| `useRouter()` | Returns the Void Router with current route state (`url`, `path`, `query`, `params`) and navigation methods: `visit`, `refresh`, awaitable `prefetch`, `flush`, `flushAll`. |
| `useParams()` | Returns dynamic route params for the page currently being rendered. |
| `useNavigation()` | Returns pending navigation state: `{ state, location, method }`, where `state` is `"idle"`, `"loading"`, or `"submitting"` and `location` is the pending destination. |
| `useForm(url, defaults, options?)` | Typed reactive form helper bound to a page action URL. Returns `{ data, post, put, patch, delete, pending, errors, error, hasChanges, wasSuccessful, recentlySuccessful, reset, clearErrors, clearError }`. |
| `useForm(url, defaults, options?)` | Typed reactive form helper bound to a page action URL. Returns `{ data, post, put, patch, delete, pending, errors, error, hasChanges, wasSuccessful, recentlySuccessful, reset, setError, clearErrors, clearError }`. |
| `useIslandForm(defaults)` | Form helper for island components. Same form state as `useForm()`, plus `submit(url, method)`. `post(url)`, `put(url)`, `patch(url)`, and `delete(url)` take the action URL and submit with `fetch()`. |
| `action(url, options?)` | Awaitable one-shot page action helper. Uses `POST` by default and accepts `{ data, method, params }`, where `method` can be `"PUT"`, `"PATCH"`, or `"DELETE"`. Returns an `ActionResult`. |
| `useShared()` | Returns shared data injected by middleware via `c.set("shared", {...})`. |
Expand Down Expand Up @@ -101,7 +101,7 @@ Imported from `"@void/react"`.
| `useRouter()` | Returns the Void Router with current route state (`url`, `path`, `query`, `params`) and navigation methods: `visit`, `refresh`, awaitable `prefetch`, `flush`, `flushAll`. React navigations are scheduled as transitions. |
| `useParams()` | Returns dynamic route params for the page currently being rendered. |
| `useNavigation()` | Returns pending navigation state: `{ state, location, method }`, where `state` is `"idle"`, `"loading"`, or `"submitting"` and `location` is the pending destination. |
| `useForm(url, defaults, options?)` | Form helper hook. Returns `{ data, setData, post, put, patch, delete, pending, errors, error, hasChanges, wasSuccessful, recentlySuccessful, reset, clearErrors, clearError }`. |
| `useForm(url, defaults, options?)` | Form helper hook. Returns `{ data, setData, post, put, patch, delete, pending, errors, error, hasChanges, wasSuccessful, recentlySuccessful, reset, setError, clearErrors, clearError }`. |
| `useIslandForm(defaults)` | Form helper hook for island components. Same form state as `useForm()`. `post(url)`, `put(url)`, `patch(url)`, and `delete(url)` take the action URL and submit with `fetch()`. |
| `action(url, options?)` | Awaitable one-shot page action helper. Uses `POST` by default and accepts `{ data, method, params }`, where `method` can be `"PUT"`, `"PATCH"`, or `"DELETE"`. Returns an `ActionResult`. |
| `useShared()` | Returns shared data injected by middleware via `c.set("shared", {...})`. |
Expand Down Expand Up @@ -158,7 +158,7 @@ Imported from `"@void/svelte"`.
| Export | Description |
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Link` | Svelte component for SPA navigation. Renders `<a>` for GET, `<button>` for non-GET methods. Props: `href`, `method`, `data`, `prefetch`, `cacheFor`, `preserveScroll`, `preserveState`, `replace`, `reloadDocument`, `viewTransition`, `onNavigate`. |
| `useForm(url, defaults, options?)` | Form helper using Svelte 5 runes. Returns `{ data, post, put, patch, delete, pending, errors, error, hasChanges, wasSuccessful, recentlySuccessful, reset, clearErrors, clearError }`. |
| `useForm(url, defaults, options?)` | Form helper using Svelte 5 runes. Returns `{ data, post, put, patch, delete, pending, errors, error, hasChanges, wasSuccessful, recentlySuccessful, reset, setError, clearErrors, clearError }`. |
| `useIslandForm(defaults)` | Form helper for island components. Same form state as `useForm()`, plus `submit(url, method)`. `post(url)`, `put(url)`, `patch(url)`, and `delete(url)` take the action URL and submit with `fetch()`. |
| `action(url, options?)` | Awaitable one-shot page action helper. Uses `POST` by default and accepts `{ data, method, params }`, where `method` can be `"PUT"`, `"PATCH"`, or `"DELETE"`. Returns an `ActionResult`. |
| `useShared()` | Returns shared data injected by middleware via `c.set("shared", {...})`. |
Expand Down Expand Up @@ -208,7 +208,7 @@ Imported from `"@void/solid"`.
| Export | Description |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Link` | Solid component for SPA navigation. Renders `<a>` for GET, `<button>` for non-GET methods. Props: `href`, `method`, `data`, `prefetch`, `cacheFor`, `preserveScroll`, `preserveState`, `replace`, `reloadDocument`, `viewTransition`, `onNavigate`. |
| `useForm(url, defaults, options?)` | Form helper using Solid stores. Returns `{ data, setData, post, put, patch, delete, pending, errors, error, hasChanges, wasSuccessful, recentlySuccessful, reset, clearErrors, clearError }`. |
| `useForm(url, defaults, options?)` | Form helper using Solid stores. Returns `{ data, setData, post, put, patch, delete, pending, errors, error, hasChanges, wasSuccessful, recentlySuccessful, reset, setError, clearErrors, clearError }`. |
| `useIslandForm(defaults)` | Form helper for island components. Same form state as `useForm()`. `post(url)`, `put(url)`, `patch(url)`, and `delete(url)` take the action URL and submit with `fetch()`. |
| `action(url, options?)` | Awaitable one-shot page action helper. Uses `POST` by default and accepts `{ data, method, params }`, where `method` can be `"PUT"`, `"PATCH"`, or `"DELETE"`. Returns an `ActionResult`. |
| `useShared()` | Returns shared data injected by middleware via `c.set("shared", {...})`. |
Expand Down
23 changes: 19 additions & 4 deletions packages/react/src/runtime/use-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type UntypedForm = {
delete(formData: FormData): void;
reset(options: FormResetOptions, ...fields: ReadonlyArray<string>): void;
reset(...fields: ReadonlyArray<string>): void;
setError(field: string, message: string): void;
clearErrors(...fields: ReadonlyArray<string>): void;
clearError(): void;
};
Expand Down Expand Up @@ -67,6 +68,7 @@ export function useForm<U extends ActionUrl & string>(
...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>
): void;
reset(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
setError(field: keyof ResolveActionBody<U> & string, message: string): void;
clearErrors(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
clearError(): void;
};
Expand All @@ -82,7 +84,7 @@ export function useForm(
cloneFormValue(defaults),
);
const [errors, setErrors] = useState<Record<string, string>>({});
const [error, setError] = useState<VoidActionError | null>(null);
const [error, setActionError] = useState<VoidActionError | null>(null);
const [wasSuccessful, setWasSuccessful] = useState(false);
const [recentlySuccessful, setRecentlySuccessful] = useState(false);

Expand All @@ -100,6 +102,14 @@ export function useForm(
const setData = useCallback((field: string, value: unknown) => {
dataRef.current = { ...dataRef.current, [field]: value };
setDataState(dataRef.current);
setErrors((prev) => {
if (!(field in prev)) {
return prev;
}
const next = { ...prev };
delete next[field];
return next;
});
}, []);

const reset = useCallback((...args: ReadonlyArray<string | FormResetOptions>) => {
Expand Down Expand Up @@ -131,8 +141,12 @@ export function useForm(
}
}, []);

const setError = useCallback((field: string, message: string) => {
setErrors((prev) => ({ ...prev, [field]: message }));
}, []);

const clearError = useCallback(() => {
setError(null);
setActionError(null);
}, []);

const [, dispatchAction, pending] = useActionState(
Expand All @@ -150,7 +164,7 @@ export function useForm(
successTimeoutRef.current = null;
}
setErrors({});
setError(null);
setActionError(null);

let result;
try {
Expand All @@ -173,7 +187,7 @@ export function useForm(
if (validationErrors) {
setErrors(validationErrors);
} else {
setError(result.error);
setActionError(result.error);
}
return previousSuccessVersion;
}
Expand Down Expand Up @@ -230,6 +244,7 @@ export function useForm(
patch,
delete: deleteAction,
reset,
setError,
clearErrors,
clearError,
};
Expand Down
16 changes: 16 additions & 0 deletions packages/react/test/unit/form-errors.typecheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useForm } from '../../src/runtime/use-form.ts';

declare module 'void/routes' {
interface PageActionMap {
'/__form-errors/react': { body: { email: string } };
}
}

export function checkFieldErrors(): void {
const form = useForm('/__form-errors/react', { email: '' });
form.setError('email', 'Invalid email');
// @ts-expect-error Error fields must exist in the action body.
form.setError('missing', 'Invalid field');
// @ts-expect-error Error messages must be strings.
form.setError('email', 123);
}
53 changes: 34 additions & 19 deletions packages/solid/src/runtime/use-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type UntypedForm = {
delete(opts?: FormSubmitOptions): Promise<void>;
reset(options: FormResetOptions, ...fields: ReadonlyArray<string>): void;
reset(...fields: ReadonlyArray<string>): void;
setError(field: string, message: string): void;
clearErrors(...fields: ReadonlyArray<string>): void;
clearError(): void;
};
Expand Down Expand Up @@ -63,6 +64,7 @@ export function useForm<U extends ActionUrl & string>(
...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>
): void;
reset(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
setError(field: keyof ResolveActionBody<U> & string, message: string): void;
clearErrors(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
clearError(): void;
};
Expand All @@ -82,12 +84,7 @@ export function useForm(
const [data, setDataStore] = createStore<Record<string, unknown>>(
cloneFormValue(initialDefaults),
);
const [localErrors, setLocalErrors] = createSignal<Record<string, string>>({});
const errors = createMemo(() => {
const local = localErrors();
if (Object.keys(local).length > 0) {
return local;
}
const serverErrors = createMemo(() => {
// globalErrors is a signal accessor (function) on the client, or a plain
// object on the server. Handle both cases.
if (!globalErrors) {
Expand All @@ -98,8 +95,22 @@ export function useForm(
}
return globalErrors;
});
const [localErrors, setLocalErrors] = createSignal<{
source: Record<string, string>;
values: Record<string, string>;
} | null>(null);
const errors = createMemo(() => {
const server = serverErrors();
const local = localErrors();
// A new router result replaces earlier edits, including explicit clears.
return local?.source === server ? local.values : server;
});

function replaceErrors(values: Record<string, string>) {
setLocalErrors({ source: serverErrors(), values });
}
const [pending, setPending] = createSignal(false);
const [error, setError] = createSignal<VoidActionError | null>(null);
const [error, setActionError] = createSignal<VoidActionError | null>(null);
const [wasSuccessful, setWasSuccessful] = createSignal(false);
const [recentlySuccessful, setRecentlySuccessful] = createSignal(false);

Expand All @@ -113,6 +124,7 @@ export function useForm(

function setData(field: string, value: unknown) {
setDataStore(field, value);
clearErrors(field);
}

function reset(...args: ReadonlyArray<string | FormResetOptions>) {
Expand All @@ -129,20 +141,22 @@ export function useForm(

function clearErrors(...fields: ReadonlyArray<string>) {
if (fields.length === 0) {
setLocalErrors({});
replaceErrors({});
} else {
setLocalErrors((prev) => {
const next = { ...prev };
for (const field of fields) {
delete next[field];
}
return next;
});
const next = { ...errors() };
for (const field of fields) {
delete next[field];
}
replaceErrors(next);
}
}

function setFieldError(field: string, message: string) {
replaceErrors({ ...errors(), [field]: message });
}

function clearError() {
setError(null);
setActionError(null);
}

async function submit(method: string, opts: FormSubmitOptions = {}) {
Expand All @@ -152,7 +166,7 @@ export function useForm(
if (successTimeout) {
clearTimeout(successTimeout);
}
setLocalErrors({});
clearErrors();
clearError();

try {
Expand All @@ -171,9 +185,9 @@ export function useForm(
if (!result.ok) {
const validationErrors = getValidationErrors(result.error.body);
if (validationErrors) {
setLocalErrors(validationErrors);
replaceErrors(validationErrors);
} else {
setError(result.error);
setActionError(result.error);
}
return;
}
Expand Down Expand Up @@ -223,6 +237,7 @@ export function useForm(
return recentlySuccessful();
},
reset,
setError: setFieldError,
clearErrors,
clearError,
post: (opts?: FormSubmitOptions) => submit('POST', opts),
Expand Down
Loading