Skip to content

Commit 2efa29b

Browse files
committed
refactor: streamline button disable logic and enhance field validator typings for clarity
1 parent adf85db commit 2efa29b

7 files changed

Lines changed: 50 additions & 34 deletions

File tree

README.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export function LoginForm() {
7575
<span>{form.errors.password}</span>
7676
)}
7777

78-
<button type="submit" disabled={form.isSubmitting || !form.isValid}>
78+
<button type="submit" disabled={form.isSubmitting}>
7979
{form.isSubmitting ? 'Logging in...' : 'Log In'}
8080
</button>
8181
</form>
@@ -174,13 +174,12 @@ export function RegistrationForm() {
174174
},
175175
fieldValidators: {
176176
username: async (value) => {
177-
const username = value as string;
178-
if (username.length < 3) return undefined;
177+
if (value.length < 3) return undefined;
179178

180179
try {
181180
const { available } = await queryClient.fetchQuery({
182-
queryKey: ['check-username', username],
183-
queryFn: () => checkUsername(username),
181+
queryKey: ['check-username', value],
182+
queryFn: () => checkUsername(value),
184183
staleTime: 30_000,
185184
});
186185
return available ? undefined : 'Username is already taken';
@@ -221,7 +220,7 @@ export function RegistrationForm() {
221220

222221
<button
223222
type="submit"
224-
disabled={mutation.isPending || form.isValidating || !form.isValid}
223+
disabled={mutation.isPending || form.isValidating}
225224
>
226225
{mutation.isPending ? 'Registering...' : 'Create Account'}
227226
</button>

docs/API.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,10 @@ Controls when validation occurs.
114114

115115
#### fieldValidators
116116

117-
Type: `Partial<Record<ExtractFieldPaths<TValues>, (value: unknown, values: TValues) => string | undefined | Promise<string | undefined>>>`
117+
Type: `{ [K in ExtractFieldPaths<TValues>]?: (value: FieldTypeAtPath<TValues, K>, values: TValues) => string | undefined | Promise<string | undefined> }`
118118
Optional
119119

120-
Per-field validator functions for field-level async validation. Run independently from the main schema validator, only for the changed field. A field validator only runs when the schema produces **no error** for that field (schema passes first, then the field validator runs).
120+
Per-field validator functions for field-level async validation. The `value` parameter is typed based on the field path, so no cast is needed. Run independently from the main schema validator, only for the changed field. A field validator only runs when the schema produces **no error** for that field (schema passes first, then the field validator runs).
121121

122122
Return `undefined` for valid, or an error message string for invalid.
123123

@@ -126,7 +126,7 @@ const form = useForm(userSchema, {
126126
initialValues: { username: '', email: '' },
127127
fieldValidators: {
128128
username: async (value) => {
129-
const available = await checkUsernameAvailable(value as string);
129+
const available = await checkUsernameAvailable(value);
130130
return available ? undefined : 'Username is already taken';
131131
},
132132
},

docs/GETTING_STARTED.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ function LoginForm() {
9898
)}
9999
</div>
100100

101-
<button type="submit" disabled={form.isSubmitting || !form.isValid}>
101+
<button type="submit" disabled={form.isSubmitting}>
102102
{form.isSubmitting ? 'Logging in...' : 'Log In'}
103103
</button>
104104
</form>

docs/RECIPES.md

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ function CreateUserForm() {
112112
<div className="form-error">{form.errors[ROOT_ERROR_KEY]}</div>
113113
)}
114114

115-
<button type="submit" disabled={form.isSubmitting || !form.isValid}>
115+
<button type="submit" disabled={form.isSubmitting}>
116116
{form.isSubmitting ? 'Saving...' : 'Create User'}
117117
</button>
118118

@@ -1007,7 +1007,7 @@ function RegistrationForm() {
10071007
},
10081008
fieldValidators: {
10091009
username: async (value) => {
1010-
const available = await checkUsernameAvailable(value as string);
1010+
const available = await checkUsernameAvailable(value);
10111011
return available ? undefined : 'Username is already taken';
10121012
},
10131013
},
@@ -1029,7 +1029,7 @@ function RegistrationForm() {
10291029

10301030
<button
10311031
type="submit"
1032-
disabled={form.isSubmitting || form.isValidating || !form.isValid}
1032+
disabled={form.isSubmitting || form.isValidating}
10331033
>
10341034
Register
10351035
</button>
@@ -1048,7 +1048,7 @@ function RegistrationForm() {
10481048
| **Race conditions** | Handled per-field (latest wins) | Handled per-form (latest wins) |
10491049
| **Best for** | Server lookups (username, email) | Cross-field async checks |
10501050

1051-
Field validators return `undefined` for valid, or an error message string for invalid. They receive the field value and the full form values object: `(value, values) => string | undefined | Promise<string | undefined>`.
1051+
Field validators return `undefined` for valid, or an error message string for invalid. They receive the typed field value and the full form values object: `(value: FieldTypeAtPath<TValues, K>, values: TValues) => string | undefined | Promise<string | undefined>` — no cast needed.
10521052

10531053
---
10541054

@@ -1664,9 +1664,8 @@ export function RegistrationForm() {
16641664
initialValues: { username: '', email: '', password: '', confirmPassword: '' },
16651665
fieldValidators: {
16661666
username: async (value) => {
1667-
const username = value as string;
1668-
if (username.length < 3) return undefined;
1669-
const result = await checkUsername(username);
1667+
if (value.length < 3) return undefined;
1668+
const result = await checkUsername(value);
16701669
return match(result, {
16711670
ok: ({ available }) => available ? undefined : 'Username is already taken',
16721671
err: () => 'Unable to check username availability',
@@ -1703,7 +1702,7 @@ export function RegistrationForm() {
17031702
<span>{form.errors[ROOT_ERROR_KEY]}</span>
17041703
)}
17051704

1706-
<button type="submit" disabled={form.isSubmitting || form.isValidating || !form.isValid}>
1705+
<button type="submit" disabled={form.isSubmitting || form.isValidating}>
17071706
{form.isSubmitting ? 'Registering...' : 'Create Account'}
17081707
</button>
17091708
</form>
@@ -1796,13 +1795,12 @@ export function RegistrationForm() {
17961795
initialValues: { username: '', email: '', password: '', confirmPassword: '' },
17971796
fieldValidators: {
17981797
username: async (value) => {
1799-
const username = value as string;
1800-
if (username.length < 3) return undefined;
1798+
if (value.length < 3) return undefined;
18011799

18021800
try {
18031801
const { available } = await queryClient.fetchQuery({
1804-
queryKey: ['check-username', username],
1805-
queryFn: () => checkUsername(username),
1802+
queryKey: ['check-username', value],
1803+
queryFn: () => checkUsername(value),
18061804
staleTime: 30_000,
18071805
});
18081806
return available ? undefined : 'Username is already taken';
@@ -1835,7 +1833,7 @@ export function RegistrationForm() {
18351833
<span>{form.errors[ROOT_ERROR_KEY]}</span>
18361834
)}
18371835

1838-
<button type="submit" disabled={mutation.isPending || form.isValidating || !form.isValid}>
1836+
<button type="submit" disabled={mutation.isPending || form.isValidating}>
18391837
{mutation.isPending ? 'Registering...' : 'Create Account'}
18401838
</button>
18411839
</form>

examples/field-validators/FieldValidatorForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export default function FieldValidatorForm() {
2929
// Per-field async validators — run after schema validation passes for that field
3030
fieldValidators: {
3131
username: async (value) => {
32-
const available = await checkUsernameAvailable(value as string);
32+
const available = await checkUsernameAvailable(value);
3333
return available ? undefined : 'Username is already taken';
3434
},
3535
},

src/types.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -197,15 +197,12 @@ export interface FormOptions<TValues extends Record<string, unknown>> {
197197
* A field validator only runs when the schema produces no error for that field.
198198
* Return undefined for valid, or an error message string for invalid.
199199
*/
200-
fieldValidators?: Partial<
201-
Record<
202-
ExtractFieldPaths<TValues>,
203-
(
204-
value: unknown,
205-
values: TValues
206-
) => string | undefined | Promise<string | undefined>
207-
>
208-
>;
200+
fieldValidators?: {
201+
[K in ExtractFieldPaths<TValues>]?: (
202+
value: FieldTypeAtPath<TValues, K>,
203+
values: TValues
204+
) => string | undefined | Promise<string | undefined>;
205+
};
209206
}
210207

211208
// =============================================================================
@@ -502,6 +499,28 @@ export type NativeCheckboxGroupOptionProps = {
502499
* type Form = { payment: Payment };
503500
* type Paths = ExtractFieldPaths<Form>; // "payment" | "payment.type" | "payment.cardNumber" | "payment.email"
504501
*/
502+
/**
503+
* Resolves the type at a dot-separated field path within a type.
504+
* Companion to ExtractFieldPaths — while ExtractFieldPaths extracts all valid paths,
505+
* FieldTypeAtPath resolves the value type at a given path.
506+
*
507+
* @template T - The root type to traverse
508+
* @template P - A dot-separated path string
509+
*
510+
* @example
511+
* type User = { name: string; address: { city: string; zip: number } };
512+
* type City = FieldTypeAtPath<User, "address.city">; // string
513+
* type Zip = FieldTypeAtPath<User, "address.zip">; // number
514+
*/
515+
export type FieldTypeAtPath<T, P extends string> =
516+
P extends `${infer K}.${infer Rest}`
517+
? K extends keyof T
518+
? FieldTypeAtPath<NonNullable<T[K]>, Rest>
519+
: unknown
520+
: P extends keyof T
521+
? T[P]
522+
: unknown;
523+
505524
export type ExtractFieldPaths<T> = T extends readonly unknown[]
506525
? never
507526
: T extends object

src/useForm.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ export const useForm = <TValues extends Record<string, unknown>>(
361361
dispatch({ type: 'SET_FIELD_ERROR', field, error: undefined });
362362

363363
const fieldValue = getValueByPath(values, field);
364-
const result = validatorFn(fieldValue, values);
364+
const result = (validatorFn as (value: unknown, values: TValues) => string | undefined | Promise<string | undefined>)(fieldValue, values);
365365

366366
if (result instanceof Promise) {
367367
if (!fieldValidationSeqRef.current[field]) {

0 commit comments

Comments
 (0)