Skip to content

Commit b018a22

Browse files
cursoragentcpojer
authored andcommitted
feat(pages): add useForm setError and clear field errors on change
Fixes #525 ## Summary Adds `setError(field, message)` to `useForm` across React, Vue, Svelte, and Solid so apps can surface client-side validation through the same typed `errors` object as server `withValidator()` responses. Changing a field clears that field's validation error automatically (`setData` / `data` assignment). ## Changes - Add public `setError(field, message)` on `useForm` for all four frameworks. - Clear `errors[field]` on field change via `setData` (React/Solid) or `data` assignment (Vue/Svelte). - Update docs for actions/forms and the pages API reference. - Add Vue unit coverage for `setError` and clear-on-change. ## Testing - `packages/vue/test/unit/use-form.test.ts` - Typecheck `@void/react`, `@void/vue`, `@void/svelte`, `@void/solid` Co-authored-by: froQ <sayhola@froq.me>
1 parent 233a0f7 commit b018a22

7 files changed

Lines changed: 131 additions & 13 deletions

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,12 @@ The types are inferred from your action's `withValidator()` schema in the compan
159159
| `recentlySuccessful` | `true` for 2 seconds after a successful submission. Useful for flash messages. |
160160
| `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`. |
161161
| `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. |
162+
| `setError(field, message)` | Set a client-side validation error on a field. |
162163
| `clearErrors(...fields)` | Clear validation errors. Field names autocomplete. |
163164
| `clearError()` | Clear `error`. |
164165

166+
Changing a field clears that field's validation error automatically (`setData` in React and Solid, or assigning to `data` in Vue and Svelte).
167+
165168
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.
166169

167170
For dynamic routes, pass `params` in the options:

‎docs/reference/api/pages.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ Imported from `"@void/vue"`.
4949
| `useRouter()` | Returns the Void Router with current route state (`url`, `path`, `query`, `params`) and navigation methods: `visit`, `refresh`, awaitable `prefetch`, `flush`, `flushAll`. |
5050
| `useParams()` | Returns dynamic route params for the page currently being rendered. |
5151
| `useNavigation()` | Returns pending navigation state: `{ state, location, method }`, where `state` is `"idle"`, `"loading"`, or `"submitting"` and `location` is the pending destination. |
52-
| `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 }`. |
52+
| `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 }`. |
5353
| `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()`. |
5454
| `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`. |
5555
| `useShared()` | Returns shared data injected by middleware via `c.set("shared", {...})`. |
@@ -101,7 +101,7 @@ Imported from `"@void/react"`.
101101
| `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. |
102102
| `useParams()` | Returns dynamic route params for the page currently being rendered. |
103103
| `useNavigation()` | Returns pending navigation state: `{ state, location, method }`, where `state` is `"idle"`, `"loading"`, or `"submitting"` and `location` is the pending destination. |
104-
| `useForm(url, defaults, options?)` | Form helper hook. Returns `{ data, setData, post, put, patch, delete, pending, errors, error, hasChanges, wasSuccessful, recentlySuccessful, reset, clearErrors, clearError }`. |
104+
| `useForm(url, defaults, options?)` | Form helper hook. Returns `{ data, setData, post, put, patch, delete, pending, errors, error, hasChanges, wasSuccessful, recentlySuccessful, reset, setError, clearErrors, clearError }`. |
105105
| `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()`. |
106106
| `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`. |
107107
| `useShared()` | Returns shared data injected by middleware via `c.set("shared", {...})`. |
@@ -158,7 +158,7 @@ Imported from `"@void/svelte"`.
158158
| Export | Description |
159159
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
160160
| `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`. |
161-
| `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 }`. |
161+
| `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 }`. |
162162
| `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()`. |
163163
| `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`. |
164164
| `useShared()` | Returns shared data injected by middleware via `c.set("shared", {...})`. |
@@ -208,7 +208,7 @@ Imported from `"@void/solid"`.
208208
| Export | Description |
209209
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
210210
| `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`. |
211-
| `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 }`. |
211+
| `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 }`. |
212212
| `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()`. |
213213
| `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`. |
214214
| `useShared()` | Returns shared data injected by middleware via `c.set("shared", {...})`. |

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

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ type UntypedForm = {
3737
delete(formData: FormData): void;
3838
reset(options: FormResetOptions, ...fields: ReadonlyArray<string>): void;
3939
reset(...fields: ReadonlyArray<string>): void;
40+
setError(field: string, message: string): void;
4041
clearErrors(...fields: ReadonlyArray<string>): void;
4142
clearError(): void;
4243
};
@@ -67,6 +68,7 @@ export function useForm<U extends ActionUrl & string>(
6768
...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>
6869
): void;
6970
reset(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
71+
setError(field: keyof ResolveActionBody<U> & string, message: string): void;
7072
clearErrors(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
7173
clearError(): void;
7274
};
@@ -82,7 +84,7 @@ export function useForm(
8284
cloneFormValue(defaults),
8385
);
8486
const [errors, setErrors] = useState<Record<string, string>>({});
85-
const [error, setError] = useState<VoidActionError | null>(null);
87+
const [error, setActionError] = useState<VoidActionError | null>(null);
8688
const [wasSuccessful, setWasSuccessful] = useState(false);
8789
const [recentlySuccessful, setRecentlySuccessful] = useState(false);
8890

@@ -100,6 +102,14 @@ export function useForm(
100102
const setData = useCallback((field: string, value: unknown) => {
101103
dataRef.current = { ...dataRef.current, [field]: value };
102104
setDataState(dataRef.current);
105+
setErrors((prev) => {
106+
if (!(field in prev)) {
107+
return prev;
108+
}
109+
const next = { ...prev };
110+
delete next[field];
111+
return next;
112+
});
103113
}, []);
104114

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

144+
const setError = useCallback((field: string, message: string) => {
145+
setErrors((prev) => ({ ...prev, [field]: message }));
146+
}, []);
147+
134148
const clearError = useCallback(() => {
135-
setError(null);
149+
setActionError(null);
136150
}, []);
137151

138152
const [, dispatchAction, pending] = useActionState(
@@ -150,7 +164,7 @@ export function useForm(
150164
successTimeoutRef.current = null;
151165
}
152166
setErrors({});
153-
setError(null);
167+
setActionError(null);
154168

155169
let result;
156170
try {
@@ -173,7 +187,7 @@ export function useForm(
173187
if (validationErrors) {
174188
setErrors(validationErrors);
175189
} else {
176-
setError(result.error);
190+
setActionError(result.error);
177191
}
178192
return previousSuccessVersion;
179193
}
@@ -230,6 +244,7 @@ export function useForm(
230244
patch,
231245
delete: deleteAction,
232246
reset,
247+
setError,
233248
clearErrors,
234249
clearError,
235250
};

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ type UntypedForm = {
3333
delete(opts?: FormSubmitOptions): Promise<void>;
3434
reset(options: FormResetOptions, ...fields: ReadonlyArray<string>): void;
3535
reset(...fields: ReadonlyArray<string>): void;
36+
setError(field: string, message: string): void;
3637
clearErrors(...fields: ReadonlyArray<string>): void;
3738
clearError(): void;
3839
};
@@ -63,6 +64,7 @@ export function useForm<U extends ActionUrl & string>(
6364
...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>
6465
): void;
6566
reset(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
67+
setError(field: keyof ResolveActionBody<U> & string, message: string): void;
6668
clearErrors(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
6769
clearError(): void;
6870
};
@@ -99,7 +101,7 @@ export function useForm(
99101
return globalErrors;
100102
});
101103
const [pending, setPending] = createSignal(false);
102-
const [error, setError] = createSignal<VoidActionError | null>(null);
104+
const [error, setActionError] = createSignal<VoidActionError | null>(null);
103105
const [wasSuccessful, setWasSuccessful] = createSignal(false);
104106
const [recentlySuccessful, setRecentlySuccessful] = createSignal(false);
105107

@@ -113,6 +115,14 @@ export function useForm(
113115

114116
function setData(field: string, value: unknown) {
115117
setDataStore(field, value);
118+
setLocalErrors((prev) => {
119+
if (!(field in prev)) {
120+
return prev;
121+
}
122+
const next = { ...prev };
123+
delete next[field];
124+
return next;
125+
});
116126
}
117127

118128
function reset(...args: ReadonlyArray<string | FormResetOptions>) {
@@ -141,8 +151,12 @@ export function useForm(
141151
}
142152
}
143153

154+
function setFieldError(field: string, message: string) {
155+
setLocalErrors((prev) => ({ ...prev, [field]: message }));
156+
}
157+
144158
function clearError() {
145-
setError(null);
159+
setActionError(null);
146160
}
147161

148162
async function submit(method: string, opts: FormSubmitOptions = {}) {
@@ -173,7 +187,7 @@ export function useForm(
173187
if (validationErrors) {
174188
setLocalErrors(validationErrors);
175189
} else {
176-
setError(result.error);
190+
setActionError(result.error);
177191
}
178192
return;
179193
}
@@ -223,6 +237,7 @@ export function useForm(
223237
return recentlySuccessful();
224238
},
225239
reset,
240+
setError: setFieldError,
226241
clearErrors,
227242
clearError,
228243
post: (opts?: FormSubmitOptions) => submit('POST', opts),

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type UntypedForm = {
3131
delete(opts?: FormSubmitOptions): Promise<void>;
3232
reset(options: FormResetOptions, ...fields: ReadonlyArray<string>): void;
3333
reset(...fields: ReadonlyArray<string>): void;
34+
setError(field: string, message: string): void;
3435
clearErrors(...fields: ReadonlyArray<string>): void;
3536
clearError(): void;
3637
};
@@ -57,6 +58,7 @@ export function useForm<U extends ActionUrl & string>(
5758
...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>
5859
): void;
5960
reset(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
61+
setError(field: keyof ResolveActionBody<U> & string, message: string): void;
6062
clearErrors(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
6163
clearError(): void;
6264
};
@@ -78,6 +80,16 @@ export function useForm(
7880

7981
let defaults_ = $state.raw(initialDefaults);
8082
let successTimeout: ReturnType<typeof setTimeout> | null = null;
83+
let dataSnapshot = { ...defaults };
84+
85+
$effect(() => {
86+
for (const key of Object.keys(data)) {
87+
if (!isEqualFormValue(data[key], dataSnapshot[key])) {
88+
delete errors[key];
89+
}
90+
}
91+
dataSnapshot = { ...data };
92+
});
8193

8294
const hasChanges = $derived(
8395
Object.keys(defaults_).some((key) => !isEqualFormValue(data[key], defaults_[key])),
@@ -93,6 +105,7 @@ export function useForm(
93105
data[field] = cloneFormValue(source[field]);
94106
}
95107
}
108+
dataSnapshot = { ...data };
96109
}
97110

98111
function clearErrors(...fields: ReadonlyArray<string>) {
@@ -105,6 +118,10 @@ export function useForm(
105118
}
106119
}
107120

121+
function setError(field: string, message: string) {
122+
errors = { ...errors, [field]: message };
123+
}
124+
108125
function clearError() {
109126
error = null;
110127
}
@@ -182,6 +199,7 @@ export function useForm(
182199
return recentlySuccessful;
183200
},
184201
reset,
202+
setError,
185203
clearErrors,
186204
clearError,
187205
post: (opts?: FormSubmitOptions) => submit('POST', opts),

0 commit comments

Comments
 (0)