From 7519997356c343ab983fcb70f633e2132e0b0295 Mon Sep 17 00:00:00 2001 From: Harry Whorlow Date: Thu, 2 Apr 2026 15:43:02 +0200 Subject: [PATCH 1/8] feat(react-from): extend appform --- examples/react/composition/.eslintrc.cjs | 11 +++ examples/react/composition/.gitignore | 27 +++++++ examples/react/composition/README.md | 6 ++ examples/react/composition/index.html | 16 ++++ examples/react/composition/package.json | 36 +++++++++ .../react/composition/public/emblem-light.svg | 13 ++++ .../react/composition/src/AppForm/AppForm.tsx | 23 ++++++ .../src/AppForm/FieldComponents/TextField.tsx | 22 ++++++ .../AppForm/FormComponents/SubmitButton.tsx | 14 ++++ .../src/ExtendedAppForm/ExtendedAppForm.tsx | 15 ++++ .../FieldComponents/CustomTextField.tsx | 22 ++++++ examples/react/composition/src/index.tsx | 77 +++++++++++++++++++ examples/react/composition/tsconfig.json | 23 ++++++ packages/react-form/src/createFormHook.tsx | 37 +++++++++ 14 files changed, 342 insertions(+) create mode 100644 examples/react/composition/.eslintrc.cjs create mode 100644 examples/react/composition/.gitignore create mode 100644 examples/react/composition/README.md create mode 100644 examples/react/composition/index.html create mode 100644 examples/react/composition/package.json create mode 100644 examples/react/composition/public/emblem-light.svg create mode 100644 examples/react/composition/src/AppForm/AppForm.tsx create mode 100644 examples/react/composition/src/AppForm/FieldComponents/TextField.tsx create mode 100644 examples/react/composition/src/AppForm/FormComponents/SubmitButton.tsx create mode 100644 examples/react/composition/src/ExtendedAppForm/ExtendedAppForm.tsx create mode 100644 examples/react/composition/src/ExtendedAppForm/FieldComponents/CustomTextField.tsx create mode 100644 examples/react/composition/src/index.tsx create mode 100644 examples/react/composition/tsconfig.json diff --git a/examples/react/composition/.eslintrc.cjs b/examples/react/composition/.eslintrc.cjs new file mode 100644 index 000000000..35853b617 --- /dev/null +++ b/examples/react/composition/.eslintrc.cjs @@ -0,0 +1,11 @@ +// @ts-check + +/** @type {import('eslint').Linter.Config} */ +const config = { + extends: ['plugin:react/recommended', 'plugin:react-hooks/recommended'], + rules: { + 'react/no-children-prop': 'off', + }, +} + +module.exports = config diff --git a/examples/react/composition/.gitignore b/examples/react/composition/.gitignore new file mode 100644 index 000000000..4673b022e --- /dev/null +++ b/examples/react/composition/.gitignore @@ -0,0 +1,27 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +pnpm-lock.yaml +yarn.lock +package-lock.json + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/examples/react/composition/README.md b/examples/react/composition/README.md new file mode 100644 index 000000000..1cf889265 --- /dev/null +++ b/examples/react/composition/README.md @@ -0,0 +1,6 @@ +# Example + +To run this example: + +- `npm install` +- `npm run dev` diff --git a/examples/react/composition/index.html b/examples/react/composition/index.html new file mode 100644 index 000000000..5d0e76cd4 --- /dev/null +++ b/examples/react/composition/index.html @@ -0,0 +1,16 @@ + + + + + + + + + TanStack Form React Simple Example App + + + +
+ + + diff --git a/examples/react/composition/package.json b/examples/react/composition/package.json new file mode 100644 index 000000000..592e59dcf --- /dev/null +++ b/examples/react/composition/package.json @@ -0,0 +1,36 @@ +{ + "name": "@tanstack/form-example-react-composition", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port=3001", + "build": "vite build", + "preview": "vite preview", + "test:types": "tsc" + }, + "dependencies": { + "@tanstack/react-form": "^1.28.6", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@tanstack/react-devtools": "^0.9.7", + "@tanstack/react-form-devtools": "^0.2.20", + "@types/react": "^19.0.7", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^5.1.1", + "vite": "^7.2.2" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/examples/react/composition/public/emblem-light.svg b/examples/react/composition/public/emblem-light.svg new file mode 100644 index 000000000..a58e69ad5 --- /dev/null +++ b/examples/react/composition/public/emblem-light.svg @@ -0,0 +1,13 @@ + + + + emblem-light + Created with Sketch. + + + + + + + + \ No newline at end of file diff --git a/examples/react/composition/src/AppForm/AppForm.tsx b/examples/react/composition/src/AppForm/AppForm.tsx new file mode 100644 index 000000000..76d687c98 --- /dev/null +++ b/examples/react/composition/src/AppForm/AppForm.tsx @@ -0,0 +1,23 @@ +import { createFormHook, createFormHookContexts } from '@tanstack/react-form' + +// +// fields +// +import { TextField } from './FieldComponents/TextField' + +// +// components +// +import { SubmitButton } from './FormComponents/SubmitButton' + +export const { fieldContext, formContext, useFieldContext, useFormContext } = + createFormHookContexts() + +const appForm = createFormHook({ + fieldContext, + formContext, + fieldComponents: { TextField: TextField }, + formComponents: { SubmitButton }, +}) + +export default appForm diff --git a/examples/react/composition/src/AppForm/FieldComponents/TextField.tsx b/examples/react/composition/src/AppForm/FieldComponents/TextField.tsx new file mode 100644 index 000000000..2248d1a22 --- /dev/null +++ b/examples/react/composition/src/AppForm/FieldComponents/TextField.tsx @@ -0,0 +1,22 @@ +import { useFieldContext } from '../AppForm' + +export function TextField({ label }: { label: string }) { + // The `Field` infers that it should have a `value` type of `string` + const field = useFieldContext() + return ( + + ) +} diff --git a/examples/react/composition/src/AppForm/FormComponents/SubmitButton.tsx b/examples/react/composition/src/AppForm/FormComponents/SubmitButton.tsx new file mode 100644 index 000000000..a21e2f324 --- /dev/null +++ b/examples/react/composition/src/AppForm/FormComponents/SubmitButton.tsx @@ -0,0 +1,14 @@ +import { useFormContext } from '../AppForm' + +export function SubmitButton({ label }: { label: string }) { + const form = useFormContext() + return ( + state.isSubmitting}> + {(isSubmitting) => ( + + )} + + ) +} diff --git a/examples/react/composition/src/ExtendedAppForm/ExtendedAppForm.tsx b/examples/react/composition/src/ExtendedAppForm/ExtendedAppForm.tsx new file mode 100644 index 000000000..62f7f3577 --- /dev/null +++ b/examples/react/composition/src/ExtendedAppForm/ExtendedAppForm.tsx @@ -0,0 +1,15 @@ +import { extendForm } from '@tanstack/react-form' +import appForm from '../AppForm/AppForm' + +// +// fields +// +import { CustomTextField } from './FieldComponents/CustomTextField' + +const extendedAppForm = extendForm(appForm, { + // field name must be unique + fieldComponents: { CustomTextField }, +}) + +export const { useAppForm } = extendedAppForm +export default extendedAppForm diff --git a/examples/react/composition/src/ExtendedAppForm/FieldComponents/CustomTextField.tsx b/examples/react/composition/src/ExtendedAppForm/FieldComponents/CustomTextField.tsx new file mode 100644 index 000000000..137de8c2b --- /dev/null +++ b/examples/react/composition/src/ExtendedAppForm/FieldComponents/CustomTextField.tsx @@ -0,0 +1,22 @@ +import { useFieldContext } from '../../AppForm/AppForm' + +export function CustomTextField({ label }: { label: string }) { + // The `Field` infers that it should have a `value` type of `string` + const field = useFieldContext() + return ( + + ) +} diff --git a/examples/react/composition/src/index.tsx b/examples/react/composition/src/index.tsx new file mode 100644 index 000000000..1ae691223 --- /dev/null +++ b/examples/react/composition/src/index.tsx @@ -0,0 +1,77 @@ +import * as React from 'react' +import { createRoot } from 'react-dom/client' + +import { TanStackDevtools } from '@tanstack/react-devtools' +import { formDevtoolsPlugin } from '@tanstack/react-form-devtools' + +import { useAppForm } from './ExtendedAppForm/ExtendedAppForm' + +export default function App() { + const form = useAppForm({ + defaultValues: { + firstName: '', + lastName: '', + }, + onSubmit: async ({ value }) => { + // Do something with form data + console.log(value) + }, + }) + + return ( +
+

Simple Form Example

+ +
{ + e.preventDefault() + e.stopPropagation() + form.handleSubmit() + }} + > + {/* A type-safe field component*/} + + !value + ? 'A first name is required' + : value.length < 3 + ? 'First name must be at least 3 characters' + : undefined, + onChangeAsyncDebounceMs: 500, + onChangeAsync: async ({ value }) => { + await new Promise((resolve) => setTimeout(resolve, 1000)) + return ( + value.includes('error') && 'No "error" allowed in first name' + ) + }, + }} + > + {(f) => } + + + + {(f) => } + + + + + +
+
+ ) +} + +const rootElement = document.getElementById('root')! + +createRoot(rootElement).render( + + + + + , +) diff --git a/examples/react/composition/tsconfig.json b/examples/react/composition/tsconfig.json new file mode 100644 index 000000000..22b43163b --- /dev/null +++ b/examples/react/composition/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/packages/react-form/src/createFormHook.tsx b/packages/react-form/src/createFormHook.tsx index 2514f057f..6336c5ce9 100644 --- a/packages/react-form/src/createFormHook.tsx +++ b/packages/react-form/src/createFormHook.tsx @@ -601,5 +601,42 @@ export function createFormHook< withForm, withFieldGroup, useTypedAppFormContext, + /** @internal used by `extendForm` */ + _internals: { + fieldContext, + formContext, + fieldComponents, + formComponents, + }, } } + +export function extendForm< + const TExistingField extends Record>, + const TExistingForm extends Record>, + const TNewField extends Record> & { + [K in keyof TExistingField]?: 'Error: field component names must be unique — this key already exists in the base form' + }, + const TNewForm extends Record> & { + [K in keyof TExistingForm]?: 'Error: form component names must be unique — this key already exists in the base form' + }, +>( + base: { _internals: CreateFormHookProps }, + extension: { + fieldComponents?: TNewField + formComponents?: TNewForm + }, +) { + return createFormHook({ + fieldContext: base._internals.fieldContext, + formContext: base._internals.formContext, + fieldComponents: { + ...base._internals.fieldComponents, + ...extension.fieldComponents, + }, + formComponents: { + ...base._internals.formComponents, + ...extension.formComponents, + }, + }) +} From e79b4f274679944b779746926e42e1b546840c95 Mon Sep 17 00:00:00 2001 From: Harry Whorlow Date: Thu, 2 Apr 2026 18:10:02 +0200 Subject: [PATCH 2/8] chore: update to api --- .../src/ExtendedAppForm/ExtendedAppForm.tsx | 3 +- packages/react-form/src/createFormHook.tsx | 63 ++++++++----------- 2 files changed, 27 insertions(+), 39 deletions(-) diff --git a/examples/react/composition/src/ExtendedAppForm/ExtendedAppForm.tsx b/examples/react/composition/src/ExtendedAppForm/ExtendedAppForm.tsx index 62f7f3577..afbcc39b4 100644 --- a/examples/react/composition/src/ExtendedAppForm/ExtendedAppForm.tsx +++ b/examples/react/composition/src/ExtendedAppForm/ExtendedAppForm.tsx @@ -1,4 +1,3 @@ -import { extendForm } from '@tanstack/react-form' import appForm from '../AppForm/AppForm' // @@ -6,7 +5,7 @@ import appForm from '../AppForm/AppForm' // import { CustomTextField } from './FieldComponents/CustomTextField' -const extendedAppForm = extendForm(appForm, { +const extendedAppForm = appForm.extendForm({ // field name must be unique fieldComponents: { CustomTextField }, }) diff --git a/packages/react-form/src/createFormHook.tsx b/packages/react-form/src/createFormHook.tsx index 6336c5ce9..434c1d46b 100644 --- a/packages/react-form/src/createFormHook.tsx +++ b/packages/react-form/src/createFormHook.tsx @@ -596,47 +596,36 @@ export function createFormHook< return form as never } + function extendForm< + const TNewField extends Record> & { + [K in keyof TComponents]?: 'Error: field component names must be unique — this key already exists in the base form' + }, + const TNewForm extends Record> & { + [K in keyof TFormComponents]?: 'Error: form component names must be unique — this key already exists in the base form' + }, + >(extension: { + fieldComponents?: TNewField + formComponents?: TNewForm + }) { + return createFormHook({ + fieldContext, + formContext, + fieldComponents: { + ...fieldComponents, + ...extension.fieldComponents, + } as TComponents & TNewField, + formComponents: { + ...formComponents, + ...extension.formComponents, + } as TFormComponents & TNewForm, + }) + } + return { useAppForm, withForm, withFieldGroup, useTypedAppFormContext, - /** @internal used by `extendForm` */ - _internals: { - fieldContext, - formContext, - fieldComponents, - formComponents, - }, + extendForm, } } - -export function extendForm< - const TExistingField extends Record>, - const TExistingForm extends Record>, - const TNewField extends Record> & { - [K in keyof TExistingField]?: 'Error: field component names must be unique — this key already exists in the base form' - }, - const TNewForm extends Record> & { - [K in keyof TExistingForm]?: 'Error: form component names must be unique — this key already exists in the base form' - }, ->( - base: { _internals: CreateFormHookProps }, - extension: { - fieldComponents?: TNewField - formComponents?: TNewForm - }, -) { - return createFormHook({ - fieldContext: base._internals.fieldContext, - formContext: base._internals.formContext, - fieldComponents: { - ...base._internals.fieldComponents, - ...extension.fieldComponents, - }, - formComponents: { - ...base._internals.formComponents, - ...extension.formComponents, - }, - }) -} From 4a49d2e46953bca986bb602b7d3f24d8dc485ab7 Mon Sep 17 00:00:00 2001 From: Harry Whorlow Date: Fri, 3 Apr 2026 00:14:51 +0200 Subject: [PATCH 3/8] docs: form composition update --- .../react/guides/form-composition.md | 64 ++++++++++++++++++- .../react/composition/src/AppForm/AppForm.tsx | 4 +- .../src/ExtendedAppForm/ExtendedAppForm.tsx | 14 ---- .../FieldComponents/CustomTextField.tsx | 22 ------- examples/react/composition/src/index.tsx | 2 +- 5 files changed, 66 insertions(+), 40 deletions(-) delete mode 100644 examples/react/composition/src/ExtendedAppForm/ExtendedAppForm.tsx delete mode 100644 examples/react/composition/src/ExtendedAppForm/FieldComponents/CustomTextField.tsx diff --git a/docs/framework/react/guides/form-composition.md b/docs/framework/react/guides/form-composition.md index 597947934..dff8a04f9 100644 --- a/docs/framework/react/guides/form-composition.md +++ b/docs/framework/react/guides/form-composition.md @@ -103,7 +103,7 @@ function App() { } ``` -This not only allows you to reuse the UI of your shared component, but retains the type-safety you'd expect from TanStack Form: Mistyping `name` will result in a TypeScript error. +This not only allows you to reuse the UI of your shared component, but retains the type-safety you'd expect from TanStack Form: Mistyping `firstName` will result in a TypeScript error. #### A note on performance @@ -160,6 +160,68 @@ function App() { } ``` +### Extending custom appForm + +It is quite common for platform teams to ship pre built appForms. It can be exported from a library in a monorepo or as a standalone package on npm. + +```tsx weyland-yutan-corp/forms.tsx +import { createFormHook, createFormHookContexts } from '@tanstack/react-form' + +// fields +import { UserIdField } from './FieldComponents/UserIdField' + +// components +import { SubmitButton } from './FormComponents/SubmitButton' + +export const { fieldContext, formContext, useFieldContext, useFormContext } = + createFormHookContexts() + +const ProfileForm = createFormHook({ + fieldContext, + formContext, + fieldComponents: { UserIdField }, + formComponents: { SubmitButton }, +}) + +export default ProfileForm +``` + +There is a situation that you might have a field exclusive to a downstream dev team, in such a case you can extend the AppForm like so. + +1 - Create new AppForm fields + +```tsx AppForm.tsx +// imported from the same AppForm you want to extend +import { useFieldContext } from 'weyland-yutan-corp/forms' + +export function CustomTextField({ label }: { label: string }) { + const field = useFieldContext() + return ( +
+ +
+ ) +} +``` + +2 - Extend the AppForm + +```tsx AppForm.tsx +// notice the same import as above +import ProfileForm from 'Weyland-Yutan-corp/forms' + +import { CustomTextField } from './FieldComponents/CustomTextField' +import { CustomSubmit } from './FormComponents/CustomSubmit' + +export const { useAppForm } = ProfileForm.extendForm({ + fieldComponents: { CustomTextField }, + // Ts will error since the parent appForm already has a component called CustomSubmit + formComponents: { CustomSubmit }, +}) +``` + +This way you can add extra fields that are unique to your team without bloating the upstream AppForm. + ## Breaking big forms into smaller pieces Sometimes forms get very large; it's just how it goes sometimes. While TanStack Form supports large forms well, it's never fun to work with hundreds or thousands of lines of code in single files. diff --git a/examples/react/composition/src/AppForm/AppForm.tsx b/examples/react/composition/src/AppForm/AppForm.tsx index 76d687c98..4e37aa657 100644 --- a/examples/react/composition/src/AppForm/AppForm.tsx +++ b/examples/react/composition/src/AppForm/AppForm.tsx @@ -13,11 +13,11 @@ import { SubmitButton } from './FormComponents/SubmitButton' export const { fieldContext, formContext, useFieldContext, useFormContext } = createFormHookContexts() -const appForm = createFormHook({ +const { useAppForm } = createFormHook({ fieldContext, formContext, fieldComponents: { TextField: TextField }, formComponents: { SubmitButton }, }) -export default appForm +export default useAppForm diff --git a/examples/react/composition/src/ExtendedAppForm/ExtendedAppForm.tsx b/examples/react/composition/src/ExtendedAppForm/ExtendedAppForm.tsx deleted file mode 100644 index afbcc39b4..000000000 --- a/examples/react/composition/src/ExtendedAppForm/ExtendedAppForm.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import appForm from '../AppForm/AppForm' - -// -// fields -// -import { CustomTextField } from './FieldComponents/CustomTextField' - -const extendedAppForm = appForm.extendForm({ - // field name must be unique - fieldComponents: { CustomTextField }, -}) - -export const { useAppForm } = extendedAppForm -export default extendedAppForm diff --git a/examples/react/composition/src/ExtendedAppForm/FieldComponents/CustomTextField.tsx b/examples/react/composition/src/ExtendedAppForm/FieldComponents/CustomTextField.tsx deleted file mode 100644 index 137de8c2b..000000000 --- a/examples/react/composition/src/ExtendedAppForm/FieldComponents/CustomTextField.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { useFieldContext } from '../../AppForm/AppForm' - -export function CustomTextField({ label }: { label: string }) { - // The `Field` infers that it should have a `value` type of `string` - const field = useFieldContext() - return ( - - ) -} diff --git a/examples/react/composition/src/index.tsx b/examples/react/composition/src/index.tsx index 1ae691223..797a577a3 100644 --- a/examples/react/composition/src/index.tsx +++ b/examples/react/composition/src/index.tsx @@ -4,7 +4,7 @@ import { createRoot } from 'react-dom/client' import { TanStackDevtools } from '@tanstack/react-devtools' import { formDevtoolsPlugin } from '@tanstack/react-form-devtools' -import { useAppForm } from './ExtendedAppForm/ExtendedAppForm' +import useAppForm from './AppForm/AppForm' export default function App() { const form = useAppForm({ From 94d3397d6d4c1b2065b511e863c9d5a0f8acd51d Mon Sep 17 00:00:00 2001 From: Harry Whorlow Date: Fri, 3 Apr 2026 00:41:47 +0200 Subject: [PATCH 4/8] chore: test --- .../tests/createFormHook.test-d.tsx | 127 +++++++++ .../react-form/tests/createFormHook.test.tsx | 251 ++++++++++++++++++ 2 files changed, 378 insertions(+) diff --git a/packages/react-form/tests/createFormHook.test-d.tsx b/packages/react-form/tests/createFormHook.test-d.tsx index b8a9b3971..c44e9fa8c 100644 --- a/packages/react-form/tests/createFormHook.test-d.tsx +++ b/packages/react-form/tests/createFormHook.test-d.tsx @@ -893,4 +893,131 @@ describe('createFormHook', () => { props: null, render: () => <>, }) + + describe('extendForm', () => { + function ExtendedField() { + return null + } + function ExtendedFormComp() { + return null + } + + const baseHook = createFormHook({ + fieldComponents: { Test }, + formComponents: { Test }, + fieldContext, + formContext, + }) + + const { + withForm: withExtendedForm, + withFieldGroup: withExtendedFieldGroup, + extendForm, + } = baseHook.extendForm({ + fieldComponents: { ExtendedField }, + formComponents: { ExtendedFormComp }, + }) + + it('should expose extendForm on the returned hook', () => { + expectTypeOf(extendForm).toBeFunction() + }) + + it('should include parent field components in the extended AppField render prop', () => { + withExtendedForm({ + defaultValues: { name: '' }, + render: ({ form }) => { + expectTypeOf(form.AppField).toBeFunction() + + return ( + + {(field) => { + expectTypeOf(field.Test).toBeFunction() + expectTypeOf(field.ExtendedField).toBeFunction() + return null + }} + + ) + }, + }) + }) + + it('should include parent form components on the extended form', () => { + withExtendedForm({ + defaultValues: { name: '' }, + render: ({ form }) => { + expectTypeOf(form.Test).toBeFunction() + expectTypeOf(form.ExtendedFormComp).toBeFunction() + return null + }, + }) + }) + + it('should include parent and extended components in withFieldGroup', () => { + withExtendedFieldGroup({ + defaultValues: { name: '' }, + render: ({ group }) => { + expectTypeOf(group.Test).toBeFunction() + expectTypeOf(group.ExtendedFormComp).toBeFunction() + + return ( + + {(field) => { + expectTypeOf(field.Test).toBeFunction() + expectTypeOf(field.ExtendedField).toBeFunction() + return null + }} + + ) + }, + }) + }) + + it('should error when a duplicate field component name is used in extendForm', () => { + baseHook.extendForm({ + fieldComponents: { + // @ts-expect-error 'Test' already exists in the base form + Test: ExtendedField, + }, + }) + }) + + it('should error when a duplicate form component name is used in extendForm', () => { + baseHook.extendForm({ + formComponents: { + // @ts-expect-error 'Test' already exists in the base form + Test: ExtendedFormComp, + }, + }) + }) + + it('should allow further extension of an already extended hook', () => { + function ThirdField() { + return null + } + + const { useAppForm: useDoublyExtended } = baseHook + .extendForm({ fieldComponents: { ExtendedField } }) + .extendForm({ fieldComponents: { ThirdField } }) + + withExtendedForm({ + defaultValues: { name: '' }, + render: ({ form }) => { + return ( + + {(field) => { + expectTypeOf(field.Test).toBeFunction() + expectTypeOf(field.ExtendedField).toBeFunction() + return null + }} + + ) + }, + }) + + const doublyExtendedForm = useDoublyExtended({ + defaultValues: { name: '' }, + }) + expectTypeOf(doublyExtendedForm.AppField).toBeFunction() + }) + }) }) diff --git a/packages/react-form/tests/createFormHook.test.tsx b/packages/react-form/tests/createFormHook.test.tsx index 273746d0b..c93d0d725 100644 --- a/packages/react-form/tests/createFormHook.test.tsx +++ b/packages/react-form/tests/createFormHook.test.tsx @@ -700,6 +700,257 @@ describe('createFormHook', () => { expect(button).toBeInTheDocument() }) + describe('extendForm', () => { + it('should include both parent and extended field components', () => { + function ExtendedTextField({ label }: { label: string }) { + const field = useFieldContext() + return ( + + ) + } + + const { useAppForm: useExtendedForm } = createFormHook({ + fieldComponents: { TextField }, + formComponents: { SubscribeButton }, + fieldContext, + formContext, + }).extendForm({ + fieldComponents: { ExtendedTextField }, + }) + + function Comp() { + const form = useExtendedForm({ + defaultValues: { firstName: 'John', lastName: 'Doe' }, + }) + + return ( + <> + } + /> + ( + + )} + /> + + ) + } + + const { getByLabelText, getByTestId } = render() + expect(getByLabelText('First Name')).toHaveValue('John') + expect(getByTestId('extended-input')).toHaveValue('Doe') + }) + + it('should include both parent and extended form components', () => { + function ExtendedSubmit({ label }: { label: string }) { + const form = useFormContext() + return ( + state.isSubmitting}> + {(isSubmitting) => ( + + )} + + ) + } + + const { useAppForm: useExtendedForm } = createFormHook({ + fieldComponents: { TextField }, + formComponents: { SubscribeButton }, + fieldContext, + formContext, + }).extendForm({ + formComponents: { ExtendedSubmit }, + }) + + function Comp() { + const form = useExtendedForm({ + defaultValues: { firstName: 'John' }, + }) + + return ( + + + + + ) + } + + const { getByText, getByTestId } = render() + expect(getByText('Submit')).toBeInTheDocument() + expect(getByTestId('extended-submit')).toHaveTextContent( + 'Extended Submit', + ) + }) + + it('should support chaining multiple extendForm calls', () => { + function FieldA({ label }: { label: string }) { + const field = useFieldContext() + return ( + + ) + } + + function FieldB({ label }: { label: string }) { + const field = useFieldContext() + return ( + + ) + } + + const base = createFormHook({ + fieldComponents: { TextField }, + formComponents: {}, + fieldContext, + formContext, + }) + + const { useAppForm: useChainedForm } = base + .extendForm({ fieldComponents: { FieldA } }) + .extendForm({ fieldComponents: { FieldB } }) + + function Comp() { + const form = useChainedForm({ + defaultValues: { a: 'valueA', b: 'valueB', c: 'valueC' }, + }) + + return ( + <> + } + /> + } + /> + } + /> + + ) + } + + const { getByLabelText } = render() + expect(getByLabelText('A')).toHaveValue('valueA') + expect(getByLabelText('B')).toHaveValue('valueB') + expect(getByLabelText('C')).toHaveValue('valueC') + }) + + it('should work with withForm after extendForm', () => { + function ExtendedTextField({ label }: { label: string }) { + const field = useFieldContext() + return ( + + ) + } + + const { useAppForm: useExtendedForm, withForm: withExtendedForm } = + createFormHook({ + fieldComponents: { TextField }, + formComponents: { SubscribeButton }, + fieldContext, + formContext, + }).extendForm({ + fieldComponents: { ExtendedTextField }, + }) + + const ChildForm = withExtendedForm({ + defaultValues: { firstName: 'Jane', lastName: 'Smith' }, + render: function Render({ form }) { + return ( + <> + } + /> + ( + + )} + /> + + ) + }, + }) + + function Parent() { + const form = useExtendedForm({ + defaultValues: { firstName: 'Jane', lastName: 'Smith' }, + }) + return + } + + const { getByLabelText } = render() + expect(getByLabelText('First Name')).toHaveValue('Jane') + expect(getByLabelText('Last Name')).toHaveValue('Smith') + }) + + it('should return a new extendForm from the extended hook', () => { + function ExtendedTextField({ label }: { label: string }) { + const field = useFieldContext() + return ( + + ) + } + + const base = createFormHook({ + fieldComponents: { TextField }, + formComponents: { SubscribeButton }, + fieldContext, + formContext, + }) + + const extended = base.extendForm({ + fieldComponents: { ExtendedTextField }, + }) + + // extendForm should itself expose extendForm + expect(typeof extended.extendForm).toBe('function') + }) + }) + it('should render FieldGroup Subscribe without selector (default identity)', async () => { const formOpts = formOptions({ defaultValues: { From 6323166d2d3d5d9e287b2d3f6f34f2c6f3d237d4 Mon Sep 17 00:00:00 2001 From: Harry Whorlow Date: Fri, 3 Apr 2026 00:43:34 +0200 Subject: [PATCH 5/8] fix: lock file --- pnpm-lock.yaml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63400797f..91fdce04f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -448,6 +448,37 @@ importers: specifier: ^7.2.2 version: 7.2.2(@types/node@24.1.0)(jiti@2.6.1)(less@4.4.0)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.43.1)(tsx@4.19.4)(yaml@2.8.1) + examples/react/composition: + dependencies: + '@tanstack/react-form': + specifier: ^1.28.6 + version: link:../../../packages/react-form + react: + specifier: ^19.0.0 + version: 19.1.0 + react-dom: + specifier: ^19.0.0 + version: 19.1.0(react@19.1.0) + devDependencies: + '@tanstack/react-devtools': + specifier: ^0.9.7 + version: 0.9.10(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(csstype@3.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.11) + '@tanstack/react-form-devtools': + specifier: ^0.2.20 + version: link:../../../packages/react-form-devtools + '@types/react': + specifier: ^19.0.7 + version: 19.1.6 + '@types/react-dom': + specifier: ^19.0.3 + version: 19.1.5(@types/react@19.1.6) + '@vitejs/plugin-react': + specifier: ^5.1.1 + version: 5.1.1(vite@7.2.2(@types/node@24.1.0)(jiti@2.6.1)(less@4.4.0)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.43.1)(tsx@4.19.4)(yaml@2.8.1)) + vite: + specifier: ^7.2.2 + version: 7.2.2(@types/node@24.1.0)(jiti@2.6.1)(less@4.4.0)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.43.1)(tsx@4.19.4)(yaml@2.8.1) + examples/react/devtools: dependencies: '@tanstack/react-form': From 02b63cc5f31086bfba8c4be0b6c25f78a8ce3136 Mon Sep 17 00:00:00 2001 From: Harry Whorlow Date: Thu, 9 Apr 2026 23:11:54 +0200 Subject: [PATCH 6/8] chore: pr comments --- .../react/guides/form-composition.md | 31 +++++++++++++------ .../src/AppForm/FieldComponents/TextField.tsx | 1 + examples/react/composition/src/index.tsx | 2 +- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/docs/framework/react/guides/form-composition.md b/docs/framework/react/guides/form-composition.md index dff8a04f9..2cc825799 100644 --- a/docs/framework/react/guides/form-composition.md +++ b/docs/framework/react/guides/form-composition.md @@ -15,12 +15,17 @@ At it's most basic, `createFormHook` is a function that takes a `fieldContext` a > This un-customized `useAppForm` hook is identical to `useForm`, but that will quickly change as we add more options to `createFormHook`. -```tsx -import { createFormHookContexts, createFormHook } from '@tanstack/react-form' +```tsx AppFormContext.tsx +import { createFormHookContexts } from '@tanstack/react-form' // export useFieldContext for use in your custom components export const { fieldContext, formContext, useFieldContext } = createFormHookContexts() +``` + +```tsx AppForm.tsx +import { createFormHook } from '@tanstack/react-form' +import { createFormHookContexts } from './AppFormContext' const { useAppForm } = createFormHook({ fieldContext, @@ -164,8 +169,14 @@ function App() { It is quite common for platform teams to ship pre built appForms. It can be exported from a library in a monorepo or as a standalone package on npm. +```tsx weyland-yutan-corp/forms-context.tsx +export const { fieldContext, formContext, useFieldContext, useFormContext } = + createFormHookContexts() +``` + ```tsx weyland-yutan-corp/forms.tsx -import { createFormHook, createFormHookContexts } from '@tanstack/react-form' +import { createFormHook } from '@tanstack/react-form' +import { fieldContext, formContext } from 'weyland-yutan-corp/forms-context' // fields import { UserIdField } from './FieldComponents/UserIdField' @@ -173,9 +184,6 @@ import { UserIdField } from './FieldComponents/UserIdField' // components import { SubmitButton } from './FormComponents/SubmitButton' -export const { fieldContext, formContext, useFieldContext, useFormContext } = - createFormHookContexts() - const ProfileForm = createFormHook({ fieldContext, formContext, @@ -192,7 +200,7 @@ There is a situation that you might have a field exclusive to a downstream dev t ```tsx AppForm.tsx // imported from the same AppForm you want to extend -import { useFieldContext } from 'weyland-yutan-corp/forms' +import { useFieldContext } from 'weyland-yutan-corp/forms-context' export function CustomTextField({ label }: { label: string }) { const field = useFieldContext() @@ -208,15 +216,15 @@ export function CustomTextField({ label }: { label: string }) { ```tsx AppForm.tsx // notice the same import as above -import ProfileForm from 'Weyland-Yutan-corp/forms' +import ProfileForm from 'weyland-yutan-corp/forms' import { CustomTextField } from './FieldComponents/CustomTextField' -import { CustomSubmit } from './FormComponents/CustomSubmit' +import { SubmitButton } from './FormComponents/SubmitButton' export const { useAppForm } = ProfileForm.extendForm({ fieldComponents: { CustomTextField }, // Ts will error since the parent appForm already has a component called CustomSubmit - formComponents: { CustomSubmit }, + formComponents: { SubmitButton }, }) ``` @@ -280,6 +288,9 @@ function App() { } ``` +> Something worth mentioning, is that while multiple chaining of `AppForm` extensions is possible it is can lead to decreases in TypeScript performance. +> For most users that may only extend an appForm once this isn't a problem, however we recommend limiting it to 3-5 extensions. + ### `withForm` FAQ > Why a higher-order component instead of a hook? diff --git a/examples/react/composition/src/AppForm/FieldComponents/TextField.tsx b/examples/react/composition/src/AppForm/FieldComponents/TextField.tsx index 2248d1a22..cdc56c320 100644 --- a/examples/react/composition/src/AppForm/FieldComponents/TextField.tsx +++ b/examples/react/composition/src/AppForm/FieldComponents/TextField.tsx @@ -9,6 +9,7 @@ export function TextField({ label }: { label: string }) { field.handleChange(e.target.value)} + onBlur={() => field.handleBlur()} /> <> diff --git a/examples/react/composition/src/index.tsx b/examples/react/composition/src/index.tsx index 797a577a3..2f33faf6a 100644 --- a/examples/react/composition/src/index.tsx +++ b/examples/react/composition/src/index.tsx @@ -48,7 +48,7 @@ export default function App() { }, }} > - {(f) => } + {(f) => } From 11f51c2ca94cd87d1a62fd6979cd9ed4670f39ae Mon Sep 17 00:00:00 2001 From: Harry Whorlow Date: Fri, 10 Apr 2026 13:29:31 +0200 Subject: [PATCH 7/8] chore: changeset --- .changeset/neat-jars-rescue.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/neat-jars-rescue.md diff --git a/.changeset/neat-jars-rescue.md b/.changeset/neat-jars-rescue.md new file mode 100644 index 000000000..1fe2c59a8 --- /dev/null +++ b/.changeset/neat-jars-rescue.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-form': minor +--- + +Adds extension method to AppForm allowing for teams to extend upstream AppForms From f6733e4be07c1cc5f45da5730c50ece15768d248 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:30:28 +0000 Subject: [PATCH 8/8] ci: apply automated fixes and generate docs --- packages/react-form/src/createFormHook.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/react-form/src/createFormHook.tsx b/packages/react-form/src/createFormHook.tsx index 434c1d46b..5c0e8b7ad 100644 --- a/packages/react-form/src/createFormHook.tsx +++ b/packages/react-form/src/createFormHook.tsx @@ -603,10 +603,7 @@ export function createFormHook< const TNewForm extends Record> & { [K in keyof TFormComponents]?: 'Error: form component names must be unique — this key already exists in the base form' }, - >(extension: { - fieldComponents?: TNewField - formComponents?: TNewForm - }) { + >(extension: { fieldComponents?: TNewField; formComponents?: TNewForm }) { return createFormHook({ fieldContext, formContext,