diff --git a/docs/framework/react/guides/form-composition.md b/docs/framework/react/guides/form-composition.md index d5b4a305e..6e63f36af 100644 --- a/docs/framework/react/guides/form-composition.md +++ b/docs/framework/react/guides/form-composition.md @@ -302,12 +302,12 @@ const { useAppForm, withForm } = createFormHook({ ```tsx // src/App.tsx import { Suspense } from 'react' -import { PeoplePage } from './features/people/page.tsx' +import { PeoplePage } from './features/people/form.tsx' export default function App() { return ( Loading...

}> - +
) } diff --git a/docs/framework/solid/guides/form-composition.md b/docs/framework/solid/guides/form-composition.md new file mode 100644 index 000000000..8b208864e --- /dev/null +++ b/docs/framework/solid/guides/form-composition.md @@ -0,0 +1,375 @@ +--- +id: form-composition +title: Form Composition +--- + +A common criticism of TanStack Form is its verbosity out-of-the-box. While this _can_ be useful for educational purposes - helping enforce understanding our APIs - it's not ideal in production use cases. + +As a result, while `form.Field` enables the most powerful and flexible usage of TanStack Form, we provide APIs that wrap it and make your application code less verbose. + +## Custom Form Hooks + +The most powerful way to compose forms is to create custom form hooks. This allows you to create a form hook that is tailored to your application's needs, including pre-bound custom UI components and more. + +At it's most basic, `createFormHook` is a function that takes a `fieldContext` and `formContext` and returns a `useAppForm` hook. + +> 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' + +// export useFieldContext for use in your custom components +export const { fieldContext, formContext, useFieldContext } = + createFormHookContexts() + +const { useAppForm } = createFormHook({ + fieldContext, + formContext, + // We'll learn more about these options later + fieldComponents: {}, + formComponents: {}, +}) + +function App() { + const form = useAppForm({ + // Supports all useForm options + defaultValues: { + firstName: 'John', + lastName: 'Doe', + }, + }) + + return // ... +} +``` + +### Pre-bound Field Components + +Once this scaffolding is in place, you can start adding custom field and form components to your form hook. + +> Note: the `useFieldContext` must be the same one exported from your custom form context + +```tsx +import { useFieldContext } from './form-context.tsx' + +export function TextField(props: { label: string }) { + // The `Field` infers that it should have a `value` type of `string` + const field = useFieldContext() + return ( + + ) +} +``` + +You're then able to register this component with your form hook. + +```tsx +import { TextField } from './text-field.tsx' + +const { useAppForm } = createFormHook({ + fieldContext, + formContext, + fieldComponents: { + TextField, + }, + formComponents: {}, +}) +``` + +And use it in your form: + +```tsx +function App() { + const form = useAppForm({ + defaultValues: { + firstName: 'John', + lastName: 'Doe', + }, + }) + + return ( + // Notice the `AppField` instead of `Field`; `AppField` provides the required context + } + /> + ) +} +``` + +This not only allows you to reuse the UI of your shared component, but retains the type-safety you'd expect from TanStack Form: Typo `name` and get a TypeScript error. + +### Pre-bound Form Components + +While `form.AppField` solves many of the problems with Field boilerplate and reusability, it doesn't solve the problem of _form_ boilerplate and reusability. + +In particular, being able to share instances of `form.Subscribe` for, say, a reactive form submission button is a common usecase. + +```tsx +function SubscribeButton(props: { label: string }) { + const form = useFormContext() + return ( + state.isSubmitting}> + {(isSubmitting) => ( + + )} + + ) +} + +const { useAppForm, withForm } = createFormHook({ + fieldComponents: {}, + formComponents: { + SubscribeButton, + }, + fieldContext, + formContext, +}) + +function App() { + const form = useAppForm({ + defaultValues: { + firstName: 'John', + lastName: 'Doe', + }, + }) + + return ( + + // Notice the `AppForm` component wrapper; `AppForm` provides the required + context + + + ) +} +``` + +## 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 long files. + +To solve this, we support breaking forms into smaller pieces using the `withForm` higher-order component. + +```tsx +const { useAppForm, withForm } = createFormHook({ + fieldComponents: { + TextField, + }, + formComponents: { + SubscribeButton, + }, + fieldContext, + formContext, +}) + +const ChildForm = withForm({ + // These values are only used for type-checking, and are not used at runtime + // This allows you to `...formOpts` from `formOptions` without needing to redeclare the options + defaultValues: { + firstName: 'John', + lastName: 'Doe', + }, + // Optional, but adds props to the `render` function in addition to `form` + props: { + // These props are also set as default values for the `render` function + title: 'Child Form', + }, + render: function Render(props) { + return ( +
+

{props.title}

+ } + /> + + + +
+ ) + }, +}) + +function App() { + const form = useAppForm({ + defaultValues: { + firstName: 'John', + lastName: 'Doe', + }, + }) + + return +} +``` + +### `withForm` FAQ + +> Why a higher-order component instead of a hook? + +While hooks are the future of React, higher-order components are still a powerful tool for composition. In particular, the API of `withForm` enables us to have strong type-safety without requiring users to pass generics. + +## Tree-shaking form and field components + +While the above examples are great for getting started, they're not ideal for certain use-cases where you might have hundreds of form and field components. +In particular, you may not want to include all of your form and field components in the bundle of every file that uses your form hook. + +To solve this, you can mix the `createFormHook` TanStack API with the Solid `lazy` and `Suspense` components: + +```typescript +// src/hooks/form-context.ts +import { createFormHookContexts } from '@tanstack/solid-form' + +export const { fieldContext, useFieldContext, formContext, useFormContext } = + createFormHookContexts() +``` + +```tsx +// src/components/text-field.tsx +import { useFieldContext } from '../hooks/form-context.tsx' + +export default function TextField(props: { label: string }) { + const field = useFieldContext() + + return ( + + ) +} +``` + +```tsx +// src/hooks/form.ts +import { lazy } from 'solid-js' +import { createFormHook } from '@tanstack/react-form' + +const TextField = lazy(() => import('../components/text-fields.tsx')) + +const { useAppForm, withForm } = createFormHook({ + fieldContext, + formContext, + fieldComponents: { + TextField, + }, + formComponents: {}, +}) +``` + +```tsx +// src/App.tsx +import { Suspense } from 'solid-js' +import { PeoplePage } from './features/people/form.tsx' + +export default function App() { + return ( + Loading...

}> + +
+ ) +} +``` + +This will show the Suspense fallback while the `TextField` component is being loaded, and then render the form once it's loaded. + +## Putting it all together + +Now that we've covered the basics of creating custom form hooks, let's put it all together in a single example. + +```tsx +// /src/hooks/form.ts, to be used across the entire app +const { fieldContext, useFieldContext, formContext, useFormContext } = + createFormHookContexts() + +function TextField(props: { label: string }) { + const field = useFieldContext() + return ( + + ) +} + +function SubscribeButton(props: { label: string }) { + const form = useFormContext() + return ( + state.isSubmitting}> + {(isSubmitting) => ( + + )} + + ) +} + +const { useAppForm, withForm } = createFormHook({ + fieldComponents: { + TextField, + }, + formComponents: { + SubscribeButton, + }, + fieldContext, + formContext, +}) + +// /src/features/people/shared-form.ts, to be used across `people` features +const formOpts = formOptions({ + defaultValues: { + firstName: 'John', + lastName: 'Doe', + }, +}) + +// /src/features/people/nested-form.ts, to be used in the `people` page +const ChildForm = withForm({ + ...formOpts, + // Optional, but adds props to the `render` function outside of `form` + props: { + title: 'Child Form', + }, + render: (props) => { + return ( +
+

{title}

+ } + /> + + + +
+ ) + }, +}) + +// /src/features/people/page.ts +const Parent = () => { + const form = useAppForm({ + ...formOpts, + }) + + return +} +``` + +## API Usage Guidance + +Here's a chart to help you decide what APIs you should be using: + +![](https://raw.githubusercontent.com/TanStack/form/main/docs/assets/react_form_composability.svg) diff --git a/examples/solid/large-form/.gitignore b/examples/solid/large-form/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/examples/solid/large-form/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/examples/solid/large-form/README.md b/examples/solid/large-form/README.md new file mode 100644 index 000000000..1cf889265 --- /dev/null +++ b/examples/solid/large-form/README.md @@ -0,0 +1,6 @@ +# Example + +To run this example: + +- `npm install` +- `npm run dev` diff --git a/examples/solid/large-form/index.html b/examples/solid/large-form/index.html new file mode 100644 index 000000000..e1b25b917 --- /dev/null +++ b/examples/solid/large-form/index.html @@ -0,0 +1,16 @@ + + + + + + + + + TanStack Form Solid Large Example App + + + +
+ + + diff --git a/examples/solid/large-form/package.json b/examples/solid/large-form/package.json new file mode 100644 index 000000000..207d2bcb3 --- /dev/null +++ b/examples/solid/large-form/package.json @@ -0,0 +1,20 @@ +{ + "name": "@tanstack/form-example-solid-large-form", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port=3001", + "build": "tsc && vite build", + "test:types": "tsc", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/solid-form": "^1.12.4", + "solid-js": "^1.9.7" + }, + "devDependencies": { + "typescript": "5.8.2", + "vite": "^6.3.5", + "vite-plugin-solid": "^2.11.6" + } +} diff --git a/examples/solid/large-form/public/emblem-light.svg b/examples/solid/large-form/public/emblem-light.svg new file mode 100644 index 000000000..a58e69ad5 --- /dev/null +++ b/examples/solid/large-form/public/emblem-light.svg @@ -0,0 +1,13 @@ + + + + emblem-light + Created with Sketch. + + + + + + + + \ No newline at end of file diff --git a/examples/solid/large-form/src/App.tsx b/examples/solid/large-form/src/App.tsx new file mode 100644 index 000000000..87e553910 --- /dev/null +++ b/examples/solid/large-form/src/App.tsx @@ -0,0 +1,10 @@ +import { Suspense } from 'solid-js' +import { PeoplePage } from './features/people/page.tsx' + +export default function App() { + return ( + Loading...

}> + +
+ ) +} diff --git a/examples/solid/large-form/src/components/text-fields.tsx b/examples/solid/large-form/src/components/text-fields.tsx new file mode 100644 index 000000000..c6bb3b8f8 --- /dev/null +++ b/examples/solid/large-form/src/components/text-fields.tsx @@ -0,0 +1,24 @@ +import { For } from 'solid-js' +import { useStore } from '@tanstack/solid-form' +import { useFieldContext } from '../hooks/form-context.tsx' + +export default function TextField(props: { label: string }) { + const field = useFieldContext() + + const errors = useStore(field().store, (state) => state.meta.errors) + + return ( +
+ + + {(error) =>
{error}
} +
+
+ ) +} diff --git a/examples/solid/large-form/src/features/people/address-fields.tsx b/examples/solid/large-form/src/features/people/address-fields.tsx new file mode 100644 index 000000000..ecc28cd51 --- /dev/null +++ b/examples/solid/large-form/src/features/people/address-fields.tsx @@ -0,0 +1,33 @@ +import { withForm } from '../../hooks/form.tsx' +import { peopleFormOpts } from './shared-form.tsx' + +export const AddressFields = withForm({ + ...peopleFormOpts, + render: (props) => { + return ( +
+

Address

+ } + /> + } + /> + } + /> + } + /> + } + /> +
+ ) + }, +}) diff --git a/examples/solid/large-form/src/features/people/page.tsx b/examples/solid/large-form/src/features/people/page.tsx new file mode 100644 index 000000000..b7b6426ff --- /dev/null +++ b/examples/solid/large-form/src/features/people/page.tsx @@ -0,0 +1,73 @@ +import { useAppForm } from '../../hooks/form.tsx' +import { AddressFields } from './address-fields.tsx' +import { peopleFormOpts } from './shared-form.tsx' + +export const PeoplePage = () => { + const form = useAppForm(() => ({ + ...peopleFormOpts, + validators: { + onChange: ({ value }) => { + const errors = { + fields: {}, + } as { + fields: Record + } + if (!value.fullName) { + errors.fields.fullName = 'Full name is required' + } + if (!value.phone) { + errors.fields.phone = 'Phone is required' + } + if (!value.emergencyContact.fullName) { + errors.fields['emergencyContact.fullName'] = + 'Emergency contact full name is required' + } + if (!value.emergencyContact.phone) { + errors.fields['emergencyContact.phone'] = + 'Emergency contact phone is required' + } + + return errors + }, + }, + onSubmit: ({ value }) => { + alert(JSON.stringify(value, null, 2)) + }, + })) + + return ( +
{ + e.preventDefault() + form.handleSubmit() + }} + > +

Personal Information

+ } + /> + } + /> + } + /> + +

Emergency Contact

+ } + /> + } + /> + + + + + ) +} diff --git a/examples/solid/large-form/src/features/people/shared-form.tsx b/examples/solid/large-form/src/features/people/shared-form.tsx new file mode 100644 index 000000000..70d5917ab --- /dev/null +++ b/examples/solid/large-form/src/features/people/shared-form.tsx @@ -0,0 +1,20 @@ +import { formOptions } from '@tanstack/solid-form' + +export const peopleFormOpts = formOptions({ + defaultValues: { + fullName: '', + email: '', + phone: '', + address: { + line1: '', + line2: '', + city: '', + state: '', + zip: '', + }, + emergencyContact: { + fullName: '', + phone: '', + }, + }, +}) diff --git a/examples/solid/large-form/src/hooks/form-context.tsx b/examples/solid/large-form/src/hooks/form-context.tsx new file mode 100644 index 000000000..04455937a --- /dev/null +++ b/examples/solid/large-form/src/hooks/form-context.tsx @@ -0,0 +1,4 @@ +import { createFormHookContexts } from '@tanstack/solid-form' + +export const { fieldContext, useFieldContext, formContext, useFormContext } = + createFormHookContexts() diff --git a/examples/solid/large-form/src/hooks/form.tsx b/examples/solid/large-form/src/hooks/form.tsx new file mode 100644 index 000000000..e26451d86 --- /dev/null +++ b/examples/solid/large-form/src/hooks/form.tsx @@ -0,0 +1,27 @@ +import { createFormHook } from '@tanstack/solid-form' +import { lazy } from 'solid-js' +import { fieldContext, formContext, useFormContext } from './form-context.tsx' + +const TextField = lazy(() => import('../components/text-fields.tsx')) + +function SubscribeButton(props: { label: string }) { + const form = useFormContext() + return ( + state.isSubmitting}> + {(isSubmitting) => ( + + )} + + ) +} + +export const { useAppForm, withForm } = createFormHook({ + fieldComponents: { + TextField, + }, + formComponents: { + SubscribeButton, + }, + fieldContext, + formContext, +}) diff --git a/examples/solid/large-form/src/index.tsx b/examples/solid/large-form/src/index.tsx new file mode 100644 index 000000000..f764172a5 --- /dev/null +++ b/examples/solid/large-form/src/index.tsx @@ -0,0 +1,6 @@ +import { render } from 'solid-js/web' +import App from './App.tsx' + +const root = document.getElementById('root') + +render(() => , root!) diff --git a/examples/solid/large-form/tsconfig.json b/examples/solid/large-form/tsconfig.json new file mode 100644 index 000000000..5e559e80f --- /dev/null +++ b/examples/solid/large-form/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + /* Bundler mode */ + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/examples/solid/large-form/vite.config.ts b/examples/solid/large-form/vite.config.ts new file mode 100644 index 000000000..4095d9be5 --- /dev/null +++ b/examples/solid/large-form/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import solid from 'vite-plugin-solid' + +export default defineConfig({ + plugins: [solid()], +}) diff --git a/packages/solid-form/src/createField.tsx b/packages/solid-form/src/createField.tsx index 9113720de..522b06254 100644 --- a/packages/solid-form/src/createField.tsx +++ b/packages/solid-form/src/createField.tsx @@ -2,7 +2,6 @@ import { FieldApi } from '@tanstack/form-core' import { createComponent, createComputed, - createMemo, createSignal, onCleanup, onMount, @@ -17,7 +16,7 @@ import type { Narrow, } from '@tanstack/form-core' -import type { JSXElement } from 'solid-js' +import type { Accessor, Component, JSX, JSXElement } from 'solid-js' import type { CreateFieldOptions, CreateFieldOptionsBound } from './types' interface SolidFieldApi< @@ -359,7 +358,8 @@ interface FieldComponentBoundProps< TFormOnSubmit extends undefined | FormValidateOrFn, TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, TFormOnServer extends undefined | FormAsyncValidateOrFn, - TParentSubmitMeta, + TPatentSubmitMeta, + ExtendedApi = {}, > extends CreateFieldOptionsBound< TParentData, TName, @@ -373,43 +373,56 @@ interface FieldComponentBoundProps< TOnSubmitAsync > { children: ( - fieldApi: () => FieldApi< - TParentData, - TName, - TData, - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync, - TFormOnMount, - TFormOnChange, - TFormOnChangeAsync, - TFormOnBlur, - TFormOnBlurAsync, - TFormOnSubmit, - TFormOnSubmitAsync, - TFormOnServer, - TParentSubmitMeta - >, - ) => JSXElement + fieldApi: Accessor< + FieldApi< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnServer, + TPatentSubmitMeta + > + > & + ExtendedApi, + ) => JSX.Element } +/** + * A type alias representing a field component for a specific form data type. + */ export type FieldComponent< - TParentData, - TFormOnMount extends undefined | FormValidateOrFn, - TFormOnChange extends undefined | FormValidateOrFn, - TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, - TFormOnBlur extends undefined | FormValidateOrFn, - TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, - TFormOnSubmit extends undefined | FormValidateOrFn, - TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, - TFormOnServer extends undefined | FormAsyncValidateOrFn, - TParentSubmitMeta, + in out TParentData, + in out TFormOnMount extends undefined | FormValidateOrFn, + in out TFormOnChange extends undefined | FormValidateOrFn, + in out TFormOnChangeAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnBlur extends undefined | FormValidateOrFn, + in out TFormOnBlurAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnSubmit extends undefined | FormValidateOrFn, + in out TFormOnSubmitAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnServer extends undefined | FormAsyncValidateOrFn, + in out TPatentSubmitMeta, + in out ExtendedApi = {}, > = < - TName extends DeepKeys, + const TName extends DeepKeys, TData extends DeepValue, TOnMount extends undefined | FieldValidateOrFn, TOnChange extends undefined | FieldValidateOrFn, @@ -446,8 +459,9 @@ export type FieldComponent< TFormOnSubmit, TFormOnSubmitAsync, TFormOnServer, - TParentSubmitMeta ->) => JSXElement + TPatentSubmitMeta, + ExtendedApi +>) => JSX.Element interface FieldComponentProps< TParentData, diff --git a/packages/solid-form/src/createForm.tsx b/packages/solid-form/src/createForm.tsx index 3b4b52fc4..4473d3207 100644 --- a/packages/solid-form/src/createForm.tsx +++ b/packages/solid-form/src/createForm.tsx @@ -112,6 +112,44 @@ export interface SolidFormApi< }) => JSXElement } +/** + * An extended version of the `FormApi` class that includes React-specific functionalities from `ReactFormApi` + */ +export type SolidFormExtendedApi< + TFormData, + TOnMount extends undefined | FormValidateOrFn, + TOnChange extends undefined | FormValidateOrFn, + TOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TOnBlur extends undefined | FormValidateOrFn, + TOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TOnSubmit extends undefined | FormValidateOrFn, + TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TOnServer extends undefined | FormAsyncValidateOrFn, + TSubmitMeta, +> = FormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta +> & + SolidFormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta + > export function createForm< TParentData, TFormOnMount extends undefined | FormValidateOrFn, diff --git a/packages/solid-form/src/createFormHook.tsx b/packages/solid-form/src/createFormHook.tsx new file mode 100644 index 000000000..b3747ac00 --- /dev/null +++ b/packages/solid-form/src/createFormHook.tsx @@ -0,0 +1,386 @@ +import { createContext, splitProps, useContext } from 'solid-js' +import { createForm } from './createForm' +import type { + AnyFieldApi, + AnyFormApi, + FieldApi, + FormAsyncValidateOrFn, + FormOptions, + FormValidateOrFn, +} from '@tanstack/form-core' +import type { + Accessor, + Component, + Context, + JSXElement, + ParentProps, +} from 'solid-js' +import type { FieldComponent } from './createField' +import type { SolidFormExtendedApi } from './createForm' + +/** + * TypeScript inferencing is weird. + * + * If you have: + * + * @example + * + * interface Args { + * arg?: T + * } + * + * function test(arg?: Partial>): T { + * return 0 as any; + * } + * + * const a = test({}); + * + * Then `T` will default to `unknown`. + * + * However, if we change `test` to be: + * + * @example + * + * function test(arg?: Partial>): T; + * + * Then `T` becomes `undefined`. + * + * Here, we are checking if the passed type `T` extends `DefaultT` and **only** + * `DefaultT`, as if that's the case we assume that inferencing has not occured. + */ +type UnwrapOrAny = [unknown] extends [T] ? any : T +type UnwrapDefaultOrAny = [DefaultT] extends [T] + ? [T] extends [DefaultT] + ? any + : T + : T + +export function createFormHookContexts() { + // We should never hit the `null` case here + const fieldContext = createContext>( + null as unknown as Accessor, + ) + + function useFieldContext() { + const field = useContext(fieldContext) + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!field) { + throw new Error( + '`fieldContext` only works when within a `fieldComponent` passed to `createFormHook`', + ) + } + + return field as Accessor< + FieldApi< + any, + string, + TData, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any + > + > + } + + // We should never hit the `null` case here + const formContext = createContext(null as unknown as AnyFormApi) + + function useFormContext() { + const form = useContext(formContext) + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!form) { + throw new Error( + '`formContext` only works when within a `formComponent` passed to `createFormHook`', + ) + } + + return form as SolidFormExtendedApi< + // If you need access to the form data, you need to use `withForm` instead + Record, + any, + any, + any, + any, + any, + any, + any, + any, + any + > + } + + return { fieldContext, useFieldContext, useFormContext, formContext } +} + +interface CreateFormHookProps< + TFieldComponents extends Record>, + TFormComponents extends Record>, +> { + fieldComponents: TFieldComponents + fieldContext: Context> + formComponents: TFormComponents + formContext: Context +} + +type AppFieldExtendedSolidFormApi< + TFormData, + TOnMount extends undefined | FormValidateOrFn, + TOnChange extends undefined | FormValidateOrFn, + TOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TOnBlur extends undefined | FormValidateOrFn, + TOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TOnSubmit extends undefined | FormValidateOrFn, + TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TOnServer extends undefined | FormAsyncValidateOrFn, + TSubmitMeta, + TFieldComponents extends Record>, + TFormComponents extends Record>, +> = SolidFormExtendedApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta +> & + NoInfer & { + AppField: FieldComponent< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta, + NoInfer + > + AppForm: Component + } + +export interface WithFormProps< + TFormData, + TOnMount extends undefined | FormValidateOrFn, + TOnChange extends undefined | FormValidateOrFn, + TOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TOnBlur extends undefined | FormValidateOrFn, + TOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TOnSubmit extends undefined | FormValidateOrFn, + TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TOnServer extends undefined | FormAsyncValidateOrFn, + TSubmitMeta, + TFieldComponents extends Record>, + TFormComponents extends Record>, + TRenderProps extends Record = Record, +> extends FormOptions< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta + > { + // Optional, but adds props to the `render` function outside of `form` + props?: TRenderProps + render: ( + props: ParentProps< + NoInfer & { + form: AppFieldExtendedSolidFormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta, + TFieldComponents, + TFormComponents + > + } + >, + ) => JSXElement +} + +export function createFormHook< + const TComponents extends Record>, + const TFormComponents extends Record>, +>(opts: CreateFormHookProps) { + function useAppForm< + TFormData, + TOnMount extends undefined | FormValidateOrFn, + TOnChange extends undefined | FormValidateOrFn, + TOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TOnBlur extends undefined | FormValidateOrFn, + TOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TOnSubmit extends undefined | FormValidateOrFn, + TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TOnServer extends undefined | FormAsyncValidateOrFn, + TSubmitMeta, + >( + props: Accessor< + FormOptions< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta + > + >, + ): AppFieldExtendedSolidFormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta, + TComponents, + TFormComponents + > { + const form = createForm(props) + + const AppForm = ((formProps) => { + return ( + + {formProps.children} + + ) + }) as Component + + const AppField = ((_props) => { + const [childProps, fieldProps] = splitProps(_props, ['children']) + return ( + + {(field) => ( + + {childProps.children(Object.assign(field, opts.fieldComponents))} + + )} + + ) + }) as FieldComponent< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta, + TComponents + > + + const extendedForm: AppFieldExtendedSolidFormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta, + TComponents, + TFormComponents + > = form as never + extendedForm.AppField = AppField + extendedForm.AppForm = AppForm + for (const [key, value] of Object.entries(opts.formComponents)) { + // Since it's a generic I need to cast it to an object + ;(extendedForm as Record)[key] = value + } + + return extendedForm + } + + function withForm< + TFormData, + TOnMount extends undefined | FormValidateOrFn, + TOnChange extends undefined | FormValidateOrFn, + TOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TOnBlur extends undefined | FormValidateOrFn, + TOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TOnSubmit extends undefined | FormValidateOrFn, + TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TOnServer extends undefined | FormAsyncValidateOrFn, + TSubmitMeta, + TRenderProps extends Record = {}, + >({ + render, + props, + }: WithFormProps< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta, + TComponents, + TFormComponents, + TRenderProps + >): WithFormProps< + UnwrapOrAny, + UnwrapDefaultOrAny, TOnMount>, + UnwrapDefaultOrAny, TOnChange>, + UnwrapDefaultOrAny, TOnChangeAsync>, + UnwrapDefaultOrAny, TOnBlur>, + UnwrapDefaultOrAny, TOnBlurAsync>, + UnwrapDefaultOrAny, TOnSubmit>, + UnwrapDefaultOrAny, TOnSubmitAsync>, + UnwrapDefaultOrAny, TOnServer>, + UnwrapOrAny, + UnwrapOrAny, + UnwrapOrAny, + UnwrapOrAny + >['render'] { + return (innerProps) => render({ ...props, ...innerProps }) + } + + return { + useAppForm, + withForm, + } +} diff --git a/packages/solid-form/src/index.tsx b/packages/solid-form/src/index.tsx index dbe20bcd1..dd2414c4b 100644 --- a/packages/solid-form/src/index.tsx +++ b/packages/solid-form/src/index.tsx @@ -6,3 +6,6 @@ export { createForm, type SolidFormApi } from './createForm' export type { CreateField, FieldComponent } from './createField' export { createField, Field } from './createField' + +export type { WithFormProps } from './createFormHook' +export { createFormHook, createFormHookContexts } from './createFormHook' diff --git a/packages/solid-form/tests/createFormHook.test-d.tsx b/packages/solid-form/tests/createFormHook.test-d.tsx new file mode 100644 index 000000000..067c2be1b --- /dev/null +++ b/packages/solid-form/tests/createFormHook.test-d.tsx @@ -0,0 +1,252 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { formOptions } from '@tanstack/form-core' +import { createFormHook, createFormHookContexts } from '../src' +import type { JSX } from 'solid-js/jsx-runtime' + +const { fieldContext, useFieldContext, formContext, useFormContext } = + createFormHookContexts() + +function Test() { + return null +} + +const { useAppForm, withForm } = createFormHook({ + fieldComponents: { + Test, + }, + formComponents: { + Test, + }, + fieldContext, + formContext, +}) + +describe('createFormHook', () => { + it('should not break with an infinite type on large schemas', () => { + const ActivityKind0_Names = ['Work', 'Rest', 'OnCall'] as const + type ActivityKind0 = (typeof ActivityKind0_Names)[number] + + enum DayOfWeek { + Monday = 1, + Tuesday, + Wednesday, + Thursday, + Friday, + Saturday, + Sunday, + } + + interface Branding { + __type?: Brand + } + type Branded = T & Branding + type ActivityId = Branded + interface ActivitySelectorFormData { + includeAll: boolean + includeActivityIds: ActivityId[] + includeActivityKinds: Set + excludeActivityIds: ActivityId[] + } + + const GeneratedTypes0Visibility_Names = [ + 'Normal', + 'Advanced', + 'Hidden', + ] as const + type GeneratedTypes0Visibility = + (typeof GeneratedTypes0Visibility_Names)[number] + interface FormValuesBase { + key: string + visibility: GeneratedTypes0Visibility + } + + interface ActivityCountFormValues extends FormValuesBase { + _type: 'ActivityCount' + activitySelector: ActivitySelectorFormData + daysOfWeek: DayOfWeek[] + label: string + } + + interface PlanningTimesFormValues extends FormValuesBase { + _type: 'PlanningTimes' + showTarget: boolean + showPlanned: boolean + showDiff: boolean + } + + type EditorValues = ActivityCountFormValues | PlanningTimesFormValues + interface EditorFormValues { + editors: Record + ordering: string[] + } + + const ExampleUsage = withForm({ + props: { + initialValues: '' as keyof EditorFormValues['editors'], + }, + defaultValues: {} as EditorFormValues, + render: ({ form, initialValues }) => { + return ( +
+ + {(field) => { + expectTypeOf(field().state.value).toExtend() + return null + }} + + + + +
+ ) + }, + }) + }) + + it('types should be properly inferred when using formOptions', () => { + type Person = { + firstName: string + lastName: string + } + + const formOpts = formOptions({ + defaultValues: { + firstName: 'FirstName', + lastName: 'LastName', + } as Person, + }) + + const WithFormComponent = withForm({ + ...formOpts, + render: ({ form }) => { + expectTypeOf(form.state.values).toEqualTypeOf() + return + }, + }) + }) + + it('types should be properly inferred when passing args alongside formOptions', () => { + type Person = { + firstName: string + lastName: string + } + + const formOpts = formOptions({ + defaultValues: { + firstName: 'FirstName', + lastName: 'LastName', + } as Person, + }) + + const WithFormComponent = withForm({ + ...formOpts, + onSubmitMeta: { + test: 'test', + }, + render: ({ form }) => { + expectTypeOf(form.handleSubmit).toEqualTypeOf<{ + (): Promise + (submitMeta: { test: string }): Promise + }> + return + }, + }) + }) + + it('types should be properly inferred when formOptions are being overridden', () => { + type Person = { + firstName: string + lastName: string + } + + type PersonWithAge = Person & { + age: number + } + + const formOpts = formOptions({ + defaultValues: { + firstName: 'FirstName', + lastName: 'LastName', + } as Person, + }) + + const WithFormComponent = withForm({ + ...formOpts, + defaultValues: { + firstName: 'FirstName', + lastName: 'LastName', + age: 10, + }, + render: ({ form }) => { + expectTypeOf(form.state.values).toExtend() + return + }, + }) + }) + + it('withForm props should be properly inferred', () => { + const WithFormComponent = withForm({ + props: { + prop1: 'test', + prop2: 10, + }, + render: ({ form, ...props }) => { + expectTypeOf(props).toEqualTypeOf<{ + prop1: string + prop2: number + children?: JSX.Element + }>() + return + }, + }) + }) + + it("component made from withForm should have it's props properly typed", () => { + const formOpts = formOptions({ + defaultValues: { + firstName: 'FirstName', + lastName: 'LastName', + }, + }) + + const appForm = useAppForm(() => formOpts) + + const WithFormComponent = withForm({ + ...formOpts, + props: { + prop1: 'test', + prop2: 10, + }, + render: ({ form, children, ...props }) => { + expectTypeOf(props).toEqualTypeOf<{ + prop1: string + prop2: number + }>() + return + }, + }) + + const CorrectComponent = ( + + ) + + // @ts-expect-error Missing required props prop1 and prop2 + const MissingPropsComponent = + + const incorrectFormOpts = formOptions({ + defaultValues: { + firstName: 'FirstName', + lastName: 'LastName', + firstNameWrong: 'FirstName', + lastNameWrong: 'LastName', + }, + }) + + const incorrectAppForm = useAppForm(() => incorrectFormOpts) + + const IncorrectFormOptsComponent = ( + // @ts-expect-error Incorrect form opts + + ) + }) +}) diff --git a/packages/solid-form/tests/createFormHook.test.tsx b/packages/solid-form/tests/createFormHook.test.tsx new file mode 100644 index 000000000..60025aa9c --- /dev/null +++ b/packages/solid-form/tests/createFormHook.test.tsx @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' +import { render } from '@solidjs/testing-library' +import { formOptions } from '@tanstack/form-core' +import { createFormHook, createFormHookContexts } from '../src' + +const { fieldContext, useFieldContext, formContext, useFormContext } = + createFormHookContexts() + +function TextField({ label }: { label: string }) { + const field = useFieldContext() + return ( + + ) +} + +function SubscribeButton({ label }: { label: string }) { + const form = useFormContext() + return ( + state.isSubmitting}> + {(isSubmitting) => } + + ) +} + +const { useAppForm, withForm } = createFormHook({ + fieldComponents: { + TextField, + }, + formComponents: { + SubscribeButton, + }, + fieldContext, + formContext, +}) + +describe('createFormHook', () => { + it('should allow to set default value', () => { + type Person = { + firstName: string + lastName: string + } + + function Comp() { + const form = useAppForm(() => ({ + defaultValues: { + firstName: 'FirstName', + lastName: 'LastName', + } as Person, + })) + + return ( + <> + } + /> + + ) + } + + const { getByLabelText } = render(() => ) + const input = getByLabelText('Testing') + expect(input).toHaveValue('FirstName') + }) + + it('should handle withForm types properly', () => { + const formOpts = formOptions({ + defaultValues: { + firstName: 'John', + lastName: 'Doe', + }, + }) + + const ChildForm = withForm({ + ...formOpts, + // Optional, but adds props to the `render` function outside of `form` + props: { + title: 'Child Form', + }, + render: ({ form, title }) => { + return ( +
+

{title}

+ } + /> + + + +
+ ) + }, + }) + + const Parent = () => { + const form = useAppForm(() => ({ + ...formOpts, + })) + + return + } + + const { getByLabelText, getByText } = render(() => ) + const input = getByLabelText('First Name') + expect(input).toHaveValue('John') + expect(getByText('Testing')).toBeInTheDocument() + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2e0cc083..a69730771 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -674,6 +674,25 @@ importers: specifier: ^2.11.6 version: 2.11.6(@testing-library/jest-dom@6.6.3)(solid-js@1.9.7)(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(less@4.3.0)(sass@1.89.1)(sugarss@4.0.1(postcss@8.5.5))(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)) + examples/solid/large-form: + dependencies: + '@tanstack/solid-form': + specifier: ^1.12.4 + version: link:../../../packages/solid-form + solid-js: + specifier: ^1.9.7 + version: 1.9.7 + devDependencies: + typescript: + specifier: 5.8.2 + version: 5.8.2 + vite: + specifier: ^6.3.5 + version: 6.3.5(@types/node@22.15.29)(jiti@2.4.2)(less@4.3.0)(sass@1.89.1)(sugarss@4.0.1(postcss@8.5.5))(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0) + vite-plugin-solid: + specifier: ^2.11.6 + version: 2.11.6(@testing-library/jest-dom@6.6.3)(solid-js@1.9.7)(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(less@4.3.0)(sass@1.89.1)(sugarss@4.0.1(postcss@8.5.5))(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)) + examples/solid/simple: dependencies: '@tanstack/solid-form':