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/.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..4e37aa657 --- /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 { useAppForm } = createFormHook({ + fieldContext, + formContext, + fieldComponents: { TextField: TextField }, + formComponents: { SubmitButton }, +}) + +export default useAppForm 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/index.tsx b/examples/react/composition/src/index.tsx new file mode 100644 index 000000000..797a577a3 --- /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 './AppForm/AppForm' + +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..5c0e8b7ad 100644 --- a/packages/react-form/src/createFormHook.tsx +++ b/packages/react-form/src/createFormHook.tsx @@ -596,10 +596,33 @@ 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, + extendForm, } } 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: { 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':