-
Notifications
You must be signed in to change notification settings - Fork 146
[PROPOSAL] New React Hook Form Abtraction #639
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
WalkthroughThis PR introduces a comprehensive form system built on react-hook-form, including UI field components (Field, FieldLabel, FieldError, FieldDescription), Form and FormController wrappers, form-bound field components (FieldText, FieldSelect), context-based helpers, custom hooks, and new Radix UI dependencies for Label and Separator. Changes
Sequence DiagramsequenceDiagram
actor User
participant App as Application
participant Form as Form Component
participant FormProvider as FormProvider
participant Controller as FormController
participant Context as FormControllerContext
participant Field as FieldText/FieldSelect
participant RHF as React Hook Form
User->>App: Initialize form with useAppForm
App->>RHF: useForm + custom Controller
RHF-->>App: form methods + Controller
App->>Form: render Form with onSubmit
Form->>FormProvider: wrap children
App->>Controller: render FormController for each field
Controller->>RHF: render RHF Controller
RHF->>Context: provide FormControllerContextValue
Context-->>Controller: context ready
Controller->>Field: render field with context
Field->>Context: useFormControllerContext
Context-->>Field: field state, metadata (labelId, errorId)
Field->>Field: wire onChange, onBlur to form state
Field->>Field: render with ARIA attributes
User->>Field: interact (type/select)
Field->>RHF: update field value via onChange
RHF->>Field: trigger re-render with new state
User->>Form: submit
Form->>RHF: handleSubmit
RHF->>RHF: validate
alt Validation passes
RHF->>App: invoke onSubmit
else Validation fails
RHF->>Field: update fieldState.invalid
Field->>Field: render FieldError
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes
Possibly related PRs
Suggested labels
Suggested reviewers
Pre-merge checks and finishing touches❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
📜 Recent review detailsConfiguration used: Repository UI Review profile: CHILL Plan: Pro ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (18)
🚧 Files skipped from review as they are similar to previous changes (12)
🧰 Additional context used🧠 Learnings (2)📚 Learning: 2024-09-30T11:07:14.833ZApplied to files:
📚 Learning: 2024-10-11T14:57:53.600ZApplied to files:
🧬 Code graph analysis (6)src/components/new-form/form-field-label.tsx (2)
src/components/new-form/field-text/index.tsx (4)
src/components/new-form/_field-components.ts (6)
src/components/ui/field.tsx (3)
src/components/new-form/docs.stories.tsx (10)
src/components/new-form/form-controller/index.tsx (3)
🪛 Biome (2.1.2)src/components/new-form/form-controller/index.tsx[error] 58-58: This hook is being called from a nested function, but all hooks must be called unconditionally from the top-level component. For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order. (lint/correctness/useHookAtTopLevel) ⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
🔇 Additional comments (7)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 8
🧹 Nitpick comments (3)
src/types/utilities.d.ts (1)
39-42: Consider a more robust WithRequired implementation.The current implementation intersects with
{ [_ in TKey]: {} }, which doesn't preserve the original types of the required keys. The empty object type{}represents "any non-nullish value" in TypeScript, not an empty object.Consider using a standard approach that preserves type information:
-type WithRequired<TTarget, TKey extends keyof TTarget> = TTarget & { - // eslint-disable-next-line @typescript-eslint/no-empty-object-type - [_ in TKey]: {}; -}; +type WithRequired<TTarget, TKey extends keyof TTarget> = TTarget & + Required<Pick<TTarget, TKey>>;This ensures the required keys retain their original types from
TTarget.src/components/new-form/form-field/index.tsx (1)
56-66: Consider alternatives to useMemo in the render prop.While the current pattern works (and is acknowledged with the eslint-disable), calling
useMemoinside a render function is unconventional and flagged by static analysis. Consider these alternatives:
- Extract the render logic to a separate component that can use hooks at the top level
- Remove
useMemoentirely sincefieldandfieldStateare already stable references from ControllerExample of removing useMemo:
render={({ field, fieldState }) => { - // We are inside a render function so it's fine - // eslint-disable-next-line react-hooks/rules-of-hooks - const fieldCtx = useMemo( - () => ({ - field, - fieldState, - size, - }), - [field, fieldState, size] - ) as FormFieldContextValue; + const fieldCtx = { + field, + fieldState, + size, + } as FormFieldContextValue; return (src/components/ui/field.tsx (1)
127-138: Consider using a distinct data-slot for FieldTitle.Both
FieldTitle(line 130) andFieldLabel(line 115) usedata-slot="field-label". While this might be intentional for styling purposes, it could cause confusion when selecting elements with CSS or JavaScript.If they serve different semantic purposes, consider using
data-slot="field-title"for FieldTitle.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (16)
package.json(1 hunks)src/components/new-form/_field-components.ts(1 hunks)src/components/new-form/docs.stories.tsx(1 hunks)src/components/new-form/field-select/index.tsx(1 hunks)src/components/new-form/field-text/index.tsx(1 hunks)src/components/new-form/form-field-label.tsx(1 hunks)src/components/new-form/form-field/context.tsx(1 hunks)src/components/new-form/form-field/index.tsx(1 hunks)src/components/new-form/form.tsx(1 hunks)src/components/new-form/index.ts(1 hunks)src/components/ui/field.tsx(1 hunks)src/components/ui/input.tsx(1 hunks)src/components/ui/label.tsx(1 hunks)src/components/ui/select.tsx(1 hunks)src/lib/react-hook-form/index.tsx(1 hunks)src/types/utilities.d.ts(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (10)
src/components/new-form/index.ts (2)
src/components/form/docs.stories.tsx (2)
form(70-114)form(30-68)src/components/form/field-text/docs.stories.tsx (3)
form(55-82)form(31-53)form(84-112)
src/components/new-form/field-select/index.tsx (3)
src/components/ui/select.tsx (2)
TValueBase(23-23)Select(55-212)src/components/new-form/form-field/context.tsx (1)
useFormField(20-26)src/components/form/field-select/index.tsx (3)
TFieldValues(25-95)div(53-92)e(82-85)
src/components/new-form/_field-components.ts (4)
src/components/new-form/form-field-label.tsx (1)
FormFieldLabel(4-10)src/components/new-form/field-text/index.tsx (1)
FieldText(5-35)src/components/new-form/field-select/index.tsx (1)
FieldSelect(6-45)src/components/form/form-field-controller.tsx (1)
TFieldValues(72-140)
src/components/new-form/docs.stories.tsx (4)
src/components/new-form/form.tsx (1)
Form(25-54)src/lib/zod/zod-utils.ts (1)
zu(12-42)src/lib/react-hook-form/index.tsx (1)
useForm(26-37)src/components/form/docs.stories.tsx (3)
form(70-114)form(30-68)z(24-28)
src/components/new-form/field-text/index.tsx (2)
src/components/new-form/form-field/context.tsx (1)
useFormField(20-26)src/components/form/field-text/index.tsx (4)
e(71-74)div(52-82)TFieldValues(26-85)e(75-78)
src/components/new-form/form-field-label.tsx (2)
src/components/ui/field.tsx (1)
FieldLabel(242-242)src/components/new-form/form-field/context.tsx (1)
useFormField(20-26)
src/components/new-form/form-field/index.tsx (3)
src/components/new-form/form-field/context.tsx (3)
FormFieldSize(8-8)FormFieldContextValue(10-14)FormFieldContext(16-18)src/components/new-form/_field-components.ts (2)
FieldComponents(14-14)fieldComponents(5-12)src/components/ui/field.tsx (1)
Field(237-237)
src/components/new-form/form-field/context.tsx (3)
src/components/form/field-text/index.tsx (3)
TFieldValues(26-85)div(52-82)e(71-74)src/components/form/field-checkbox/index.tsx (1)
field(50-75)src/components/form/field-checkbox-group/index.tsx (1)
field(59-101)
src/lib/react-hook-form/index.tsx (2)
src/components/new-form/form-field/index.tsx (1)
FormField(44-82)src/components/new-form/index.ts (1)
FormField(4-4)
src/components/new-form/form.tsx (3)
src/components/form/form.tsx (1)
e(38-47)src/components/form/docs.stories.tsx (2)
form(70-114)form(30-68)src/components/form/form-test-utils.tsx (1)
T(14-41)
🪛 Biome (2.1.2)
src/components/new-form/docs.stories.tsx
[error] 44-44: Avoid passing children using a prop
The canonical way to pass children in React is to use JSX elements
(lint/correctness/noChildrenProp)
[error] 54-54: Avoid passing children using a prop
The canonical way to pass children in React is to use JSX elements
(lint/correctness/noChildrenProp)
src/components/new-form/form-field/index.tsx
[error] 59-59: This hook is being called from a nested function, but all hooks must be called unconditionally from the top-level component.
For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level
(lint/correctness/useHookAtTopLevel)
🪛 GitHub Check: 🧹 Linter
src/components/ui/field.tsx
[warning] 214-214:
Do not use item index in the array as its key
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
- GitHub Check: 🔬 Tests (lts/*)
- GitHub Check: Playwright E2E Tests
🔇 Additional comments (8)
src/components/ui/input.tsx (1)
13-13: LGTM! More precise ARIA invalid state handling.Narrowing the selector to
has-[[aria-invalid=true]]correctly ensures invalid styling only applies when aria-invalid is explicitly true, not when it's false or missing. This aligns well with the new form-field system's centralized error handling.src/components/new-form/form-field-label.tsx (1)
1-10: LGTM! Clean composition pattern.The component properly consumes form field context and wires up the label associations correctly. The
idandhtmlForattributes ensure proper accessibility linkage between label and input.package.json (1)
57-58: LGTM! Appropriate dependencies for accessible primitives.The Radix UI Label and Separator components provide solid accessible foundations for the new form field system.
src/components/ui/label.tsx (1)
1-22: LGTM! Well-structured accessible label component.The component properly wraps Radix UI's Label primitive with appropriate styling and disabled state handling. The use of both
group-data-[disabled=true]andpeer-disabledvariants ensures proper disabled styling in various composition scenarios.src/components/ui/select.tsx (1)
23-23: LGTM! Appropriate type export for form integration.Exporting
TValueBaseenables the new form field components to properly constrain their generic type parameters without duplicating the type definition.src/components/new-form/field-text/index.tsx (1)
5-35: Clean implementation with proper handler chaining.The component correctly:
- Integrates with form field context
- Chains onChange/onBlur handlers to preserve both RHF and custom behavior
- Conditionally renders errors based on validation state
- Maintains consistent ID patterns for accessibility
src/components/new-form/index.ts (1)
1-4: LGTM! Clean barrel export for the new form API.The exports properly consolidate the public API surface for the form abstraction.
src/components/ui/field.tsx (1)
1-247: Well-structured Field component library.The Field UI component library demonstrates solid design patterns:
- Consistent use of
data-slotattributes for composability- Clean separation of concerns with focused components
- Good use of
class-variance-authorityfor variant management- Comprehensive coverage of form field scenarios
The composable API will integrate well with the new FormField abstraction.
| const fieldCtx = useMemo( | ||
| () => ({ | ||
| field, | ||
| fieldState, | ||
| size, | ||
| }), | ||
| [field, fieldState] | ||
| ) as FormFieldContextValue; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add 'size' to the useMemo dependency array.
The memoized fieldCtx includes size (line 63) but the dependency array on line 65 only includes [field, fieldState]. When size changes, the context will contain a stale value.
Apply this diff:
const fieldCtx = useMemo(
() => ({
field,
fieldState,
size,
}),
- [field, fieldState]
+ [field, fieldState, size]
) as FormFieldContextValue;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const fieldCtx = useMemo( | |
| () => ({ | |
| field, | |
| fieldState, | |
| size, | |
| }), | |
| [field, fieldState] | |
| ) as FormFieldContextValue; | |
| const fieldCtx = useMemo( | |
| () => ({ | |
| field, | |
| fieldState, | |
| size, | |
| }), | |
| [field, fieldState, size] | |
| ) as FormFieldContextValue; |
🧰 Tools
🪛 Biome (2.1.2)
[error] 59-59: This hook is being called from a nested function, but all hooks must be called unconditionally from the top-level component.
For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level
(lint/correctness/useHookAtTopLevel)
🤖 Prompt for AI Agents
In src/components/new-form/form-field/index.tsx around lines 59 to 66, the
useMemo that builds fieldCtx includes the value "size" but the dependency array
only lists [field, fieldState], so fieldCtx can become stale when size changes;
update the dependency array to include size (i.e., [field, fieldState, size]) so
the memo recomputes whenever size updates.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
♻️ Duplicate comments (3)
src/components/ui/field.tsx (3)
213-219: Consider using error message as the key for better semantics.While the current implementation using
${id}-error-${index}is stable across remounts (thanks to theuseIdprefix), using the error message itself as the key would be more semantically correct since errors are deduplicated by message. This ensures React's reconciliation aligns with the logical identity of each error.Apply this diff:
<ul className="ml-4 flex list-disc flex-col gap-1"> {uniqueErrors.map( - (error, index) => + (error) => error?.message && ( - // eslint-disable-next-line @eslint-react/no-array-index-key - <li key={`${id}-error-${index}`}>{error.message}</li> + <li key={error.message}>{error.message}</li> ) )} </ul>Note: Since errors are deduplicated by message at line 203-204, each
error.messagein the final array should be unique, making it a suitable key.
203-204: Handle undefined error messages in deduplication logic.The Map-based deduplication will collapse all errors with
undefinedmessages into a single entry. If multiple validation rules fail without providing messages, only one undefined error will be preserved.Apply this diff to filter out undefined messages before deduplication:
const uniqueErrors = [ - ...new Map(errors.map((error) => [error?.message, error])).values(), + ...new Map( + errors + .filter((error) => error?.message) + .map((error) => [error.message, error]) + ).values(), ];
207-207: Use strict equality===instead of==.Loose equality can lead to unexpected type coercion.
Apply this diff:
- if (uniqueErrors?.length == 1) { + if (uniqueErrors?.length === 1) { return uniqueErrors[0]?.message; }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
src/components/ui/field.tsx(1 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2024-09-30T11:07:14.833Z
Learnt from: ivan-dalmet
Repo: BearStudio/start-ui-web PR: 532
File: src/features/auth/PageOAuthCallback.tsx:43-45
Timestamp: 2024-09-30T11:07:14.833Z
Learning: When suggesting changes to `useEffect` dependencies in React components, ensure that removing dependencies doesn't cause React Hook errors about missing dependencies.
Applied to files:
src/components/ui/field.tsx
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Playwright E2E Tests
|
Yes if we can rename for this API: <form.Controller
name="name"
size="lg"
render={(field) => (
<>
<field.Label>Name</field.Label>
<field.Text />
<field.Description>This is an helper text</field.Description>
</>
)}
/> |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 3
♻️ Duplicate comments (1)
src/components/new-form/field-text/index.tsx (1)
19-21: aria-describedby always references descriptionId even when no description is rendered.The
aria-describedbyattribute unconditionally includesdescriptionId, but if consumers don't render a<FormFieldDescription>element, this creates an invalid ARIA reference. Consider making this conditional based on whether a description is actually present, or document that consumers must always render a description element.
🧹 Nitpick comments (6)
src/components/new-form/form-field-description.tsx (1)
7-9: UsedescriptionIdfrom context for consistency.The context already provides
descriptionId(computed as${field.name}-description), but this component recomputes it inline. Other components likeFormFieldLabeluse the context-provided ID. For consistency and to avoid potential drift:Proposed fix
export function FormFieldDescription( props: React.ComponentProps<typeof FieldDescription> ) { - const { field } = useFormControllerContext(); + const { descriptionId } = useFormControllerContext(); - return <FieldDescription id={`${field.name}-description`} {...props} />; + return <FieldDescription id={descriptionId} {...props} />; }src/components/new-form/form-field-error.tsx (2)
7-7: Returnnullexplicitly instead of implicitundefined.While React handles
undefinedreturns, explicitly returningnullis the idiomatic pattern for "render nothing" and improves readability.Proposed fix
- if (!fieldState.invalid) return; + if (!fieldState.invalid) return null;
10-14: UseerrorIdfrom context for consistency.Similar to
FormFieldDescription, this component recomputes the error ID inline instead of using the context-providederrorId. For consistency with the context design:Proposed fix
export function FormFieldError(props: React.ComponentProps<typeof FieldError>) { - const { field, fieldState } = useFormControllerContext(); + const { errorId, fieldState } = useFormControllerContext(); if (!fieldState.invalid) return null; return ( <FieldError - id={`${field.name}-error`} + id={errorId} errors={[fieldState.error]} {...props} /> ); }src/components/new-form/form-controller/context.tsx (1)
25-25: Consider updating error message to match context name.The error message references
<FormField />but the context isFormControllerContext. For consistency and easier debugging, consider updating the message to reference<FormController />or the actual parent component name.🔎 Proposed fix
- if (!context) throw new Error('Missing <FormField /> parent component.'); + if (!context) throw new Error('Missing <FormController /> parent component.');src/components/new-form/field-select/index.tsx (2)
17-17: Consider explicit string conversion for data attribute.The
data-invalidattribute receives a boolean value, which will be coerced to the string "true" or "false". For consistency with HTML5 data attribute conventions, consider explicitly converting to a string or using a conditional:data-invalid={fieldState.invalid ? 'true' : undefined}
18-40: Consider simplifying boolean conversions.Lines 19-20 use
fieldState.error ? true : undefinedwhich works correctly but could be simplified to!!fieldState.errororBoolean(fieldState.error) || undefinedfor brevity. However, the current form is explicit and may be intentional for clarity.The value mapping (line 27), onChange handling (lines 28-31), and onBlur chaining (lines 34-36) are all implemented correctly and maintain proper event propagation.
📜 Review details
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (12)
src/components/new-form/_field-components.ts(1 hunks)src/components/new-form/docs.stories.tsx(1 hunks)src/components/new-form/field-select/index.tsx(1 hunks)src/components/new-form/field-text/index.tsx(1 hunks)src/components/new-form/form-controller/context.tsx(1 hunks)src/components/new-form/form-controller/index.tsx(1 hunks)src/components/new-form/form-field-description.tsx(1 hunks)src/components/new-form/form-field-error.tsx(1 hunks)src/components/new-form/form-field-label.tsx(1 hunks)src/components/new-form/hooks.ts(1 hunks)src/components/new-form/index.ts(1 hunks)src/components/new-form/types.ts(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
- src/components/new-form/index.ts
- src/components/new-form/docs.stories.tsx
🧰 Additional context used
🧬 Code graph analysis (10)
src/components/new-form/field-select/index.tsx (4)
src/components/ui/select.tsx (2)
TValueBase(23-23)Select(55-212)src/components/new-form/types.ts (1)
FieldProps(3-11)src/components/new-form/form-controller/context.tsx (1)
useFormControllerContext(22-28)src/components/ui/field.tsx (2)
Field(241-241)FieldError(244-244)
src/components/new-form/form-controller/index.tsx (4)
src/components/new-form/form-controller/context.tsx (2)
FormControllerContextValue(10-17)FormControllerContext(19-20)src/components/new-form/_field-components.ts (2)
FieldComponents(18-18)fieldComponents(7-16)src/components/new-form/index.ts (1)
FormController(4-4)src/components/form/form-field-controller.tsx (2)
props(86-118)TFieldValues(72-140)
src/components/new-form/form-field-description.tsx (2)
src/components/ui/field.tsx (1)
FieldDescription(243-243)src/components/new-form/form-controller/context.tsx (1)
useFormControllerContext(22-28)
src/components/new-form/form-field-error.tsx (2)
src/components/ui/field.tsx (1)
FieldError(244-244)src/components/new-form/form-controller/context.tsx (1)
useFormControllerContext(22-28)
src/components/new-form/hooks.ts (4)
src/components/new-form/form-controller/index.tsx (1)
FormController(42-82)src/components/form/field-text/index.tsx (3)
TFieldValues(26-85)div(52-82)e(71-74)src/components/form/field-date/index.tsx (1)
TFieldValues(25-84)src/components/form/field-select/index.tsx (1)
TFieldValues(25-95)
src/components/new-form/form-field-label.tsx (2)
src/components/ui/field.tsx (1)
FieldLabel(246-246)src/components/new-form/form-controller/context.tsx (1)
useFormControllerContext(22-28)
src/components/new-form/types.ts (5)
src/components/form/field-checkbox/index.tsx (1)
field(50-75)src/components/form/field-text/index.tsx (2)
TFieldValues(26-85)div(52-82)src/components/form/docs.stories.tsx (1)
form(70-114)src/components/form/field-number/index.tsx (1)
fieldProps(67-100)src/components/form/form-field-controller.tsx (1)
props(86-118)
src/components/new-form/_field-components.ts (11)
src/components/new-form/form-field-label.tsx (1)
FormFieldLabel(4-8)src/components/new-form/field-text/index.tsx (1)
FieldText(6-39)src/components/new-form/field-select/index.tsx (1)
FieldSelect(7-46)src/components/new-form/form-field-description.tsx (1)
FormFieldDescription(4-10)src/components/new-form/form-field-error.tsx (1)
FormFieldError(4-16)src/components/form/field-select/index.tsx (2)
TFieldValues(25-95)div(53-92)src/components/form/field-text/index.tsx (1)
TFieldValues(26-85)src/components/form/field-text/docs.stories.tsx (3)
form(55-82)form(84-112)form(114-142)src/components/form/field-number/docs.stories.tsx (1)
form(84-111)src/components/form/field-number/index.tsx (1)
fieldProps(67-100)src/components/form/form-field-controller.tsx (1)
TFieldValues(72-140)
src/components/new-form/form-controller/context.tsx (4)
src/components/new-form/types.ts (1)
FormFieldSize(1-1)src/components/form/field-text/index.tsx (2)
TFieldValues(26-85)div(52-82)src/components/form/form-field-controller.tsx (2)
props(86-118)TFieldValues(72-140)src/components/form/field-date/index.tsx (1)
TFieldValues(25-84)
src/components/new-form/field-text/index.tsx (4)
src/components/new-form/types.ts (1)
FieldProps(3-11)src/components/new-form/form-controller/context.tsx (1)
useFormControllerContext(22-28)src/components/ui/field.tsx (2)
Field(241-241)FieldError(244-244)src/components/form/field-text/index.tsx (2)
div(52-82)e(71-74)
🪛 Biome (2.1.2)
src/components/new-form/form-controller/index.tsx
[error] 58-58: This hook is being called from a nested function, but all hooks must be called unconditionally from the top-level component.
For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level
(lint/correctness/useHookAtTopLevel)
🔇 Additional comments (10)
src/components/new-form/field-text/index.tsx (1)
6-38: Overall implementation is solid.The component correctly:
- Chains
onChange/onBlurto both internal field handlers and external props- Uses context-provided
sizefor consistency- Conditionally renders errors based on
hideErrorsandfieldState.invalidsrc/components/new-form/form-field-label.tsx (1)
4-8: LGTM!Clean implementation that correctly binds the label to the form field using
htmlFor={field.name}and provides a stableidfor potentialaria-labelledbyreferences.src/components/new-form/types.ts (1)
1-11: LGTM!Well-designed utility types.
FieldPropsprovides a clean way to extend component props with form-specific options while preserving type inference for the underlying component.src/components/new-form/_field-components.ts (1)
7-18: Registry pattern is clean and extensible.The
as constassertion preserves literal types, and the comment guides future contributors. This aligns well with the PR's goal of registry-based field additions.src/components/new-form/form-controller/index.tsx (2)
54-68: Acknowledged: useMemo inside render prop is unconventional but acceptable here.The static analysis flags this as a hooks violation. While technically correct (hooks should be at the top level), this pattern is safe because:
- The render function is called exactly once per Controller render cycle
- The call is unconditional within that render
The eslint-disable comment documents the intentional deviation. Consider extracting to a separate inner component if this pattern becomes problematic or confuses future maintainers.
20-35: Well-structured type definition.The
FormControllerPropstype cleanly extendsUseControllerPropswith requirednameand adds a typed render prop that exposes both field state and the component registry. This enables the composition pattern described in the PR objectives.src/components/new-form/field-select/index.tsx (2)
7-14: LGTM!The component signature correctly preserves generic typing from the Select component, and the context consumption is clean and type-safe.
41-43: No issues to address. The code correctly implements error handling:FieldError expects an array of error objects, and the code properly wraps
fieldState.errorin an array[fieldState.error]. The FieldError component extracts only the.messageproperty from each error object, which correctly handles React Hook Form'sFieldErrorstructure. The conditional rendering (fieldState.invalid && !hideErrors) ensures errors only display when appropriate, and FieldError safely handles cases where error may be undefined.src/components/new-form/hooks.ts (1)
9-24: TheExplicitAnytype does not exist in react-hook-form or standard TypeScript.React Hook Form's
useFormContextusesTContext = anyas the default, notExplicitAny. IfExplicitAnyappears in the code on line 11, it must be defined locally in the codebase or represents a typo. Verify whether this is a custom type or ifanyshould be used instead.src/components/new-form/form-controller/context.tsx (1)
1-1: No action needed. The project is using React 19.2.0, which fully supports theuse()hook. The code correctly imports and usesuse(FormControllerContext)to read context values, which is the intended pattern in React 19.Likely an incorrect or invalid review comment.
| @@ -0,0 +1,18 @@ | |||
| import { FormFieldError } from '@/components/form/form-field-error'; | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Incorrect import path: imports from old form module instead of new-form.
FormFieldError is imported from @/components/form/form-field-error (the old form system), but it should be imported from @/components/new-form/form-field-error to maintain module isolation. This contradicts the PR's goal to decouple from the existing form system.
Proposed fix
-import { FormFieldError } from '@/components/form/form-field-error';
+import { FormFieldError } from '@/components/new-form/form-field-error';📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| import { FormFieldError } from '@/components/form/form-field-error'; | |
| import { FormFieldError } from '@/components/new-form/form-field-error'; |
🤖 Prompt for AI Agents
In src/components/new-form/_field-components.ts around line 1, the file imports
FormFieldError from the old form module; update the import to point to the
new-form module to maintain isolation. Change the import path from
'@/components/form/form-field-error' to '@/components/new-form/form-field-error'
and ensure any related exports/types continue to match the new module's API; run
a quick build/type-check to catch any path or type mismatches after updating.
| export type FormControllerContextValue = { | ||
| size?: FormFieldSize; | ||
| errorId?: string; | ||
| labelId?: string; | ||
| descriptionId?: string; | ||
| field: ControllerRenderProps<FieldValues>; | ||
| fieldState: ControllerFieldState; | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Type safety concern: Base FieldValues loses generic type information.
Line 15 uses ControllerRenderProps<FieldValues> without generic parameters, which means field.value will lose specific type information when the context is consumed. This weakens type safety across the form system.
Consider making FormControllerContextValue generic over TFieldValues:
export type FormControllerContextValue<
TFieldValues extends FieldValues = FieldValues
> = {
size?: FormFieldSize;
errorId?: string;
labelId?: string;
descriptionId?: string;
field: ControllerRenderProps<TFieldValues>;
fieldState: ControllerFieldState;
};Then update the context and hook accordingly to preserve type safety throughout the component tree.
🤖 Prompt for AI Agents
In src/components/new-form/form-controller/context.tsx around lines 10 to 17,
the context type uses ControllerRenderProps<FieldValues> which erases the
concrete form value types; make FormControllerContextValue generic: add a type
parameter like TFieldValues extends FieldValues = FieldValues and change the
field property to ControllerRenderProps<TFieldValues>. Then update the context
creation and any exported context hook/type to accept and propagate that same
generic parameter so consumers retain proper typed field.value (update
createContext default/undefined typing, the provider value type, and the
useFormController/useContext hook signatures where applicable).
| const fieldCtx = useMemo( | ||
| () => ({ | ||
| labelId: `${id}-label`, | ||
| errorId: `${id}-error`, | ||
| descriptionId: `${id}-description`, | ||
| field, | ||
| fieldState, | ||
| size, | ||
| }), | ||
| [field, fieldState, id] | ||
| ) as FormControllerContextValue; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing size in useMemo dependency array.
The memoized object includes size (line 65), but it's not listed in the dependency array (line 67). This could cause stale values if size changes.
Proposed fix
const fieldCtx = useMemo(
() => ({
labelId: `${id}-label`,
errorId: `${id}-error`,
descriptionId: `${id}-description`,
field,
fieldState,
size,
}),
- [field, fieldState, id]
+ [field, fieldState, id, size]
) as FormControllerContextValue;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const fieldCtx = useMemo( | |
| () => ({ | |
| labelId: `${id}-label`, | |
| errorId: `${id}-error`, | |
| descriptionId: `${id}-description`, | |
| field, | |
| fieldState, | |
| size, | |
| }), | |
| [field, fieldState, id] | |
| ) as FormControllerContextValue; | |
| const fieldCtx = useMemo( | |
| () => ({ | |
| labelId: `${id}-label`, | |
| errorId: `${id}-error`, | |
| descriptionId: `${id}-description`, | |
| field, | |
| fieldState, | |
| size, | |
| }), | |
| [field, fieldState, id, size] | |
| ) as FormControllerContextValue; |
🧰 Tools
🪛 Biome (2.1.2)
[error] 58-58: This hook is being called from a nested function, but all hooks must be called unconditionally from the top-level component.
For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level
(lint/correctness/useHookAtTopLevel)
🤖 Prompt for AI Agents
In src/components/new-form/form-controller/index.tsx around lines 58 to 68, the
useMemo creates fieldCtx including `size` but the dependency array omits `size`;
update the dependency array to include `size` (i.e., [field, fieldState, id,
size]) so the memo recalculates when size changes, ensuring fieldCtx always
reflects the current size.
6c23cfc to
5336a31
Compare
|


Proposal
This PR introduces a new
FormFieldabstraction built on top of React Hook Form’sController, inspired by [TanStack Form].It replaces the current
FormFieldController(type-switch–based) with a render-props, composition-first API that’s easier to extend and customize.Note
At this stage we’re just aiming to validate the pattern.
a11y helpers,
...otherpassthroughs, and sugar components will follow.Why a new abstraction ?
The current
FormFieldControllerworks, but it becomes costly as our field catalog grows:FormFieldControllerand vice versa, making the module graph brittle.The new
FormFieldfocuses on composition and layering:{ props, state }and a registry of field components.fieldComponentsmap. No core edits.It’s also closer to industry-standard patterns (e.g., shadcn UI Field composables) and aligns with TanStack Form’s ergonomics, which keeps a future switch feasible.
Comparison
Pros & Cons
✅ Pros (Proposal)
Label/Description/Errorpieces).FormFieldController.Summary by CodeRabbit
New Features
Chores
✏️ Tip: You can customize this high-level summary in your review settings.