Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/neat-jars-rescue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/react-form': minor
---

Adds extension method to AppForm allowing for teams to extend upstream AppForms
79 changes: 76 additions & 3 deletions docs/framework/react/guides/form-composition.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -103,7 +108,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 +165,71 @@ 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-context.tsx
export const { fieldContext, formContext, useFieldContext, useFormContext } =
createFormHookContexts()
```

```tsx weyland-yutan-corp/forms.tsx
import { createFormHook } from '@tanstack/react-form'
import { fieldContext, formContext } from 'weyland-yutan-corp/forms-context'

// fields
import { UserIdField } from './FieldComponents/UserIdField'

// components
import { SubmitButton } from './FormComponents/SubmitButton'

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-context'

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 { 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: { SubmitButton },
})
```

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 Expand Up @@ -218,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?
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,23 @@
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)}
onBlur={() => field.handleBlur()}
/>

<>
{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="first 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"]
}
Loading
Loading