Skip to content

Commit e1dcdd1

Browse files
committed
feat: enhance getFieldError to support group paths and descendant touches
1 parent 9c568e0 commit e1dcdd1

3 files changed

Lines changed: 183 additions & 12 deletions

File tree

docs/API.md

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,15 @@ resetForm(): void
528528

529529
### getFieldError
530530

531-
Returns the error message for a field only if it has been touched, otherwise `undefined`.
531+
Returns the error message for a field path, gated on user interaction.
532+
533+
- **Leaf paths**: returns the error iff the field itself has been touched.
534+
- **Group / parent paths**: returns the error iff *any* descendant field has been
535+
touched. Cross-field validation errors that schemas attach to a non-leaf path
536+
(e.g. `S.refineAt("confirm", v => v.password === v.confirm, …)` or
537+
`S.refineAt("r2", …)` over a tuple) surface naturally on the group container,
538+
without callers having to read `form.errors` and `form.touched` directly.
539+
532540
Eliminates the common `touched[field] ? errors[field] : undefined` boilerplate.
533541

534542
```typescript
@@ -539,11 +547,11 @@ getFieldError<TField extends ExtractFieldPaths<TValues>>(
539547

540548
**Parameters:**
541549

542-
- `field` - Field path with autocomplete
550+
- `field` - Field path with autocomplete (leaf or parent)
543551

544-
**Returns:** The error message string if the field is touched and has an error, otherwise `undefined`.
552+
**Returns:** The error message string if visible per the rules above, otherwise `undefined`.
545553

546-
**Example:**
554+
**Example — leaf:**
547555

548556
```typescript
549557
// Instead of:
@@ -553,6 +561,27 @@ getFieldError<TField extends ExtractFieldPaths<TValues>>(
553561
{form.getFieldError("email") && <span>{form.getFieldError("email")}</span>}
554562
```
555563
564+
**Example — group / parent path:**
565+
566+
```typescript
567+
const schema = S.chain(
568+
S.object({
569+
password: S.required(S.string()),
570+
confirm: S.required(S.string()),
571+
}),
572+
S.refineAt('confirm', (v) => v.password === v.confirm, 'Passwords must match'),
573+
);
574+
575+
// In a fieldset that owns both inputs — error becomes visible once any
576+
// descendant of `confirm` is touched (or `confirm` itself, for a leaf).
577+
{form.getFieldError('confirm') && <p className="err">{form.getFieldError('confirm')}</p>}
578+
```
579+
580+
The descendant scan uses a path-prefix check that guards against sibling
581+
collisions — `r2` does not match a touch on `r20`. `getFieldError` never
582+
aggregates *across* paths: it only returns an error attached at the queried
583+
path, gated on touch of the path or any of its descendants.
584+
556585
### getFieldId
557586
558587
Returns a stable HTML element ID for a field without creating onChange/onBlur handlers.

src/useForm.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -874,24 +874,45 @@ export const useForm = <TValues extends Record<string, unknown>>(
874874
// ===========================================================================
875875

876876
/**
877-
* Returns the error message for a field only if it has been touched, otherwise `undefined`.
878-
* Eliminates the common `touched[field] ? errors[field] : undefined` boilerplate.
877+
* Returns the error message for a field path, gated on user interaction.
878+
*
879+
* - Leaf paths: returns the error iff the field itself has been touched.
880+
* - Group / parent paths: returns the error iff *any* descendant field has
881+
* been touched. This makes cross-field validation errors that schemas
882+
* attach to a non-leaf path (e.g. `S.refineAt("confirm", v => v.password
883+
* === v.confirm, …)` or `S.refineAt("r2", …)` over a tuple) surface
884+
* naturally on the group container, without callers having to read
885+
* `form.errors` and `form.touched` directly.
886+
*
887+
* Eliminates the common `touched[field] ? errors[field] : undefined`
888+
* boilerplate and removes the leaf-only constraint that previously hid
889+
* group-level errors.
879890
*
880891
* @template TField - The field path type (auto-inferred with autocomplete)
881892
* @param field - The field path (with type-safe autocomplete)
882-
* @returns The error message string if the field is touched and has an error, otherwise `undefined`
893+
* @returns The error message string if visible per the rules above, otherwise `undefined`
883894
*
884895
* @example
885-
* // Instead of:
886-
* {form.touched.email && form.errors.email && <span>{form.errors.email}</span>}
887-
*
888-
* // Use:
896+
* // Leaf:
889897
* {form.getFieldError("email") && <span>{form.getFieldError("email")}</span>}
898+
*
899+
* @example
900+
* // Group — schema has `refineAt("r2", v => !equal(v.r1, v.r2), "must differ")`:
901+
* {form.getFieldError("r2") && <Banner msg={form.getFieldError("r2")} />}
890902
*/
891903
const getFieldError = useCallback(
892904
<TField extends ExtractFieldPaths<TValues>>(field: TField): string | undefined => {
893905
const key = normalizePath(field);
894-
return formState.touched[key] ? errors[key] : undefined;
906+
const err = errors[key];
907+
if (!err) return undefined;
908+
// Visible iff the queried path itself or any descendant has been touched.
909+
// `isPathAffected(touchedKey, key)` already encodes "same path or
910+
// descendant," reusing the same semantics used for cascading error
911+
// clears (see utils.ts).
912+
for (const touchedKey in formState.touched) {
913+
if (formState.touched[touchedKey] && isPathAffected(touchedKey, key)) return err;
914+
}
915+
return undefined;
895916
},
896917
[formState.touched, errors],
897918
);

tests/hooks/useForm.test.tsx

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -794,6 +794,127 @@ describe('useForm', () => {
794794

795795
expect(result.current.getFieldError('address.city')).toBe('City is required');
796796
});
797+
798+
test('group path: returns undefined when no descendant is touched', () => {
799+
const { result } = renderHook(() =>
800+
useForm(alwaysValidValidator, {
801+
initialValues: { r1: [0, 0, 0], r2: [0, 0, 0] },
802+
}),
803+
);
804+
805+
act(() => {
806+
result.current.setServerErrors({ r2: 'Vectors must differ' });
807+
});
808+
809+
// Error exists but no descendant touched — invisible.
810+
expect(result.current.getFieldError('r2')).toBeUndefined();
811+
});
812+
813+
test('group path: bubbles error once a descendant is touched', () => {
814+
const { result } = renderHook(() =>
815+
useForm(alwaysValidValidator, {
816+
initialValues: { r1: [0, 0, 0], r2: [0, 0, 0] },
817+
}),
818+
);
819+
820+
act(() => {
821+
result.current.setServerErrors({ r2: 'Vectors must differ' });
822+
result.current.setFieldTouched('r2.0', true);
823+
});
824+
825+
expect(result.current.getFieldError('r2')).toBe('Vectors must differ');
826+
});
827+
828+
test('group path: ignores sibling-prefix touches (r20.0 must not satisfy "r2." scan)', () => {
829+
const { result } = renderHook(() =>
830+
useForm(alwaysValidValidator, {
831+
initialValues: {
832+
r2: [0, 0, 0],
833+
r20: [0, 0, 0],
834+
},
835+
}),
836+
);
837+
838+
act(() => {
839+
result.current.setServerErrors({ r2: 'Vectors must differ' });
840+
result.current.setFieldTouched('r20.0', true);
841+
});
842+
843+
// r20.0 starts with "r20." not "r2." — the trailing-dot guard.
844+
expect(result.current.getFieldError('r2')).toBeUndefined();
845+
});
846+
847+
test('group path: nested descendant satisfies the scan', () => {
848+
const { result } = renderHook(() =>
849+
useForm(alwaysValidValidator, {
850+
initialValues: {
851+
secondary: { position: [0, 0, 0], velocity: [0, 0, 0] },
852+
},
853+
}),
854+
);
855+
856+
act(() => {
857+
result.current.setServerErrors({
858+
secondary: 'Primary and secondary must differ',
859+
});
860+
result.current.setFieldTouched('secondary.position.0', true);
861+
});
862+
863+
expect(result.current.getFieldError('secondary')).toBe('Primary and secondary must differ');
864+
});
865+
866+
test('group path: descendant touched but no error at path → undefined', () => {
867+
const { result } = renderHook(() =>
868+
useForm(alwaysValidValidator, {
869+
initialValues: { r1: [0, 0, 0], r2: [0, 0, 0] },
870+
}),
871+
);
872+
873+
act(() => {
874+
result.current.setFieldTouched('r2.0', true);
875+
});
876+
877+
// Short-circuit: no error at "r2", scan never runs.
878+
expect(result.current.getFieldError('r2')).toBeUndefined();
879+
});
880+
881+
test('leaf path: behavior unchanged when descendants are touched', () => {
882+
// Ensures the new branch doesn't accidentally affect leaf semantics.
883+
const { result } = renderHook(() =>
884+
useForm(alwaysValidValidator, {
885+
initialValues: { name: '' },
886+
}),
887+
);
888+
889+
act(() => {
890+
result.current.setServerErrors({ name: 'Name is required' });
891+
result.current.setFieldTouched('name', true);
892+
});
893+
894+
expect(result.current.getFieldError('name')).toBe('Name is required');
895+
});
896+
897+
test("group path: error at one parent does not bubble through a sibling parent's descendant touch", () => {
898+
const { result } = renderHook(() =>
899+
useForm(alwaysValidValidator, {
900+
initialValues: {
901+
primary: { position: [0, 0, 0] },
902+
secondary: { position: [0, 0, 0] },
903+
},
904+
}),
905+
);
906+
907+
act(() => {
908+
result.current.setServerErrors({
909+
primary: 'primary error',
910+
secondary: 'secondary error',
911+
});
912+
result.current.setFieldTouched('primary.position.0', true);
913+
});
914+
915+
expect(result.current.getFieldError('primary')).toBe('primary error');
916+
expect(result.current.getFieldError('secondary')).toBeUndefined();
917+
});
797918
});
798919

799920
describe('getFieldId', () => {

0 commit comments

Comments
 (0)