Skip to content

fix(react-form): address review issues from #2106 (extendForm / composition example)#2115

Open
devCluna wants to merge 7 commits intoTanStack:mainfrom
devCluna:fix/extend-appform-review-issues
Open

fix(react-form): address review issues from #2106 (extendForm / composition example)#2115
devCluna wants to merge 7 commits intoTanStack:mainfrom
devCluna:fix/extend-appform-review-issues

Conversation

@devCluna
Copy link
Copy Markdown

@devCluna devCluna commented Apr 9, 2026

Summary

Fixes the unresolved CodeRabbit review comments left on #2106 (feat(react-form): extend appform), which introduced extendForm() and the composition example project.

Actionable bugs fixed

  • Wrong label on firstName field (examples/react/composition/src/index.tsx): the field was rendering "last name" instead of "First Name".
  • Missing onBlur on TextField input (src/AppForm/FieldComponents/TextField.tsx): field.handleBlur was never wired up, so isTouched and onBlur validators never fired.
  • Async validator returned false instead of undefined when valid (src/index.tsx): short-circuit && expression replaced with an explicit ternary returning undefined.
  • Docs collision example used wrong import casing and a non-existent component (docs/framework/react/guides/form-composition.md): Weyland-Yutan-corp/formsweyland-yutan-corp/forms; CustomSubmit (doesn't exist in parent) → SubmitButton (the real collision).

Nitpicks / improvements

  • extendForm now validates against a reservedNames set (AppField, AppForm, Field, Subscribe, handleSubmit, etc.) and throws a clear error if a key would silently override a core runtime API.
  • Test for doubly-chained extendForm now asserts ThirdField survived the second extension and uses withDoublyExtendedForm instead of the singly-extended withExtendedForm.
  • HTML page title corrected from "Simple Example App" to "Composition Example App".
  • extendForm exported from AppForm.tsx for downstream composition.
  • TextField: TextFieldTextField (object shorthand).

Test plan

  • pnpm test passes in packages/react-form
  • Type-level tests (createFormHook.test-d.tsx) pass including the doubly-extended chain assertion
  • Composition example runs correctly with correct labels and onBlur validation firing

Relates to #2106

Summary by CodeRabbit

  • New Features

    • Added extendForm method enabling extension of form hooks with custom field and form components, with built-in validation to prevent naming conflicts.
    • Added comprehensive React composition example demonstrating form creation and extension patterns.
  • Documentation

    • Enhanced documentation with new sections and code examples for extending custom app forms.

harry-whorlow and others added 7 commits April 2, 2026 15:44
…mposition example

- docs: fix import casing (Weyland-Yutan-corp → weyland-yutan-corp) and use
  SubmitButton (a real parent component) in the collision example instead of
  the non-existent CustomSubmit
- example: correct HTML title from "Simple" to "Composition Example App"
- example: export extendForm from AppForm and use object shorthand for TextField
- example: add missing onBlur handler to TextField so isTouched / onBlur
  validators update correctly
- example: fix firstName label (was "last name") and capitalise both labels
- example: fix async validator to return undefined instead of false when valid
- createFormHook: block component names that collide with core runtime APIs
  (AppField, AppForm, Field, Subscribe, handleSubmit, ...) in extendForm
- test: assert ThirdField survives second extendForm chain and use the
  doubly-extended withForm instead of the singly-extended one
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 9, 2026

📝 Walkthrough

Walkthrough

Introduces an extendForm capability to TanStack Form's hook instance, enabling extension with custom field and form components. Includes documentation updates, a new composition example project, and comprehensive type and runtime tests validating the extension mechanism.

Changes

Cohort / File(s) Summary
Documentation
docs/framework/react/guides/form-composition.md
Updated field name reference in type-safety examples and added new "Extending custom appForm" section demonstrating createFormHook export patterns and component extension via extendForm.
Configuration & Project Setup
examples/react/composition/.eslintrc.cjs, .gitignore, tsconfig.json
New ESLint, gitignore, and TypeScript configurations for the composition example project with React and type-checking enabled.
Project Metadata
examples/react/composition/README.md, package.json
Added project documentation and npm configuration with dev/build scripts, dependencies on @tanstack/react-form, React, and Vite tooling.
Example Application Entrypoint
examples/react/composition/index.html, src/index.tsx
New HTML entrypoint and React application demonstrating a form with synchronized firstName/lastName fields, validators (sync, async-debounced, async), and form submission handling.
Custom Components
examples/react/composition/src/AppForm/AppForm.tsx, src/AppForm/FieldComponents/TextField.tsx, src/AppForm/FormComponents/SubmitButton.tsx
Created form hook context setup, field component with labeled input and validation display, and form component with submit button bound to form submission state.
Core Feature: extendForm
packages/react-form/src/createFormHook.tsx
Added extendForm method to hook instance enabling merging of additional field and form components with runtime validation preventing reserved key conflicts.
Type Tests
packages/react-form/tests/createFormHook.test-d.tsx
Added TypeScript type-level tests validating extendForm availability, component merging in render-prop payloads, chained extension, and rejection of duplicate component names.
Runtime Tests
packages/react-form/tests/createFormHook.test.tsx
Added tests confirming extended components merge with originals, chained extension works, extended hooks integrate with withForm, and extended hooks expose extendForm for further extension.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 Hop along with forms so grand,
Extend your hooks across the land,
With field and form components new,
They merge and blend with what you knew!
Stack them up, validate with care,
Forms that flex beyond compare! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.26% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main purpose of the PR: addressing review issues from a previous PR that introduced the extendForm feature and composition example.
Description check ✅ Passed The description comprehensively covers all changes, including actionable bug fixes with file paths, improvements, and a clear test plan, though the checklist items lack explicit completion marks.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (3)
packages/react-form/tests/createFormHook.test.tsx (1)

703-952: Add negative-path tests for extendForm runtime guards.

Given the new runtime checks in createFormHook.tsx, please also assert throw behavior for reserved keys (and duplicate base keys if added) so guardrails are regression-proof.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-form/tests/createFormHook.test.tsx` around lines 703 - 952,
Add negative-path tests covering the new runtime guards in createFormHook by
asserting that calling extendForm with reserved keys and with keys that
duplicate base hook components throws: write tests that call
createFormHook(...).extendForm({...}) passing a fieldComponents or
formComponents object that includes a reserved key (e.g. keys used by the hook
API like AppField/AppForm or other reserved identifiers your runtime check
rejects) and assert it throws, and another test where extendForm is passed a key
that already exists on the base hook (duplicate base key) and assert that also
throws; use the same test patterns and helpers as the existing extendForm tests
(referencing extendForm, createFormHook, useAppForm, withForm) so the guard
behavior is covered and regression-proof.
examples/react/composition/src/index.tsx (1)

66-68: Optional: avoid non-null assertion on root lookup.

Line 66 can fail hard with a less-informative error if #root is missing; a small explicit guard improves diagnostics.

Suggested tweak
-const rootElement = document.getElementById('root')!
+const rootElement = document.getElementById('root')
+if (!rootElement) {
+  throw new Error('Missing `#root` element')
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/react/composition/src/index.tsx` around lines 66 - 68,
document.getElementById('root') is using a non-null assertion on rootElement
which will throw a less-informative error if the element is missing; change the
logic around the root lookup in index.tsx to explicitly guard the result of
document.getElementById('root') (the rootElement variable) and either throw a
clear Error (e.g., "Root element '#root' not found") or bail out gracefully
before calling createRoot(...).render so createRoot and render are only invoked
when rootElement is non-null.
examples/react/composition/src/AppForm/FieldComponents/TextField.tsx (1)

16-19: Consider associating error text with the input for better a11y.

Line 17 renders validation errors visually, but the input on Line 10 is not explicitly tied to that message (aria-invalid / aria-describedby).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/react/composition/src/AppForm/FieldComponents/TextField.tsx` around
lines 16 - 19, The visual error message rendered from field.state.meta.errors
should be programmatically associated with the input: when
field.state.meta.isTouched && !field.state.meta.isValid render the error element
with a stable id (e.g., `${field.input.name || field.input.id}-error`) and on
the input element (in TextField component where the input is rendered) set
aria-invalid={field.state.meta.isValid === false} and
aria-describedby={field.state.meta.isValid ? undefined : `${field.input.name ||
field.input.id}-error`} so screen readers will announce the error; ensure the
error element contains that id and remains hidden/omitted when there are no
errors.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/framework/react/guides/form-composition.md`:
- Around line 216-223: The primary example shows a TypeScript collision by
reusing SubmitButton; update the main snippet so ProfileForm.extendForm uses a
uniquely named component (e.g., formComponents: { TeamSubmitButton } or another
distinct identifier) and expose useAppForm from that result; then move the
collision example (reusing SubmitButton) into a separate "Gotcha" or "Type
collision" block with an explanatory note. Target the ProfileForm.extendForm
call, the exported useAppForm binding, and the component names CustomTextField
and SubmitButton when making the edits.

In `@examples/react/composition/.gitignore`:
- Around line 14-16: The .gitignore currently excludes pnpm-lock.yaml,
yarn.lock, and package-lock.json which prevents committing lockfiles; update the
.gitignore in examples/react/composition to stop ignoring the lockfile your
example uses (e.g., remove pnpm-lock.yaml if the project uses pnpm, or remove
yarn.lock/package-lock.json for their respective managers) so the appropriate
lockfile is committed for reproducible installs; ensure only irrelevant
lockfiles remain listed (or none) so the correct lockfile (pnpm-lock.yaml or
yarn.lock or package-lock.json) is tracked in Git.

In `@examples/react/composition/README.md`:
- Around line 3-6: Update the "To run this example:" instructions to explicitly
set the working directory before running npm commands: add a step like "cd
examples/react/composition" (or similar wording) before the existing `npm
install` and `npm run dev` lines so users know to change into the example
directory; ensure the "To run this example:" section in README.md includes that
cd step and that the subsequent commands remain in the same ordered list.

In `@packages/react-form/src/createFormHook.tsx`:
- Around line 641-651: The merge in createFormHook currently lets
extension.fieldComponents and extension.formComponents silently overwrite
existing keys at runtime; before creating the merged objects (the
fieldComponents and formComponents passed into createFormHook), iterate the keys
of extension.fieldComponents and extension.formComponents and check for
collisions against the base fieldComponents and formComponents respectively
(using Object.prototype.hasOwnProperty or similar), and if any duplicate key is
found, throw a clear error (or at minimum console.error and throw) naming the
duplicate key and whether it came from fieldComponents or formComponents so JS
consumers cannot accidentally overwrite base components at runtime.

---

Nitpick comments:
In `@examples/react/composition/src/AppForm/FieldComponents/TextField.tsx`:
- Around line 16-19: The visual error message rendered from
field.state.meta.errors should be programmatically associated with the input:
when field.state.meta.isTouched && !field.state.meta.isValid render the error
element with a stable id (e.g., `${field.input.name || field.input.id}-error`)
and on the input element (in TextField component where the input is rendered)
set aria-invalid={field.state.meta.isValid === false} and
aria-describedby={field.state.meta.isValid ? undefined : `${field.input.name ||
field.input.id}-error`} so screen readers will announce the error; ensure the
error element contains that id and remains hidden/omitted when there are no
errors.

In `@examples/react/composition/src/index.tsx`:
- Around line 66-68: document.getElementById('root') is using a non-null
assertion on rootElement which will throw a less-informative error if the
element is missing; change the logic around the root lookup in index.tsx to
explicitly guard the result of document.getElementById('root') (the rootElement
variable) and either throw a clear Error (e.g., "Root element '#root' not
found") or bail out gracefully before calling createRoot(...).render so
createRoot and render are only invoked when rootElement is non-null.

In `@packages/react-form/tests/createFormHook.test.tsx`:
- Around line 703-952: Add negative-path tests covering the new runtime guards
in createFormHook by asserting that calling extendForm with reserved keys and
with keys that duplicate base hook components throws: write tests that call
createFormHook(...).extendForm({...}) passing a fieldComponents or
formComponents object that includes a reserved key (e.g. keys used by the hook
API like AppField/AppForm or other reserved identifiers your runtime check
rejects) and assert it throws, and another test where extendForm is passed a key
that already exists on the base hook (duplicate base key) and assert that also
throws; use the same test patterns and helpers as the existing extendForm tests
(referencing extendForm, createFormHook, useAppForm, withForm) so the guard
behavior is covered and regression-proof.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9864e5dd-df27-41da-8fa0-7d99e0e7d1bd

📥 Commits

Reviewing files that changed from the base of the PR and between 4276176 and 6263287.

⛔ Files ignored due to path filters (2)
  • examples/react/composition/public/emblem-light.svg is excluded by !**/*.svg
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (14)
  • docs/framework/react/guides/form-composition.md
  • examples/react/composition/.eslintrc.cjs
  • examples/react/composition/.gitignore
  • examples/react/composition/README.md
  • examples/react/composition/index.html
  • examples/react/composition/package.json
  • examples/react/composition/src/AppForm/AppForm.tsx
  • examples/react/composition/src/AppForm/FieldComponents/TextField.tsx
  • examples/react/composition/src/AppForm/FormComponents/SubmitButton.tsx
  • examples/react/composition/src/index.tsx
  • examples/react/composition/tsconfig.json
  • packages/react-form/src/createFormHook.tsx
  • packages/react-form/tests/createFormHook.test-d.tsx
  • packages/react-form/tests/createFormHook.test.tsx

Comment on lines +216 to +223
export const { useAppForm } = ProfileForm.extendForm({
fieldComponents: { CustomTextField },
// Ts will error since the parent appForm already has a component called SubmitButton
formComponents: { SubmitButton },
})
```

This way you can add extra fields that are unique to your team without bloating the upstream AppForm.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Primary extension example currently demonstrates a failing snippet.

At Line 219 the sample intentionally triggers a TS collision, but this appears in the main “how to extend” flow and is followed by success-oriented text. Please make the primary snippet valid (unique component name), and move the collision case to a separate “gotcha” block.

Suggested patch
 import ProfileForm from 'weyland-yutan-corp/forms'
 
 import { CustomTextField } from './FieldComponents/CustomTextField'
-import { SubmitButton } from './FormComponents/SubmitButton'
+import { CustomSubmitButton } from './FormComponents/CustomSubmitButton'
 
 export const { useAppForm } = ProfileForm.extendForm({
   fieldComponents: { CustomTextField },
-  // Ts will error since the parent appForm already has a component called SubmitButton
-  formComponents: { SubmitButton },
+  formComponents: { CustomSubmitButton },
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/framework/react/guides/form-composition.md` around lines 216 - 223, The
primary example shows a TypeScript collision by reusing SubmitButton; update the
main snippet so ProfileForm.extendForm uses a uniquely named component (e.g.,
formComponents: { TeamSubmitButton } or another distinct identifier) and expose
useAppForm from that result; then move the collision example (reusing
SubmitButton) into a separate "Gotcha" or "Type collision" block with an
explanatory note. Target the ProfileForm.extendForm call, the exported
useAppForm binding, and the component names CustomTextField and SubmitButton
when making the edits.

Comment on lines +14 to +16
pnpm-lock.yaml
yarn.lock
package-lock.json
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Lockfiles should be committed for reproducible builds.

Ignoring all package manager lockfiles (pnpm-lock.yaml, yarn.lock, package-lock.json) violates modern JavaScript best practices. Lockfiles ensure consistent dependency versions across development environments and deployments. Since this is an example project, it should demonstrate the recommended practice of committing the lockfile for whichever package manager is in use.

📦 Recommended fix

Keep only the lockfiles you don't use:

-pnpm-lock.yaml
-yarn.lock
-package-lock.json
+# Commit the lockfile for your package manager
+# If using pnpm, ignore these:
+yarn.lock
+package-lock.json
+
+# If using yarn, ignore these:
+# pnpm-lock.yaml
+# package-lock.json
+
+# If using npm, ignore these:
+# pnpm-lock.yaml
+# yarn.lock
📝 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.

Suggested change
pnpm-lock.yaml
yarn.lock
package-lock.json
# Commit the lockfile for your package manager
# If using pnpm, ignore these:
yarn.lock
package-lock.json
# If using yarn, ignore these:
# pnpm-lock.yaml
# package-lock.json
# If using npm, ignore these:
# pnpm-lock.yaml
# yarn.lock
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/react/composition/.gitignore` around lines 14 - 16, The .gitignore
currently excludes pnpm-lock.yaml, yarn.lock, and package-lock.json which
prevents committing lockfiles; update the .gitignore in
examples/react/composition to stop ignoring the lockfile your example uses
(e.g., remove pnpm-lock.yaml if the project uses pnpm, or remove
yarn.lock/package-lock.json for their respective managers) so the appropriate
lockfile is committed for reproducible installs; ensure only irrelevant
lockfiles remain listed (or none) so the correct lockfile (pnpm-lock.yaml or
yarn.lock or package-lock.json) is tracked in Git.

Comment on lines +3 to +6
To run this example:

- `npm install`
- `npm run dev`
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Specify the working directory before run commands.

The commands are ambiguous as written. Add an explicit cd examples/react/composition step to avoid running them from repo root.

Suggested patch
 To run this example:
 
+- `cd examples/react/composition`
 - `npm install`
 - `npm run dev`
📝 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.

Suggested change
To run this example:
- `npm install`
- `npm run dev`
To run this example:
- `cd examples/react/composition`
- `npm install`
- `npm run dev`
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/react/composition/README.md` around lines 3 - 6, Update the "To run
this example:" instructions to explicitly set the working directory before
running npm commands: add a step like "cd examples/react/composition" (or
similar wording) before the existing `npm install` and `npm run dev` lines so
users know to change into the example directory; ensure the "To run this
example:" section in README.md includes that cd step and that the subsequent
commands remain in the same ordered list.

Comment on lines +641 to +651
return createFormHook({
fieldContext,
formContext,
fieldComponents: {
...fieldComponents,
...extension.fieldComponents,
} as TComponents & TNewField,
formComponents: {
...formComponents,
...extension.formComponents,
} as TFormComponents & TNewForm,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent silent overwrite of base components in runtime (extendForm).

At Line 645 and Line 649, extension keys can still overwrite base keys at runtime. Type checks catch this in TS, but JS consumers can bypass it and get silent behavior changes. Add explicit duplicate-key runtime checks against fieldComponents / formComponents before merging.

Suggested patch
+    const duplicateField = Object.keys(extension.fieldComponents ?? {}).find(
+      (k) => k in fieldComponents,
+    )
+    if (duplicateField) {
+      throw new Error(
+        `extendForm: "${duplicateField}" already exists in base field components.`,
+      )
+    }
+
+    const duplicateForm = Object.keys(extension.formComponents ?? {}).find(
+      (k) => k in formComponents,
+    )
+    if (duplicateForm) {
+      throw new Error(
+        `extendForm: "${duplicateForm}" already exists in base form components.`,
+      )
+    }
+
     return createFormHook({
       fieldContext,
       formContext,
📝 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.

Suggested change
return createFormHook({
fieldContext,
formContext,
fieldComponents: {
...fieldComponents,
...extension.fieldComponents,
} as TComponents & TNewField,
formComponents: {
...formComponents,
...extension.formComponents,
} as TFormComponents & TNewForm,
const duplicateField = Object.keys(extension.fieldComponents ?? {}).find(
(k) => k in fieldComponents,
)
if (duplicateField) {
throw new Error(
`extendForm: "${duplicateField}" already exists in base field components.`,
)
}
const duplicateForm = Object.keys(extension.formComponents ?? {}).find(
(k) => k in formComponents,
)
if (duplicateForm) {
throw new Error(
`extendForm: "${duplicateForm}" already exists in base form components.`,
)
}
return createFormHook({
fieldContext,
formContext,
fieldComponents: {
...fieldComponents,
...extension.fieldComponents,
} as TComponents & TNewField,
formComponents: {
...formComponents,
...extension.formComponents,
} as TFormComponents & TNewForm,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-form/src/createFormHook.tsx` around lines 641 - 651, The merge
in createFormHook currently lets extension.fieldComponents and
extension.formComponents silently overwrite existing keys at runtime; before
creating the merged objects (the fieldComponents and formComponents passed into
createFormHook), iterate the keys of extension.fieldComponents and
extension.formComponents and check for collisions against the base
fieldComponents and formComponents respectively (using
Object.prototype.hasOwnProperty or similar), and if any duplicate key is found,
throw a clear error (or at minimum console.error and throw) naming the duplicate
key and whether it came from fieldComponents or formComponents so JS consumers
cannot accidentally overwrite base components at runtime.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants