Skip to content
Open
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
7 changes: 5 additions & 2 deletions docs/guide/pages-routing/actions-and-forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |

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

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`:
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/type-safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 25 additions & 7 deletions packages/react/src/runtime/use-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -32,6 +34,7 @@ type UntypedForm = {
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;
Expand All @@ -58,6 +61,10 @@ export function useForm<U extends ActionUrl & string>(
put(formData: FormData): void;
patch(formData: FormData): void;
delete(formData: FormData): void;
reset(
options: FormResetOptions,
...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>
): void;
reset(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
clearErrors(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
clearError(): void;
Expand All @@ -66,7 +73,7 @@ export function useForm<U extends ActionUrl & string>(
export function useForm(
url: string,
defaults: Record<string, unknown>,
options?: { params?: Record<string, string> },
options?: { params?: Record<string, string>; resetOnSuccess?: boolean },
): UntypedForm {
const router = useContext(RouterContext);
const [data, setDataState] = useState<Record<string, unknown>>(() => ({ ...defaults }));
Expand All @@ -79,6 +86,7 @@ export function useForm(
const [recentlySuccessful, setRecentlySuccessful] = useState(false);

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

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

const reset = useCallback((...fields: ReadonlyArray<string>) => {
const reset = useCallback((...args: ReadonlyArray<string | FormResetOptions>) => {
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;
}
Expand Down Expand Up @@ -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(() => {
Expand Down
25 changes: 20 additions & 5 deletions packages/solid/src/runtime/use-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -28,6 +30,7 @@ type UntypedForm = {
put(opts?: FormSubmitOptions): Promise<void>;
patch(opts?: FormSubmitOptions): Promise<void>;
delete(opts?: FormSubmitOptions): Promise<void>;
reset(options: FormResetOptions, ...fields: ReadonlyArray<string>): void;
reset(...fields: ReadonlyArray<string>): void;
clearErrors(...fields: ReadonlyArray<string>): void;
clearError(): void;
Expand All @@ -54,6 +57,10 @@ export function useForm<U extends ActionUrl & string>(
put(opts?: FormSubmitOptions): Promise<void>;
patch(opts?: FormSubmitOptions): Promise<void>;
delete(opts?: FormSubmitOptions): Promise<void>;
reset(
options: FormResetOptions,
...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>
): void;
reset(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
clearErrors(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
clearError(): void;
Expand All @@ -62,7 +69,7 @@ export function useForm<U extends ActionUrl & string>(
export function useForm(
url: string,
defaults: Record<string, unknown>,
options?: { params?: Record<string, string> },
options?: { params?: Record<string, string>; resetOnSuccess?: boolean },
): UntypedForm {
const router = useContext(RouterContext);
// ErrorsContext provides the errors signal accessor — a reactive function
Expand Down Expand Up @@ -93,6 +100,7 @@ export function useForm(
const [recentlySuccessful, setRecentlySuccessful] = createSignal(false);

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

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

function reset(...fields: ReadonlyArray<string>) {
function reset(...args: ReadonlyArray<string | FormResetOptions>) {
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]);
}
}
}
Expand Down Expand Up @@ -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);
Expand Down
25 changes: 20 additions & 5 deletions packages/svelte/src/runtime/use-form.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {
interpolateRouteParams,
isAbortError,
isEqualFormValue,
parseFormResetArgs,
submitAction,
type FormResetOptions,
type VisitOptions,
type VoidRouter,
} from 'void/pages-client';
Expand All @@ -26,6 +28,7 @@ type UntypedForm = {
put(opts?: FormSubmitOptions): Promise<void>;
patch(opts?: FormSubmitOptions): Promise<void>;
delete(opts?: FormSubmitOptions): Promise<void>;
reset(options: FormResetOptions, ...fields: ReadonlyArray<string>): void;
reset(...fields: ReadonlyArray<string>): void;
clearErrors(...fields: ReadonlyArray<string>): void;
clearError(): void;
Expand All @@ -48,6 +51,10 @@ export function useForm<U extends ActionUrl & string>(
put(opts?: FormSubmitOptions): Promise<void>;
patch(opts?: FormSubmitOptions): Promise<void>;
delete(opts?: FormSubmitOptions): Promise<void>;
reset(
options: FormResetOptions,
...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>
): void;
reset(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
clearErrors(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
clearError(): void;
Expand All @@ -56,7 +63,7 @@ export function useForm<U extends ActionUrl & string>(
export function useForm(
url: string,
defaults: Record<string, unknown>,
options?: { params?: Record<string, string> },
options?: { params?: Record<string, string>; resetOnSuccess?: boolean },
): UntypedForm {
const router = getContext<VoidRouter>('__void_router');

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

let defaults_ = { ...defaults };
const initialDefaults = { ...defaults };
let successTimeout: ReturnType<typeof setTimeout> | null = null;

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

function reset(...fields: ReadonlyArray<string>) {
function reset(...args: ReadonlyArray<string | FormResetOptions>) {
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];
}
}
}
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 6 additions & 4 deletions packages/void/src/codegen/route-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,12 @@ export function generateRouteTypes(
lines.push(' export type HasActionParams<U extends string> =');
lines.push(' ResolveActionParams<U> extends Record<string, string> ? true : false;');
lines.push('');
lines.push(' export type ActionFormOptions<U extends string> =');
lines.push(
' HasActionParams<U> extends true ? { params: ResolveActionParams<U> } : { params?: never };',
);
lines.push(' type ActionFormBaseOptions = { resetOnSuccess?: boolean };');
lines.push('');
lines.push(' export type ActionFormOptions<U extends string> = ActionFormBaseOptions &');
lines.push(' (HasActionParams<U> extends true');
lines.push(' ? { params: ResolveActionParams<U> }');
lines.push(' : { params?: never });');
lines.push('');
lines.push(' export type ActionUrl =');
lines.push(' | {');
Expand Down
5 changes: 5 additions & 0 deletions packages/void/src/pages/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions packages/void/src/pages/form-reset.ts
Original file line number Diff line number Diff line change
@@ -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<string | FormResetOptions>,
): { initial: boolean; fields: ReadonlyArray<string> } {
if (args.length > 0 && isFormResetOptions(args[0])) {
return {
initial: args[0].initial === true,
fields: args.slice(1) as ReadonlyArray<string>,
};
}
return { initial: false, fields: args as ReadonlyArray<string> };
}
8 changes: 6 additions & 2 deletions packages/void/src/routes-stub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,13 @@ export type ResolveActionParams<U extends string> =
export type HasActionParams<U extends string> =
ResolveActionParams<U> extends Record<string, string> ? true : false;

type ActionFormBaseOptions = { resetOnSuccess?: boolean };

/** Form options type (requires params for dynamic routes) */
export type ActionFormOptions<U extends string> =
HasActionParams<U> extends true ? { params: ResolveActionParams<U> } : { params?: never };
export type ActionFormOptions<U extends string> = ActionFormBaseOptions &
(HasActionParams<U> extends true
? { params: ResolveActionParams<U> }
: { params?: never });

/** Valid useForm URLs — single-action pages + "page?name" combos + default actions */
export type ActionUrl =
Expand Down
31 changes: 31 additions & 0 deletions packages/void/test/unit/form-reset.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading