-
-
Notifications
You must be signed in to change notification settings - Fork 608
feat(react-from): extend appform #2106
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
7519997
e79b4f2
4a49d2e
94d3397
6323166
23d9e03
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| 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* |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| # Example | ||
|
|
||
| To run this example: | ||
|
|
||
| - `npm install` | ||
| - `npm run dev` |
| 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> |
| 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" | ||
| ] | ||
| } | ||
| } |
| 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)} | ||
| /> | ||
harry-whorlow marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| <> | ||
| {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> | ||
| ) | ||
| } |
| 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> | ||
harry-whorlow marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| <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>, | ||
| ) | ||
| 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"] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reserve core form/field API names in 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. 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 |
||
| }) | ||
| } | ||
|
|
||
| return { | ||
| useAppForm, | ||
| withForm, | ||
| withFieldGroup, | ||
| useTypedAppFormContext, | ||
| extendForm, | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.