diff --git a/docs/guide/pages-routing/actions-and-forms.md b/docs/guide/pages-routing/actions-and-forms.md
index 620701ce4..4d33df87c 100644
--- a/docs/guide/pages-routing/actions-and-forms.md
+++ b/docs/guide/pages-routing/actions-and-forms.md
@@ -154,10 +154,11 @@ The types are inferred from your action's `withValidator()` schema in the compan
| `error` | Action error, or `null`. |
| `post`, `put`, `patch`, `delete` | Submit the form with that method. In React these are native form action callbacks. |
| `pending` | `true` while the submission is in flight. |
-| `hasChanges` | `true` if form data differs from initial values. |
+| `hasChanges` | `true` if form data differs from the current defaults. After a successful submit, defaults update to the submitted values so `hasChanges` becomes `false`. |
| `wasSuccessful` | `true` after a successful submission. Stays `true` until the next submission. |
| `recentlySuccessful` | `true` for 2 seconds after a successful submission. Useful for flash messages. |
-| `reset(...fields)` | Reset form data to initial values. Field names autocomplete. |
+| `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. |
| `clearErrors(...fields)` | Clear validation errors. Field names autocomplete. |
| `clearError()` | Clear `error`. |
@@ -171,6 +172,8 @@ 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`.
+
## 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`:
diff --git a/docs/guide/type-safety.md b/docs/guide/type-safety.md
index c7621cc44..a72e94650 100644
--- a/docs/guide/type-safety.md
+++ b/docs/guide/type-safety.md
@@ -109,7 +109,7 @@ The `action()` helper gets the same type checking. See [Actions & Forms](./pages
| Database | Table columns, value types, insert/update shapes from Drizzle schema |
| Handlers | `c.env` bindings, validator input, return type |
| Fetch client | Route paths, HTTP methods, `body`/`query`/`params`, response type |
-| `useForm` | Action URLs, `data` fields, `errors` keys, `reset()` args, `params` |
+| `useForm` | Action URLs, `data` fields, `errors` keys, `reset()` args (including `{ initial: true }`), `params`, `resetOnSuccess` |
| `action()` | Action URLs, `data` payload, `params` |
## Serialization
diff --git a/packages/react/src/runtime/use-form.ts b/packages/react/src/runtime/use-form.ts
index 1b11862ae..420649a23 100644
--- a/packages/react/src/runtime/use-form.ts
+++ b/packages/react/src/runtime/use-form.ts
@@ -13,7 +13,9 @@ import {
interpolateRouteParams,
isAbortError,
isEqualFormValue,
+ parseFormResetArgs,
submitAction,
+ type FormResetOptions,
} from 'void/pages-client';
import type { ActionUrl, ResolveActionBody, ActionFormOptions } from 'void/routes';
import { RouterContext } from './context.ts';
@@ -32,6 +34,7 @@ type UntypedForm = {
put(formData: FormData): void;
patch(formData: FormData): void;
delete(formData: FormData): void;
+ reset(options: FormResetOptions, ...fields: ReadonlyArray): void;
reset(...fields: ReadonlyArray): void;
clearErrors(...fields: ReadonlyArray): void;
clearError(): void;
@@ -58,6 +61,10 @@ export function useForm(
put(formData: FormData): void;
patch(formData: FormData): void;
delete(formData: FormData): void;
+ reset(
+ options: FormResetOptions,
+ ...fields: ReadonlyArray & string>
+ ): void;
reset(...fields: ReadonlyArray & string>): void;
clearErrors(...fields: ReadonlyArray & string>): void;
clearError(): void;
@@ -66,7 +73,7 @@ export function useForm(
export function useForm(
url: string,
defaults: Record,
- options?: { params?: Record },
+ options?: { params?: Record; resetOnSuccess?: boolean },
): UntypedForm {
const router = useContext(RouterContext);
const [data, setDataState] = useState>(() => ({ ...defaults }));
@@ -79,6 +86,7 @@ export function useForm(
const [recentlySuccessful, setRecentlySuccessful] = useState(false);
const defaultsRef = useRef(defaultsState);
+ const initialRef = useRef>({ ...defaults });
const dataRef = useRef(data);
const successTimeoutRef = useRef | null>(null);
@@ -93,13 +101,15 @@ export function useForm(
setDataState(dataRef.current);
}, []);
- const reset = useCallback((...fields: ReadonlyArray) => {
+ const reset = useCallback((...args: ReadonlyArray) => {
+ const { initial, fields } = parseFormResetArgs(args);
+ const source = initial ? initialRef.current : defaultsRef.current;
if (fields.length === 0) {
- dataRef.current = { ...defaultsRef.current };
+ dataRef.current = { ...source };
} else {
const next = { ...dataRef.current };
for (const field of fields) {
- next[field] = defaultsRef.current[field];
+ next[field] = source[field];
}
dataRef.current = next;
}
@@ -167,9 +177,17 @@ export function useForm(
return previousSuccessVersion;
}
- const nextDefaults = { ...dataRef.current };
- defaultsRef.current = nextDefaults;
- setDefaultsState(nextDefaults);
+ if (options?.resetOnSuccess) {
+ const initial = { ...initialRef.current };
+ dataRef.current = initial;
+ setDataState(initial);
+ defaultsRef.current = initial;
+ setDefaultsState(initial);
+ } else {
+ const nextDefaults = { ...dataRef.current };
+ defaultsRef.current = nextDefaults;
+ setDefaultsState(nextDefaults);
+ }
setWasSuccessful(true);
setRecentlySuccessful(true);
successTimeoutRef.current = setTimeout(() => {
diff --git a/packages/solid/src/runtime/use-form.ts b/packages/solid/src/runtime/use-form.ts
index 39a049431..60b462144 100644
--- a/packages/solid/src/runtime/use-form.ts
+++ b/packages/solid/src/runtime/use-form.ts
@@ -6,7 +6,9 @@ import {
interpolateRouteParams,
isAbortError,
isEqualFormValue,
+ parseFormResetArgs,
submitAction,
+ type FormResetOptions,
type VisitOptions,
} from 'void/pages-client';
import type { ActionUrl, ResolveActionBody, ActionFormOptions } from 'void/routes';
@@ -28,6 +30,7 @@ type UntypedForm = {
put(opts?: FormSubmitOptions): Promise;
patch(opts?: FormSubmitOptions): Promise;
delete(opts?: FormSubmitOptions): Promise;
+ reset(options: FormResetOptions, ...fields: ReadonlyArray): void;
reset(...fields: ReadonlyArray): void;
clearErrors(...fields: ReadonlyArray): void;
clearError(): void;
@@ -54,6 +57,10 @@ export function useForm(
put(opts?: FormSubmitOptions): Promise;
patch(opts?: FormSubmitOptions): Promise;
delete(opts?: FormSubmitOptions): Promise;
+ reset(
+ options: FormResetOptions,
+ ...fields: ReadonlyArray & string>
+ ): void;
reset(...fields: ReadonlyArray & string>): void;
clearErrors(...fields: ReadonlyArray & string>): void;
clearError(): void;
@@ -62,7 +69,7 @@ export function useForm(
export function useForm(
url: string,
defaults: Record,
- options?: { params?: Record },
+ options?: { params?: Record; resetOnSuccess?: boolean },
): UntypedForm {
const router = useContext(RouterContext);
// ErrorsContext provides the errors signal accessor — a reactive function
@@ -93,6 +100,7 @@ export function useForm(
const [recentlySuccessful, setRecentlySuccessful] = createSignal(false);
const [defaults_, setDefaults] = createSignal>({ ...defaults });
+ const initialDefaults = { ...defaults };
let successTimeout: ReturnType | null = null;
const hasChanges = createMemo(() => {
@@ -104,12 +112,14 @@ export function useForm(
setDataStore(field, value);
}
- function reset(...fields: ReadonlyArray) {
+ function reset(...args: ReadonlyArray) {
+ const { initial, fields } = parseFormResetArgs(args);
+ const source = initial ? initialDefaults : defaults_();
if (fields.length === 0) {
- setDataStore(reconcile({ ...defaults_() }));
+ setDataStore(reconcile({ ...source }));
} else {
for (const field of fields) {
- setDataStore(field, defaults_()[field]);
+ setDataStore(field, source[field]);
}
}
}
@@ -167,7 +177,12 @@ export function useForm(
setWasSuccessful(true);
setRecentlySuccessful(true);
- setDefaults({ ...data });
+ if (options?.resetOnSuccess) {
+ setDataStore(reconcile({ ...initialDefaults }));
+ setDefaults({ ...initialDefaults });
+ } else {
+ setDefaults({ ...data });
+ }
successTimeout = setTimeout(() => {
setRecentlySuccessful(false);
}, 2000);
diff --git a/packages/svelte/src/runtime/use-form.svelte.ts b/packages/svelte/src/runtime/use-form.svelte.ts
index b63ba4157..d6dc663dd 100644
--- a/packages/svelte/src/runtime/use-form.svelte.ts
+++ b/packages/svelte/src/runtime/use-form.svelte.ts
@@ -5,7 +5,9 @@ import {
interpolateRouteParams,
isAbortError,
isEqualFormValue,
+ parseFormResetArgs,
submitAction,
+ type FormResetOptions,
type VisitOptions,
type VoidRouter,
} from 'void/pages-client';
@@ -26,6 +28,7 @@ type UntypedForm = {
put(opts?: FormSubmitOptions): Promise;
patch(opts?: FormSubmitOptions): Promise;
delete(opts?: FormSubmitOptions): Promise;
+ reset(options: FormResetOptions, ...fields: ReadonlyArray): void;
reset(...fields: ReadonlyArray): void;
clearErrors(...fields: ReadonlyArray): void;
clearError(): void;
@@ -48,6 +51,10 @@ export function useForm(
put(opts?: FormSubmitOptions): Promise;
patch(opts?: FormSubmitOptions): Promise;
delete(opts?: FormSubmitOptions): Promise;
+ reset(
+ options: FormResetOptions,
+ ...fields: ReadonlyArray & string>
+ ): void;
reset(...fields: ReadonlyArray & string>): void;
clearErrors(...fields: ReadonlyArray & string>): void;
clearError(): void;
@@ -56,7 +63,7 @@ export function useForm(
export function useForm(
url: string,
defaults: Record,
- options?: { params?: Record },
+ options?: { params?: Record; resetOnSuccess?: boolean },
): UntypedForm {
const router = getContext('__void_router');
@@ -68,18 +75,21 @@ export function useForm(
let recentlySuccessful = $state(false);
let defaults_ = { ...defaults };
+ const initialDefaults = { ...defaults };
let successTimeout: ReturnType | null = null;
const hasChanges = $derived(
Object.keys(defaults_).some((key) => !isEqualFormValue(data[key], defaults_[key])),
);
- function reset(...fields: ReadonlyArray) {
+ function reset(...args: ReadonlyArray) {
+ const { initial, fields } = parseFormResetArgs(args);
+ const source = initial ? initialDefaults : defaults_;
if (fields.length === 0) {
- data = { ...defaults_ };
+ data = { ...source };
} else {
for (const field of fields) {
- data[field] = defaults_[field];
+ data[field] = source[field];
}
}
}
@@ -129,7 +139,12 @@ export function useForm(
wasSuccessful = true;
recentlySuccessful = true;
- defaults_ = { ...data };
+ if (options?.resetOnSuccess) {
+ data = { ...initialDefaults };
+ defaults_ = { ...initialDefaults };
+ } else {
+ defaults_ = { ...data };
+ }
successTimeout = setTimeout(() => {
recentlySuccessful = false;
}, 2000);
diff --git a/packages/void/src/codegen/route-types.ts b/packages/void/src/codegen/route-types.ts
index e6a909510..5bc9eb386 100644
--- a/packages/void/src/codegen/route-types.ts
+++ b/packages/void/src/codegen/route-types.ts
@@ -127,10 +127,12 @@ export function generateRouteTypes(
lines.push(' export type HasActionParams =');
lines.push(' ResolveActionParams extends Record ? true : false;');
lines.push('');
- lines.push(' export type ActionFormOptions =');
- lines.push(
- ' HasActionParams extends true ? { params: ResolveActionParams } : { params?: never };',
- );
+ lines.push(' type ActionFormBaseOptions = { resetOnSuccess?: boolean };');
+ lines.push('');
+ lines.push(' export type ActionFormOptions = ActionFormBaseOptions &');
+ lines.push(' (HasActionParams extends true');
+ lines.push(' ? { params: ResolveActionParams }');
+ lines.push(' : { params?: never });');
lines.push('');
lines.push(' export type ActionUrl =');
lines.push(' | {');
diff --git a/packages/void/src/pages/client.ts b/packages/void/src/pages/client.ts
index c2f27094e..9609187a4 100644
--- a/packages/void/src/pages/client.ts
+++ b/packages/void/src/pages/client.ts
@@ -28,6 +28,11 @@ export {
} from './action-result.ts';
export type { ActionErrorHandlingScope, ActionResult } from './action-result.ts';
export { isEqualFormValue } from './form-value-equality.ts';
+export {
+ isFormResetOptions,
+ parseFormResetArgs,
+ type FormResetOptions,
+} from './form-reset.ts';
export { interpolateRouteParams };
export {
createPageNavigationEngine,
diff --git a/packages/void/src/pages/form-reset.ts b/packages/void/src/pages/form-reset.ts
new file mode 100644
index 000000000..88cd4a4cc
--- /dev/null
+++ b/packages/void/src/pages/form-reset.ts
@@ -0,0 +1,17 @@
+export type FormResetOptions = { initial?: boolean };
+
+export function isFormResetOptions(value: unknown): value is FormResetOptions {
+ return typeof value === 'object' && value !== null && 'initial' in value;
+}
+
+export function parseFormResetArgs(
+ args: ReadonlyArray,
+): { initial: boolean; fields: ReadonlyArray } {
+ if (args.length > 0 && isFormResetOptions(args[0])) {
+ return {
+ initial: args[0].initial === true,
+ fields: args.slice(1) as ReadonlyArray,
+ };
+ }
+ return { initial: false, fields: args as ReadonlyArray };
+}
diff --git a/packages/void/src/routes-stub.ts b/packages/void/src/routes-stub.ts
index 01bceae10..4fb4e5de2 100644
--- a/packages/void/src/routes-stub.ts
+++ b/packages/void/src/routes-stub.ts
@@ -54,9 +54,13 @@ export type ResolveActionParams =
export type HasActionParams =
ResolveActionParams extends Record ? true : false;
+type ActionFormBaseOptions = { resetOnSuccess?: boolean };
+
/** Form options type (requires params for dynamic routes) */
-export type ActionFormOptions =
- HasActionParams extends true ? { params: ResolveActionParams } : { params?: never };
+export type ActionFormOptions = ActionFormBaseOptions &
+ (HasActionParams extends true
+ ? { params: ResolveActionParams }
+ : { params?: never });
/** Valid useForm URLs — single-action pages + "page?name" combos + default actions */
export type ActionUrl =
diff --git a/packages/void/test/unit/form-reset.test.ts b/packages/void/test/unit/form-reset.test.ts
new file mode 100644
index 000000000..b17a52ccd
--- /dev/null
+++ b/packages/void/test/unit/form-reset.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vite-plus/test';
+import { isFormResetOptions, parseFormResetArgs } from '../../src/pages/form-reset.ts';
+
+describe('parseFormResetArgs', () => {
+ it('treats string arguments as field names', () => {
+ expect(parseFormResetArgs(['title', 'body'])).toEqual({
+ initial: false,
+ fields: ['title', 'body'],
+ });
+ });
+
+ it('reads initial from the first options object', () => {
+ expect(parseFormResetArgs([{ initial: true }])).toEqual({ initial: true, fields: [] });
+ expect(parseFormResetArgs([{ initial: true }, 'title'])).toEqual({
+ initial: true,
+ fields: ['title'],
+ });
+ expect(parseFormResetArgs([{ initial: false }, 'title'])).toEqual({
+ initial: false,
+ fields: ['title'],
+ });
+ });
+});
+
+describe('isFormResetOptions', () => {
+ it('detects reset option objects', () => {
+ expect(isFormResetOptions({ initial: true })).toBe(true);
+ expect(isFormResetOptions('title')).toBe(false);
+ expect(isFormResetOptions(null)).toBe(false);
+ });
+});
diff --git a/packages/vue/src/runtime/use-form.ts b/packages/vue/src/runtime/use-form.ts
index 9fff54bb0..3403faabe 100644
--- a/packages/vue/src/runtime/use-form.ts
+++ b/packages/vue/src/runtime/use-form.ts
@@ -4,7 +4,9 @@ import {
interpolateRouteParams,
isAbortError,
isEqualFormValue,
+ parseFormResetArgs,
submitAction,
+ type FormResetOptions,
type VisitOptions,
type VoidRouter,
} from 'void/pages-client';
@@ -26,6 +28,7 @@ type UntypedForm = {
put(opts?: FormSubmitOptions): Promise;
patch(opts?: FormSubmitOptions): Promise;
delete(opts?: FormSubmitOptions): Promise;
+ reset(options: FormResetOptions, ...fields: ReadonlyArray): void;
reset(...fields: ReadonlyArray): void;
clearErrors(...fields: ReadonlyArray): void;
clearError(): void;
@@ -47,6 +50,10 @@ export function useForm(
put(opts?: FormSubmitOptions): Promise;
patch(opts?: FormSubmitOptions): Promise;
delete(opts?: FormSubmitOptions): Promise;
+ reset(
+ options: FormResetOptions,
+ ...fields: ReadonlyArray & string>
+ ): void;
reset(...fields: ReadonlyArray & string>): void;
clearErrors(...fields: ReadonlyArray & string>): void;
clearError(): void;
@@ -55,7 +62,7 @@ export function useForm(
export function useForm(
url: string,
defaults: Record,
- options?: { params?: Record },
+ options?: { params?: Record; resetOnSuccess?: boolean },
): UntypedForm {
const formDefaults = defaults;
const router = inject('__void_router', null);
@@ -67,6 +74,7 @@ export function useForm(
const recentlySuccessful = ref(false);
let defaults_ = { ...formDefaults };
+ const initialDefaults = { ...formDefaults };
let successTimeout: ReturnType | null = null;
const hasChanges = ref(false);
@@ -80,12 +88,14 @@ export function useForm(
{ deep: true },
);
- function reset(...fields: ReadonlyArray) {
+ function reset(...args: ReadonlyArray) {
+ const { initial, fields } = parseFormResetArgs(args);
+ const source = initial ? initialDefaults : defaults_;
if (fields.length === 0) {
- Object.assign(data, { ...defaults_ });
+ Object.assign(data, { ...source });
} else {
for (const field of fields) {
- data[field] = defaults_[field];
+ data[field] = source[field];
}
}
}
@@ -141,7 +151,12 @@ export function useForm(
wasSuccessful.value = true;
recentlySuccessful.value = true;
- defaults_ = { ...data };
+ if (options?.resetOnSuccess) {
+ Object.assign(data, { ...initialDefaults });
+ defaults_ = { ...initialDefaults };
+ } else {
+ defaults_ = { ...data };
+ }
successTimeout = setTimeout(() => {
recentlySuccessful.value = false;
}, 2000);
diff --git a/packages/vue/test/unit/use-form.test.ts b/packages/vue/test/unit/use-form.test.ts
index cd6e2577b..c1c6a7a49 100644
--- a/packages/vue/test/unit/use-form.test.ts
+++ b/packages/vue/test/unit/use-form.test.ts
@@ -4,12 +4,20 @@ import { createSSRApp, defineComponent, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
import { useForm as typedUseForm } from '../../src/runtime/use-form.ts';
+type TestForm = {
+ data: Record;
+ hasChanges: boolean;
+ pending: boolean;
+ post(): Promise;
+ reset: (...args: Array) => void;
+};
+
// The public overload only accepts generated action URLs; these tests use plain paths.
const useForm = typedUseForm as unknown as (
url: string,
defaults: Record,
- options: { params: Record },
-) => { post(): Promise; readonly pending: boolean };
+ options?: { params?: Record; resetOnSuccess?: boolean },
+) => TestForm;
async function renderForm(url: string, params: Record) {
const visits: Array = [];
@@ -34,6 +42,55 @@ async function renderForm(url: string, params: Record) {
return { form, visits };
}
+async function mountForm(
+ defaults: Record,
+ options?: { resetOnSuccess?: boolean },
+): Promise {
+ const router = {
+ visit: async () => ({}),
+ } as unknown as VoidRouter;
+ let form!: TestForm;
+ const app = createSSRApp(
+ defineComponent({
+ setup() {
+ form = useForm('/notes?create', defaults, options);
+ return () => h('form');
+ },
+ }),
+ );
+ app.provide('__void_router', router);
+ await renderToString(app);
+ return form;
+}
+
+describe('useForm reset', () => {
+ it('reset() restores submitted values after success', async () => {
+ const form = await mountForm({ title: '', body: '' });
+ form.data.title = 'Hello';
+ await form.post();
+ form.data.title = 'Changed';
+ form.reset();
+ expect(form.data.title).toBe('Hello');
+ expect(form.hasChanges).toBe(false);
+ });
+
+ it('reset({ initial: true }) restores mount-time defaults after success', async () => {
+ const form = await mountForm({ title: '', body: '' });
+ form.data.title = 'Hello';
+ await form.post();
+ form.reset({ initial: true });
+ expect(form.data.title).toBe('');
+ });
+
+ it('resetOnSuccess clears create forms after a successful submit', async () => {
+ const form = await mountForm({ title: '', body: '' }, { resetOnSuccess: true });
+ form.data.title = 'Hello';
+ await form.post();
+ expect(form.data.title).toBe('');
+ expect(form.hasChanges).toBe(false);
+ });
+});
+
describe('useForm params', () => {
it('fills in params and keeps the action query', async () => {
const { form, visits } = await renderForm('/users/:name/follow?action=follow', {