Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion docs/framework/react/guides/form-composition.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<string>()
return (
<div>
<label>{/* rest of component */}</label>
</div>
)
}
```

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.
Expand Down
11 changes: 11 additions & 0 deletions examples/react/composition/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions examples/react/composition/.gitignore
Original file line number Diff line number Diff line change
@@ -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*
6 changes: 6 additions & 0 deletions examples/react/composition/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Example

To run this example:

- `npm install`
- `npm run dev`
16 changes: 16 additions & 0 deletions examples/react/composition/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/emblem-light.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />

<title>TanStack Form React Simple Example App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
36 changes: 36 additions & 0 deletions examples/react/composition/package.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
}
13 changes: 13 additions & 0 deletions examples/react/composition/public/emblem-light.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 23 additions & 0 deletions examples/react/composition/src/AppForm/AppForm.tsx
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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<string>()
return (
<label>
<span>{label}</span>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>

<>
{field.state.meta.isTouched && !field.state.meta.isValid ? (
<em>{field.state.meta.errors.join(',')}</em>
) : null}
{field.state.meta.isValidating ? 'Validating...' : null}
</>
</label>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useFormContext } from '../AppForm'

export function SubmitButton({ label }: { label: string }) {
const form = useFormContext()
return (
<form.Subscribe selector={(state) => state.isSubmitting}>
{(isSubmitting) => (
<button type="submit" disabled={isSubmitting}>
{label}
</button>
)}
</form.Subscribe>
)
}
77 changes: 77 additions & 0 deletions examples/react/composition/src/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<h1>Simple Form Example</h1>

<form
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
form.handleSubmit()
}}
>
{/* A type-safe field component*/}
<form.AppField
name="firstName"
validators={{
onChange: ({ value }) =>
!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.TextField label="last name" />}
</form.AppField>

<form.AppField name="lastName">
{(f) => <f.TextField label="last name" />}
</form.AppField>

<form.AppForm>
<form.SubmitButton label="save" />
</form.AppForm>
</form>
</div>
)
}

const rootElement = document.getElementById('root')!

createRoot(rootElement).render(
<React.StrictMode>
<App />

<TanStackDevtools
config={{ hideUntilHover: true }}
plugins={[formDevtoolsPlugin()]}
/>
</React.StrictMode>,
)
23 changes: 23 additions & 0 deletions examples/react/composition/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
23 changes: 23 additions & 0 deletions packages/react-form/src/createFormHook.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -596,10 +596,33 @@ export function createFormHook<
return form as never
}

function extendForm<
const TNewField extends Record<string, ComponentType<any>> & {
[K in keyof TComponents]?: 'Error: field component names must be unique — this key already exists in the base form'
},
const TNewForm extends Record<string, ComponentType<any>> & {
[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,
Comment on lines +599 to +617
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

Reserve core form/field API names in extendForm.

The new check only rejects keys that already exist in the parent component maps. Extensions can still use names that already exist on the runtime APIs themselves, e.g. AppForm, AppField, Field, Subscribe, or field members like handleChange. Those names are later merged onto the live form/field objects, so an extension can silently replace core behavior.

Suggested fix
   function extendForm<
     const TNewField extends Record<string, ComponentType<any>> & {
-      [K in keyof TComponents]?: 'Error: field component names must be unique — this key already exists in the base form'
+      [K in keyof TComponents | keyof AnyFieldApi]?: 'Error: field component names must be unique and must not shadow field API members'
     },
     const TNewForm extends Record<string, ComponentType<any>> & {
-      [K in keyof TFormComponents]?: 'Error: form component names must be unique — this key already exists in the base form'
+      [K in keyof TFormComponents | keyof AnyFormApi | 'AppField' | 'AppForm']?: 'Error: form component names must be unique and must not shadow form API members'
     },
   >(extension: { fieldComponents?: TNewField; formComponents?: 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 599 - 617,
extendForm currently only prevents extending keys that exist in the parent maps
but doesn't block names that collide with core runtime APIs (e.g., AppForm,
AppField, Field, Subscribe, handleChange) and thus can silently override core
behavior; modify extendForm to declare a reservedNames set (include core
exported API and runtime member names) and validate extension.fieldComponents
and extension.formComponents against that set, rejecting/throwing with a clear
error if any key from TNewField or TNewForm appears in reservedNames; reference
the extendForm function and the fieldComponents/formComponents merge locations
so the checks run before calling createFormHook and before the cast to
TComponents & TNewField / TFormComponents & TNewForm.

})
}

return {
useAppForm,
withForm,
withFieldGroup,
useTypedAppFormContext,
extendForm,
}
}
Loading
Loading