Skip to content

Commit 0d2ec58

Browse files
cursoragent0froq
andcommitted
fix(react): let useForm reset to mount-time defaults after submit
Fixes #523 ## Summary After a successful submit, `useForm` still promotes submitted values to defaults (edit forms / `hasChanges` unchanged). You can now get back to mount-time values: - `reset({ initial: true }, ...fields)` — reset to the values passed to `useForm` on mount - `resetOnSuccess: true` on `useForm` options — clear create forms after success - `reset()` without options still targets current defaults (last submitted values after success) Shared via `parseFormResetArgs` in `void/pages-client`; React / Vue / Svelte / Solid + docs / codegen. ## Tests - `packages/void/test/unit/form-reset.test.ts` - `packages/vue/test/unit/use-form.test.ts` (reset / `reset({ initial: true })` / `resetOnSuccess`) Typecheck: `void`, `@void/react`, `@void/vue`, `@void/svelte`, `@void/solid`. Co-authored-by: froQ <sayhola@froq.me>
1 parent 6d0cbf4 commit 0d2ec58

12 files changed

Lines changed: 215 additions & 33 deletions

File tree

‎docs/guide/pages-routing/actions-and-forms.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,11 @@ The types are inferred from your action's `withValidator()` schema in the compan
154154
| `error` | Action error, or `null`. |
155155
| `post`, `put`, `patch`, `delete` | Submit the form with that method. In React these are native form action callbacks. |
156156
| `pending` | `true` while the submission is in flight. |
157-
| `hasChanges` | `true` if form data differs from initial values. |
157+
| `hasChanges` | `true` if form data differs from the current defaults. After a successful submit, defaults update to the submitted values so `hasChanges` becomes `false`. |
158158
| `wasSuccessful` | `true` after a successful submission. Stays `true` until the next submission. |
159159
| `recentlySuccessful` | `true` for 2 seconds after a successful submission. Useful for flash messages. |
160-
| `reset(...fields)` | Reset form data to initial values. Field names autocomplete. |
160+
| `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`. |
161+
| `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. |
161162
| `clearErrors(...fields)` | Clear validation errors. Field names autocomplete. |
162163
| `clearError()` | Clear `error`. |
163164

@@ -171,6 +172,8 @@ const form = useForm('/users/:id', { name: '' }, { params: { id: '42' } });
171172
<Form form={form} method="put">{/* submits to /users/42 */}</Form>
172173
```
173174

175+
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`.
176+
174177
## Named Actions
175178

176179
When a page needs multiple mutations, such as updating and deleting a user on the same page, export `actions` (plural) instead of `action`:

‎docs/guide/type-safety.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ The `action()` helper gets the same type checking. See [Actions & Forms](./pages
109109
| Database | Table columns, value types, insert/update shapes from Drizzle schema |
110110
| Handlers | `c.env` bindings, validator input, return type |
111111
| Fetch client | Route paths, HTTP methods, `body`/`query`/`params`, response type |
112-
| `useForm` | Action URLs, `data` fields, `errors` keys, `reset()` args, `params` |
112+
| `useForm` | Action URLs, `data` fields, `errors` keys, `reset()` args (including `{ initial: true }`), `params`, `resetOnSuccess` |
113113
| `action()` | Action URLs, `data` payload, `params` |
114114

115115
## Serialization

‎packages/react/src/runtime/use-form.ts‎

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import {
1313
interpolateRouteParams,
1414
isAbortError,
1515
isEqualFormValue,
16+
parseFormResetArgs,
1617
submitAction,
18+
type FormResetOptions,
1719
} from 'void/pages-client';
1820
import type { ActionUrl, ResolveActionBody, ActionFormOptions } from 'void/routes';
1921
import { RouterContext } from './context.ts';
@@ -32,6 +34,7 @@ type UntypedForm = {
3234
put(formData: FormData): void;
3335
patch(formData: FormData): void;
3436
delete(formData: FormData): void;
37+
reset(options: FormResetOptions, ...fields: ReadonlyArray<string>): void;
3538
reset(...fields: ReadonlyArray<string>): void;
3639
clearErrors(...fields: ReadonlyArray<string>): void;
3740
clearError(): void;
@@ -58,6 +61,10 @@ export function useForm<U extends ActionUrl & string>(
5861
put(formData: FormData): void;
5962
patch(formData: FormData): void;
6063
delete(formData: FormData): void;
64+
reset(
65+
options: FormResetOptions,
66+
...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>
67+
): void;
6168
reset(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
6269
clearErrors(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
6370
clearError(): void;
@@ -66,7 +73,7 @@ export function useForm<U extends ActionUrl & string>(
6673
export function useForm(
6774
url: string,
6875
defaults: Record<string, unknown>,
69-
options?: { params?: Record<string, string> },
76+
options?: { params?: Record<string, string>; resetOnSuccess?: boolean },
7077
): UntypedForm {
7178
const router = useContext(RouterContext);
7279
const [data, setDataState] = useState<Record<string, unknown>>(() => ({ ...defaults }));
@@ -79,6 +86,7 @@ export function useForm(
7986
const [recentlySuccessful, setRecentlySuccessful] = useState(false);
8087

8188
const defaultsRef = useRef(defaultsState);
89+
const initialRef = useRef<Record<string, unknown>>({ ...defaults });
8290
const dataRef = useRef(data);
8391
const successTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
8492

@@ -93,13 +101,15 @@ export function useForm(
93101
setDataState(dataRef.current);
94102
}, []);
95103

96-
const reset = useCallback((...fields: ReadonlyArray<string>) => {
104+
const reset = useCallback((...args: ReadonlyArray<string | FormResetOptions>) => {
105+
const { initial, fields } = parseFormResetArgs(args);
106+
const source = initial ? initialRef.current : defaultsRef.current;
97107
if (fields.length === 0) {
98-
dataRef.current = { ...defaultsRef.current };
108+
dataRef.current = { ...source };
99109
} else {
100110
const next = { ...dataRef.current };
101111
for (const field of fields) {
102-
next[field] = defaultsRef.current[field];
112+
next[field] = source[field];
103113
}
104114
dataRef.current = next;
105115
}
@@ -167,9 +177,17 @@ export function useForm(
167177
return previousSuccessVersion;
168178
}
169179

170-
const nextDefaults = { ...dataRef.current };
171-
defaultsRef.current = nextDefaults;
172-
setDefaultsState(nextDefaults);
180+
if (options?.resetOnSuccess) {
181+
const initial = { ...initialRef.current };
182+
dataRef.current = initial;
183+
setDataState(initial);
184+
defaultsRef.current = initial;
185+
setDefaultsState(initial);
186+
} else {
187+
const nextDefaults = { ...dataRef.current };
188+
defaultsRef.current = nextDefaults;
189+
setDefaultsState(nextDefaults);
190+
}
173191
setWasSuccessful(true);
174192
setRecentlySuccessful(true);
175193
successTimeoutRef.current = setTimeout(() => {

‎packages/solid/src/runtime/use-form.ts‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import {
66
interpolateRouteParams,
77
isAbortError,
88
isEqualFormValue,
9+
parseFormResetArgs,
910
submitAction,
11+
type FormResetOptions,
1012
type VisitOptions,
1113
} from 'void/pages-client';
1214
import type { ActionUrl, ResolveActionBody, ActionFormOptions } from 'void/routes';
@@ -28,6 +30,7 @@ type UntypedForm = {
2830
put(opts?: FormSubmitOptions): Promise<void>;
2931
patch(opts?: FormSubmitOptions): Promise<void>;
3032
delete(opts?: FormSubmitOptions): Promise<void>;
33+
reset(options: FormResetOptions, ...fields: ReadonlyArray<string>): void;
3134
reset(...fields: ReadonlyArray<string>): void;
3235
clearErrors(...fields: ReadonlyArray<string>): void;
3336
clearError(): void;
@@ -54,6 +57,10 @@ export function useForm<U extends ActionUrl & string>(
5457
put(opts?: FormSubmitOptions): Promise<void>;
5558
patch(opts?: FormSubmitOptions): Promise<void>;
5659
delete(opts?: FormSubmitOptions): Promise<void>;
60+
reset(
61+
options: FormResetOptions,
62+
...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>
63+
): void;
5764
reset(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
5865
clearErrors(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
5966
clearError(): void;
@@ -62,7 +69,7 @@ export function useForm<U extends ActionUrl & string>(
6269
export function useForm(
6370
url: string,
6471
defaults: Record<string, unknown>,
65-
options?: { params?: Record<string, string> },
72+
options?: { params?: Record<string, string>; resetOnSuccess?: boolean },
6673
): UntypedForm {
6774
const router = useContext(RouterContext);
6875
// ErrorsContext provides the errors signal accessor — a reactive function
@@ -93,6 +100,7 @@ export function useForm(
93100
const [recentlySuccessful, setRecentlySuccessful] = createSignal(false);
94101

95102
const [defaults_, setDefaults] = createSignal<Record<string, unknown>>({ ...defaults });
103+
const initialDefaults = { ...defaults };
96104
let successTimeout: ReturnType<typeof setTimeout> | null = null;
97105

98106
const hasChanges = createMemo(() => {
@@ -104,12 +112,14 @@ export function useForm(
104112
setDataStore(field, value);
105113
}
106114

107-
function reset(...fields: ReadonlyArray<string>) {
115+
function reset(...args: ReadonlyArray<string | FormResetOptions>) {
116+
const { initial, fields } = parseFormResetArgs(args);
117+
const source = initial ? initialDefaults : defaults_();
108118
if (fields.length === 0) {
109-
setDataStore(reconcile({ ...defaults_() }));
119+
setDataStore(reconcile({ ...source }));
110120
} else {
111121
for (const field of fields) {
112-
setDataStore(field, defaults_()[field]);
122+
setDataStore(field, source[field]);
113123
}
114124
}
115125
}
@@ -167,7 +177,12 @@ export function useForm(
167177

168178
setWasSuccessful(true);
169179
setRecentlySuccessful(true);
170-
setDefaults({ ...data });
180+
if (options?.resetOnSuccess) {
181+
setDataStore(reconcile({ ...initialDefaults }));
182+
setDefaults({ ...initialDefaults });
183+
} else {
184+
setDefaults({ ...data });
185+
}
171186
successTimeout = setTimeout(() => {
172187
setRecentlySuccessful(false);
173188
}, 2000);

‎packages/svelte/src/runtime/use-form.svelte.ts‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import {
55
interpolateRouteParams,
66
isAbortError,
77
isEqualFormValue,
8+
parseFormResetArgs,
89
submitAction,
10+
type FormResetOptions,
911
type VisitOptions,
1012
type VoidRouter,
1113
} from 'void/pages-client';
@@ -26,6 +28,7 @@ type UntypedForm = {
2628
put(opts?: FormSubmitOptions): Promise<void>;
2729
patch(opts?: FormSubmitOptions): Promise<void>;
2830
delete(opts?: FormSubmitOptions): Promise<void>;
31+
reset(options: FormResetOptions, ...fields: ReadonlyArray<string>): void;
2932
reset(...fields: ReadonlyArray<string>): void;
3033
clearErrors(...fields: ReadonlyArray<string>): void;
3134
clearError(): void;
@@ -48,6 +51,10 @@ export function useForm<U extends ActionUrl & string>(
4851
put(opts?: FormSubmitOptions): Promise<void>;
4952
patch(opts?: FormSubmitOptions): Promise<void>;
5053
delete(opts?: FormSubmitOptions): Promise<void>;
54+
reset(
55+
options: FormResetOptions,
56+
...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>
57+
): void;
5158
reset(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
5259
clearErrors(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
5360
clearError(): void;
@@ -56,7 +63,7 @@ export function useForm<U extends ActionUrl & string>(
5663
export function useForm(
5764
url: string,
5865
defaults: Record<string, unknown>,
59-
options?: { params?: Record<string, string> },
66+
options?: { params?: Record<string, string>; resetOnSuccess?: boolean },
6067
): UntypedForm {
6168
const router = getContext<VoidRouter>('__void_router');
6269

@@ -68,18 +75,21 @@ export function useForm(
6875
let recentlySuccessful = $state(false);
6976

7077
let defaults_ = { ...defaults };
78+
const initialDefaults = { ...defaults };
7179
let successTimeout: ReturnType<typeof setTimeout> | null = null;
7280

7381
const hasChanges = $derived(
7482
Object.keys(defaults_).some((key) => !isEqualFormValue(data[key], defaults_[key])),
7583
);
7684

77-
function reset(...fields: ReadonlyArray<string>) {
85+
function reset(...args: ReadonlyArray<string | FormResetOptions>) {
86+
const { initial, fields } = parseFormResetArgs(args);
87+
const source = initial ? initialDefaults : defaults_;
7888
if (fields.length === 0) {
79-
data = { ...defaults_ };
89+
data = { ...source };
8090
} else {
8191
for (const field of fields) {
82-
data[field] = defaults_[field];
92+
data[field] = source[field];
8393
}
8494
}
8595
}
@@ -129,7 +139,12 @@ export function useForm(
129139

130140
wasSuccessful = true;
131141
recentlySuccessful = true;
132-
defaults_ = { ...data };
142+
if (options?.resetOnSuccess) {
143+
data = { ...initialDefaults };
144+
defaults_ = { ...initialDefaults };
145+
} else {
146+
defaults_ = { ...data };
147+
}
133148
successTimeout = setTimeout(() => {
134149
recentlySuccessful = false;
135150
}, 2000);

‎packages/void/src/codegen/route-types.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -127,10 +127,12 @@ export function generateRouteTypes(
127127
lines.push(' export type HasActionParams<U extends string> =');
128128
lines.push(' ResolveActionParams<U> extends Record<string, string> ? true : false;');
129129
lines.push('');
130-
lines.push(' export type ActionFormOptions<U extends string> =');
131-
lines.push(
132-
' HasActionParams<U> extends true ? { params: ResolveActionParams<U> } : { params?: never };',
133-
);
130+
lines.push(' type ActionFormBaseOptions = { resetOnSuccess?: boolean };');
131+
lines.push('');
132+
lines.push(' export type ActionFormOptions<U extends string> = ActionFormBaseOptions &');
133+
lines.push(' (HasActionParams<U> extends true');
134+
lines.push(' ? { params: ResolveActionParams<U> }');
135+
lines.push(' : { params?: never });');
134136
lines.push('');
135137
lines.push(' export type ActionUrl =');
136138
lines.push(' | {');

‎packages/void/src/pages/client.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ export {
2828
} from './action-result.ts';
2929
export type { ActionErrorHandlingScope, ActionResult } from './action-result.ts';
3030
export { isEqualFormValue } from './form-value-equality.ts';
31+
export {
32+
isFormResetOptions,
33+
parseFormResetArgs,
34+
type FormResetOptions,
35+
} from './form-reset.ts';
3136
export { interpolateRouteParams };
3237
export {
3338
createPageNavigationEngine,
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export type FormResetOptions = { initial?: boolean };
2+
3+
export function isFormResetOptions(value: unknown): value is FormResetOptions {
4+
return typeof value === 'object' && value !== null && 'initial' in value;
5+
}
6+
7+
export function parseFormResetArgs(
8+
args: ReadonlyArray<string | FormResetOptions>,
9+
): { initial: boolean; fields: ReadonlyArray<string> } {
10+
if (args.length > 0 && isFormResetOptions(args[0])) {
11+
return {
12+
initial: args[0].initial === true,
13+
fields: args.slice(1) as ReadonlyArray<string>,
14+
};
15+
}
16+
return { initial: false, fields: args as ReadonlyArray<string> };
17+
}

‎packages/void/src/routes-stub.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,13 @@ export type ResolveActionParams<U extends string> =
5454
export type HasActionParams<U extends string> =
5555
ResolveActionParams<U> extends Record<string, string> ? true : false;
5656

57+
type ActionFormBaseOptions = { resetOnSuccess?: boolean };
58+
5759
/** Form options type (requires params for dynamic routes) */
58-
export type ActionFormOptions<U extends string> =
59-
HasActionParams<U> extends true ? { params: ResolveActionParams<U> } : { params?: never };
60+
export type ActionFormOptions<U extends string> = ActionFormBaseOptions &
61+
(HasActionParams<U> extends true
62+
? { params: ResolveActionParams<U> }
63+
: { params?: never });
6064

6165
/** Valid useForm URLs — single-action pages + "page?name" combos + default actions */
6266
export type ActionUrl =
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, expect, it } from 'vite-plus/test';
2+
import { isFormResetOptions, parseFormResetArgs } from '../../src/pages/form-reset.ts';
3+
4+
describe('parseFormResetArgs', () => {
5+
it('treats string arguments as field names', () => {
6+
expect(parseFormResetArgs(['title', 'body'])).toEqual({
7+
initial: false,
8+
fields: ['title', 'body'],
9+
});
10+
});
11+
12+
it('reads initial from the first options object', () => {
13+
expect(parseFormResetArgs([{ initial: true }])).toEqual({ initial: true, fields: [] });
14+
expect(parseFormResetArgs([{ initial: true }, 'title'])).toEqual({
15+
initial: true,
16+
fields: ['title'],
17+
});
18+
expect(parseFormResetArgs([{ initial: false }, 'title'])).toEqual({
19+
initial: false,
20+
fields: ['title'],
21+
});
22+
});
23+
});
24+
25+
describe('isFormResetOptions', () => {
26+
it('detects reset option objects', () => {
27+
expect(isFormResetOptions({ initial: true })).toBe(true);
28+
expect(isFormResetOptions('title')).toBe(false);
29+
expect(isFormResetOptions(null)).toBe(false);
30+
});
31+
});

0 commit comments

Comments
 (0)