diff --git a/docs/framework/solid/guides/form-composition.md b/docs/framework/solid/guides/form-composition.md
index 9d2c92e7c..af7c66de6 100644
--- a/docs/framework/solid/guides/form-composition.md
+++ b/docs/framework/solid/guides/form-composition.md
@@ -216,6 +216,238 @@ function App() {
While hooks are the future of Solid, 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.
+## Reusing groups of fields in multiple forms
+
+Sometimes, a pair of fields are so closely related that it makes sense to group and reuse them — like the password example listed in the [linked fields guide](../linked-fields.md). Instead of repeating this logic across multiple forms, you can utilize the `withFieldGroup` higher-order component.
+
+> Unlike `withForm`, validators cannot be specified and could be any value.
+> Ensure that your fields can accept unknown error types.
+
+Rewriting the passwords example using `withFieldGroup` would look like this:
+
+```tsx
+const { useAppForm, withForm, withFieldGroup } = createFormHook({
+ fieldComponents: {
+ TextField,
+ ErrorInfo,
+ },
+ formComponents: {
+ SubscribeButton,
+ },
+ fieldContext,
+ formContext,
+})
+
+type PasswordFields = {
+ password: string
+ confirm_password: string
+}
+
+// These default values are not used at runtime, but the keys are needed for mapping purposes.
+// This allows you to spread `formOptions` without needing to redeclare it.
+const defaultValues: PasswordFields = {
+ password: '',
+ confirm_password: '',
+}
+
+const FieldGroupPasswordFields = withFieldGroup({
+ defaultValues,
+ // You may also restrict the group to only use forms that implement this submit meta.
+ // If none is provided, any form with the right defaultValues may use it.
+ // onSubmitMeta: { action: '' }
+
+ // Optional, but adds props to the `render` function in addition to `form`
+ props: {
+ // These default values are also for type-checking and are not used at runtime
+ title: 'Password',
+ },
+ // Internally, you will have access to a `group` instead of a `form`
+ render: function Render({ group, title }) {
+ // access reactive values using the group store
+ const password = useStore(group.store, (state) => state.values.password)
+ // or the form itself
+ const isSubmitting = useStore(
+ group.form.store,
+ (state) => state.isSubmitting,
+ )
+
+ return (
+
+
{title}
+ {/* Groups also have access to Field, Subscribe, Field, AppField and AppForm */}
+
+ {(field) => }
+
+ {
+ // The form could be any values, so it is typed as 'unknown'
+ const values: unknown = fieldApi.form.state.values
+ // use the group methods instead
+ if (value !== group.getFieldValue('password')) {
+ return 'Passwords do not match'
+ }
+ return undefined
+ },
+ }}
+ >
+ {(field) => (
+
+
+
+
+ )}
+
+
+ )
+ },
+})
+```
+
+We can now use these grouped fields in any form that implements the default values:
+
+```tsx
+// You are allowed to extend the group fields as long as the
+// existing properties remain unchanged
+type Account = PasswordFields & {
+ provider: string
+ username: string
+}
+
+// You may nest the group fields wherever you want
+type FormValues = {
+ name: string
+ age: number
+ account_data: PasswordFields
+ linked_accounts: Account[]
+}
+
+const defaultValues: FormValues = {
+ name: '',
+ age: 0,
+ account_data: {
+ password: '',
+ confirm_password: '',
+ },
+ linked_accounts: [
+ {
+ provider: 'TanStack',
+ username: '',
+ password: '',
+ confirm_password: '',
+ },
+ ],
+}
+
+function App() {
+ const form = useAppForm({
+ defaultValues,
+ // If the group didn't specify an `onSubmitMeta` property,
+ // the form may implement any meta it wants.
+ // Otherwise, the meta must be defined and match.
+ onSubmitMeta: { action: '' },
+ })
+
+ return (
+
+
+
+ {(field) =>
+ field.state.value.map((account, i) => (
+
+ ))
+ }
+
+
+ )
+}
+```
+
+### Mapping field group values to a different field
+
+You may want to keep the password fields on the top level of your form, or rename the properties for clarity. You can map field group values
+to their true location by changing the `field` property:
+
+> [!IMPORTANT]
+> Due to TypeScript limitations, field mapping is only allowed for objects. You can use records or arrays at the top level of a field group, but you will not be able to map the fields.
+
+```tsx
+// To have an easier form, you can keep the fields on the top level
+type FormValues = {
+ name: string
+ age: number
+ password: string
+ confirm_password: string
+}
+
+const defaultValues: FormValues = {
+ name: '',
+ age: 0,
+ password: '',
+ confirm_password: '',
+}
+
+function App() {
+ const form = useAppForm({
+ defaultValues,
+ })
+
+ return (
+
+
+
+ )
+}
+```
+
+If you expect your fields to always be at the top level of your form, you can create a quick map
+of your field groups using a helper function:
+
+```tsx
+const defaultValues: PasswordFields = {
+ password: '',
+ confirm_password: '',
+}
+
+const passwordFields = createFieldMap(defaultValues)
+/* This generates the following map:
+ {
+ 'password': 'password',
+ 'confirm_password': 'confirm_password'
+ }
+*/
+
+// Usage:
+
+```
+
## 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.
diff --git a/docs/framework/solid/reference/functions/createformhook.md b/docs/framework/solid/reference/functions/createformhook.md
index a36b3ac77..60d00774a 100644
--- a/docs/framework/solid/reference/functions/createformhook.md
+++ b/docs/framework/solid/reference/functions/createformhook.md
@@ -124,3 +124,122 @@ withForm: (__namedParameters) => (params) => Element;
+```
+
+#### Type Parameters
+
+• **TFieldGroupData**
+
+• **TSubmitMeta**
+
+• **TRenderProps** *extends* `Record`\<`string`, `unknown`\> = \{\}
+
+#### Parameters
+
+##### \_\_namedParameters
+
+[`WithFieldGroupProps`](../../interfaces/withfieldgroupprops.md)\<`TFieldGroupData`, `TComponents`, `TFormComponents`, `TSubmitMeta`, `TRenderProps`\>
+
+#### Returns
+
+`Function`
+
+##### Type Parameters
+
+• **TFormData**
+
+• **TFields** *extends*
+ \| `string`
+ \| \{ \[K in string \| number \| symbol\]: DeepKeysOfType\ \}
+
+• **TOnMount** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\>
+
+• **TOnChange** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\>
+
+• **TOnChangeAsync** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\>
+
+• **TOnBlur** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\>
+
+• **TOnBlurAsync** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\>
+
+• **TOnSubmit** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\>
+
+• **TOnSubmitAsync** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\>
+
+• **TOnDynamic** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\>
+
+• **TOnDynamicAsync** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\>
+
+• **TOnServer** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\>
+
+• **TFormSubmitMeta**
+
+##### Parameters
+
+###### params
+
+`PropsWithChildren`\<`NoInfer`\<`TRenderProps`\> & `object`\>
+
+##### Returns
+
+`Element`
+
+### withForm()
+
+```ts
+withForm: (__namedParameters) => (props) => Element;
+```
+
+#### Type Parameters
+
+• **TFormData**
+
+• **TOnMount** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\>
+
+• **TOnChange** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\>
+
+• **TOnChangeAsync** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\>
+
+• **TOnBlur** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\>
+
+• **TOnBlurAsync** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\>
+
+• **TOnSubmit** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\>
+
+• **TOnSubmitAsync** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\>
+
+• **TOnDynamic** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\>
+
+• **TOnDynamicAsync** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\>
+
+• **TOnServer** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\>
+
+• **TSubmitMeta**
+
+• **TRenderProps** *extends* `object` = \{\}
+
+#### Parameters
+
+##### \_\_namedParameters
+
+[`WithFormProps`](../../interfaces/withformprops.md)\<`TFormData`, `TOnMount`, `TOnChange`, `TOnChangeAsync`, `TOnBlur`, `TOnBlurAsync`, `TOnSubmit`, `TOnSubmitAsync`, `TOnDynamic`, `TOnDynamicAsync`, `TOnServer`, `TSubmitMeta`, `TComponents`, `TFormComponents`, `TRenderProps`\>
+
+#### Returns
+
+`Function`
+
+##### Parameters
+
+###### props
+
+`PropsWithChildren`\<`NoInfer`\<`UnwrapOrAny`\<`TRenderProps`\>\> & `object`\>
+
+##### Returns
+
+`Element`
\ No newline at end of file
diff --git a/docs/framework/solid/reference/functions/usefieldgroup.md b/docs/framework/solid/reference/functions/usefieldgroup.md
new file mode 100644
index 000000000..1b5b3a8fa
--- /dev/null
+++ b/docs/framework/solid/reference/functions/usefieldgroup.md
@@ -0,0 +1,79 @@
+---
+id: useFieldGroup
+title: useFieldGroup
+---
+
+
+
+# Function: useFieldGroup()
+
+```ts
+function useFieldGroup(opts): AppFieldExtendedReactFieldGroupApi
+```
+
+Defined in: [packages/react-form/src/useFieldGroup.tsx:89](https://github.com/TanStack/form/blob/main/packages/react-form/src/useFieldGroup.tsx#L89)
+
+## Type Parameters
+
+• **TFormData**
+
+• **TFieldGroupData**
+
+• **TFields** *extends*
+ \| `string`
+ \| \{ \[K in string \| number \| symbol\]: DeepKeysOfType\ \}
+
+• **TOnMount** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\>
+
+• **TOnChange** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\>
+
+• **TOnChangeAsync** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\>
+
+• **TOnBlur** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\>
+
+• **TOnBlurAsync** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\>
+
+• **TOnSubmit** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\>
+
+• **TOnSubmitAsync** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\>
+
+• **TOnDynamic** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\>
+
+• **TOnDynamicAsync** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\>
+
+• **TOnServer** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\>
+
+• **TComponents** *extends* `Record`\<`string`, `ComponentType`\<`any`\>\>
+
+• **TFormComponents** *extends* `Record`\<`string`, `ComponentType`\<`any`\>\>
+
+• **TSubmitMeta** = `never`
+
+## Parameters
+
+### opts
+
+#### defaultValues?
+
+`TFieldGroupData`
+
+#### fields
+
+`TFields`
+
+#### form
+
+ \| `AppFieldExtendedReactFormApi`\<`TFormData`, `TOnMount`, `TOnChange`, `TOnChangeAsync`, `TOnBlur`, `TOnBlurAsync`, `TOnSubmit`, `TOnSubmitAsync`, `TOnDynamic`, `TOnDynamicAsync`, `TOnServer`, `TSubmitMeta`, `TComponents`, `TFormComponents`\>
+ \| `AppFieldExtendedReactFieldGroupApi`\<`unknown`, `TFormData`, `string` \| `FieldsMap`\<`unknown`, `TFormData`\>, `any`, `any`, `any`, `any`, `any`, `any`, `any`, `any`, `any`, `any`, `TSubmitMeta`, `TComponents`, `TFormComponents`\>
+
+#### formComponents
+
+`TFormComponents`
+
+#### onSubmitMeta?
+
+`TSubmitMeta`
+
+## Returns
+
+`AppFieldExtendedReactFieldGroupApi`\<`TFormData`, `TFieldGroupData`, `TFields`, `TOnMount`, `TOnChange`, `TOnChangeAsync`, `TOnBlur`, `TOnBlurAsync`, `TOnSubmit`, `TOnSubmitAsync`, `TOnDynamic`, `TOnDynamicAsync`, `TOnServer`, `TSubmitMeta`, `TComponents`, `TFormComponents`\>
diff --git a/docs/framework/solid/reference/interfaces/withfieldgroupprops.md b/docs/framework/solid/reference/interfaces/withfieldgroupprops.md
new file mode 100644
index 000000000..46536312b
--- /dev/null
+++ b/docs/framework/solid/reference/interfaces/withfieldgroupprops.md
@@ -0,0 +1,56 @@
+---
+id: WithFieldGroupProps
+title: WithFieldGroupProps
+---
+
+
+
+# Interface: WithFieldGroupProps\
+
+Defined in: [packages/react-form/src/createFormHook.tsx:247](https://github.com/TanStack/form/blob/main/packages/solid-form/src/createFormHook.tsx#L247)
+
+## Extends
+
+- `BaseFormOptions`\<`TFieldGroupData`, `TSubmitMeta`\>
+
+## Type Parameters
+
+• **TFieldGroupData**
+
+• **TFieldComponents** *extends* `Record`\<`string`, `ComponentType`\<`any`\>\>
+
+• **TFormComponents** *extends* `Record`\<`string`, `ComponentType`\<`any`\>\>
+
+• **TSubmitMeta**
+
+• **TRenderProps** *extends* `Record`\<`string`, `unknown`\> = `Record`\<`string`, `never`\>
+
+## Properties
+
+### props?
+
+```ts
+optional props: TRenderProps;
+```
+
+Defined in: [packages/react-form/src/createFormHook.tsx:255](https://github.com/TanStack/form/blob/main/packages/react-form/src/createFormHook.tsx#L255)
+
+***
+
+### render()
+
+```ts
+render: (props) => Element;
+```
+
+Defined in: [packages/react-form/src/createFormHook.tsx:256](https://github.com/TanStack/form/blob/main/packages/react-form/src/createFormHook.tsx#L256)
+
+#### Parameters
+
+##### props
+
+`PropsWithChildren`\<`NoInfer`\<`TRenderProps`\> & `object`\>
+
+#### Returns
+
+`Element`
diff --git a/packages/react-form/src/createFormHook.tsx b/packages/react-form/src/createFormHook.tsx
index 548fc2322..79957cf95 100644
--- a/packages/react-form/src/createFormHook.tsx
+++ b/packages/react-form/src/createFormHook.tsx
@@ -179,7 +179,7 @@ export type AppFieldExtendedReactFormApi<
TOnBlurAsync,
TOnSubmit,
TOnSubmitAsync,
- TOnDynamic,
+ TOnDynamic,
TOnDynamicAsync,
TOnServer,
TSubmitMeta,
diff --git a/packages/solid-form/src/createField.tsx b/packages/solid-form/src/createField.tsx
index de263f00c..9242a287a 100644
--- a/packages/solid-form/src/createField.tsx
+++ b/packages/solid-form/src/createField.tsx
@@ -11,6 +11,7 @@ import type {
DeepValue,
FieldAsyncValidateOrFn,
FieldValidateOrFn,
+ FieldValidators,
FormAsyncValidateOrFn,
FormValidateOrFn,
Narrow,
@@ -539,6 +540,94 @@ export type FieldComponent<
ExtendedApi
>) => JSX.Element
+
+/**
+ * A type alias representing a field component for a form lens data type.
+ */
+export type LensFieldComponent<
+ in out TLensData,
+ in out TParentSubmitMeta,
+ in out ExtendedApi = {},
+> = <
+ const TName extends DeepKeys,
+ TData extends DeepValue,
+ TOnMount extends undefined | FieldValidateOrFn,
+ TOnChange extends undefined | FieldValidateOrFn,
+ TOnChangeAsync extends
+ | undefined
+ | FieldAsyncValidateOrFn,
+ TOnBlur extends undefined | FieldValidateOrFn,
+ TOnBlurAsync extends
+ | undefined
+ | FieldAsyncValidateOrFn,
+ TOnSubmit extends undefined | FieldValidateOrFn,
+ TOnSubmitAsync extends
+ | undefined
+ | FieldAsyncValidateOrFn,
+ TOnDynamic extends undefined | FieldValidateOrFn,
+ TOnDynamicAsync extends
+ | undefined
+ | FieldAsyncValidateOrFn,
+>({
+ children,
+ ...fieldOptions
+}: Omit<
+ FieldComponentBoundProps<
+ unknown,
+ string,
+ TData,
+ TOnMount,
+ TOnChange,
+ TOnChangeAsync,
+ TOnBlur,
+ TOnBlurAsync,
+ TOnSubmit,
+ TOnSubmitAsync,
+ TOnDynamic,
+ TOnDynamicAsync,
+ undefined | FormValidateOrFn,
+ undefined | FormValidateOrFn,
+ undefined | FormAsyncValidateOrFn,
+ undefined | FormValidateOrFn,
+ undefined | FormAsyncValidateOrFn,
+ undefined | FormValidateOrFn,
+ undefined | FormAsyncValidateOrFn,
+ undefined | FormValidateOrFn,
+ undefined | FormAsyncValidateOrFn,
+ undefined | FormAsyncValidateOrFn,
+ TParentSubmitMeta,
+ ExtendedApi
+ >,
+ 'name' | 'validators'
+> & {
+ name: TName
+ validators?: Omit<
+ FieldValidators<
+ unknown,
+ string,
+ TData,
+ TOnMount,
+ TOnChange,
+ TOnChangeAsync,
+ TOnBlur,
+ TOnBlurAsync,
+ TOnSubmit,
+ TOnSubmitAsync,
+ TOnDynamic,
+ TOnDynamicAsync
+ >,
+ 'onChangeListenTo' | 'onBlurListenTo'
+ > & {
+ /**
+ * An optional list of field names that should trigger this field's `onChange` and `onChangeAsync` events when its value changes
+ */
+ onChangeListenTo?: DeepKeys[]
+ /**
+ * An optional list of field names that should trigger this field's `onBlur` and `onBlurAsync` events when its value changes
+ */
+ onBlurListenTo?: DeepKeys[]
+ }
+}) => JSXElement
interface FieldComponentProps<
TParentData,
TName extends DeepKeys,
diff --git a/packages/solid-form/src/createFieldGroup.tsx b/packages/solid-form/src/createFieldGroup.tsx
new file mode 100644
index 000000000..0ecff7236
--- /dev/null
+++ b/packages/solid-form/src/createFieldGroup.tsx
@@ -0,0 +1,250 @@
+import {
+ AnyFieldGroupApi,
+ DeepKeysOfType,
+ FieldGroupApi,
+ FieldGroupState,
+ FieldsMap,
+ FormAsyncValidateOrFn,
+ FormValidateOrFn,
+ functionalUpdate,
+} from "@tanstack/form-core";
+import { Accessor, Component, createRenderEffect, createSignal, JSX, ParentComponent, ParentProps } from "solid-js";
+import { LensFieldComponent } from "./createField";
+import { AppFieldExtendedSolidFormApi } from "./createFormHook"
+import { useStore } from "@tanstack/solid-store";
+
+function LocalSubscribe({
+ lens,
+ selector,
+ children,
+}: ParentProps<{
+ lens: AnyFieldGroupApi;
+ selector: (state: FieldGroupState) => FieldGroupState;
+}>) {
+ const data = useStore(lens.store, selector);
+
+ return functionalUpdate(children, data);
+}
+
+/**
+ * @private
+ */
+export type AppFieldExtendedSolidFieldGroupApi<
+ TFormData,
+ TFieldGroupData,
+ TFields extends
+ | DeepKeysOfType
+ | FieldsMap,
+ 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,
+ TOnDynamic extends undefined | FormValidateOrFn,
+ TOnDynamicAsync extends undefined | FormAsyncValidateOrFn,
+ TOnServer extends undefined | FormAsyncValidateOrFn,
+ TSubmitMeta,
+ TFieldComponents extends Record>,
+ TFormComponents extends Record>,
+> = FieldGroupApi<
+ TFormData,
+ TFieldGroupData,
+ TFields,
+ TOnMount,
+ TOnChange,
+ TOnChangeAsync,
+ TOnBlur,
+ TOnBlurAsync,
+ TOnSubmit,
+ TOnSubmitAsync,
+ TOnDynamic,
+ TOnDynamicAsync,
+ TOnServer,
+ TSubmitMeta
+> &
+ NoInfer & {
+ AppField: LensFieldComponent<
+ TFieldGroupData,
+ TSubmitMeta,
+ NoInfer
+ >;
+ AppForm: ParentComponent;
+ /**
+ * A React component to render form fields. With this, you can render and manage individual form fields.
+ */
+ Field: LensFieldComponent;
+
+ /**
+ * A `Subscribe` function that allows you to listen and react to changes in the form's state. It's especially useful when you need to execute side effects or render specific components in response to state updates.
+ */
+ Subscribe: >>(props: {
+ selector?: (
+ state: NoInfer>,
+ ) => TSelected;
+ children: ((state: NoInfer) => JSX.Element) | JSX.Element;
+ }) => JSX.Element;
+ };
+
+export function createFieldGroup<
+ TFormData,
+ TFieldGroupData,
+ TFields extends
+ | DeepKeysOfType
+ | FieldsMap,
+ 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,
+ TOnDynamic extends undefined | FormValidateOrFn,
+ TOnDynamicAsync extends undefined | FormAsyncValidateOrFn,
+ TOnServer extends undefined | FormAsyncValidateOrFn,
+ TComponents extends Record>,
+ TFormComponents extends Record>,
+ TSubmitMeta = never,
+>(opts: {
+ form:
+ | AppFieldExtendedSolidFormApi<
+ TFormData,
+ TOnMount,
+ TOnChange,
+ TOnChangeAsync,
+ TOnBlur,
+ TOnBlurAsync,
+ TOnSubmit,
+ TOnSubmitAsync,
+ TOnDynamic,
+ TOnDynamicAsync,
+ TOnServer,
+ TSubmitMeta,
+ TComponents,
+ TFormComponents
+ >
+ | AppFieldExtendedSolidFieldGroupApi<
+ // Since this only occurs if you nest it within other form lenses, it can be more
+ // lenient with the types.
+ unknown,
+ TFormData,
+ string | FieldsMap,
+ any,
+ any,
+ any,
+ any,
+ any,
+ any,
+ any,
+ any,
+ any,
+ any,
+ TSubmitMeta,
+ TComponents,
+ TFormComponents
+ >;
+ fields: TFields;
+ defaultValues?: TFieldGroupData;
+ onSubmitMeta?: TSubmitMeta;
+ formComponents: TFormComponents;
+}): Accessor> {
+ const [formLensApi] = createSignal((() => {
+ const api = new FieldGroupApi(opts);
+ const form =
+ opts.form instanceof FieldGroupApi
+ ? (opts.form.form as AppFieldExtendedSolidFormApi<
+ TFormData,
+ TOnMount,
+ TOnChange,
+ TOnChangeAsync,
+ TOnBlur,
+ TOnBlurAsync,
+ TOnSubmit,
+ TOnSubmitAsync,
+ TOnDynamic,
+ TOnDynamicAsync,
+ TOnServer,
+ TSubmitMeta,
+ TComponents,
+ TFormComponents
+ >)
+ : opts.form;
+
+ const extendedApi: AppFieldExtendedSolidFieldGroupApi<
+ TFormData,
+ TFieldGroupData,
+ TFields,
+ TOnMount,
+ TOnChange,
+ TOnChangeAsync,
+ TOnBlur,
+ TOnBlurAsync,
+ TOnSubmit,
+ TOnSubmitAsync,
+ TOnDynamic,
+ TOnDynamicAsync,
+ TOnServer,
+ TSubmitMeta,
+ TComponents,
+ TFormComponents
+ > = api as never;
+ extendedApi.AppForm = function AppForm(appFormProps) {
+ return
+ }
+
+ extendedApi.AppField = function AppField(props) {
+ return
+ }
+
+ extendedApi.Field = function Field(props) {
+ return
+ }
+
+ extendedApi.Subscribe = function Subscribe(props: any) {
+ return
+ }
+ return Object.assign(extendedApi, {
+ ...opts.formComponents,
+ }) as AppFieldExtendedSolidFieldGroupApi<
+ TFormData,
+ TFieldGroupData,
+ TFields,
+ TOnMount,
+ TOnChange,
+ TOnChangeAsync,
+ TOnBlur,
+ TOnBlurAsync,
+ TOnSubmit,
+ TOnSubmitAsync,
+ TOnDynamic,
+ TOnDynamicAsync,
+ TOnServer,
+ TSubmitMeta,
+ TComponents,
+ TFormComponents
+ >;
+ })())
+
+ createRenderEffect(() => {
+ formLensApi().mount()
+ })
+ return formLensApi;
+}
\ No newline at end of file
diff --git a/packages/solid-form/src/createFormHook.tsx b/packages/solid-form/src/createFormHook.tsx
index 9b02050e1..1c02ff129 100644
--- a/packages/solid-form/src/createFormHook.tsx
+++ b/packages/solid-form/src/createFormHook.tsx
@@ -1,9 +1,12 @@
-import { createContext, splitProps, useContext } from 'solid-js'
+import { createContext, createMemo, splitProps, useContext } from 'solid-js'
import { createForm } from './createForm'
import type {
AnyFieldApi,
AnyFormApi,
+ BaseFormOptions,
+ DeepKeysOfType,
FieldApi,
+ FieldsMap,
FormAsyncValidateOrFn,
FormOptions,
FormValidateOrFn,
@@ -17,6 +20,7 @@ import type {
} from 'solid-js'
import type { FieldComponent } from './createField'
import type { SolidFormExtendedApi } from './createForm'
+import { AppFieldExtendedSolidFieldGroupApi, createFieldGroup } from './createFieldGroup'
/**
* TypeScript inferencing is weird.
@@ -51,8 +55,8 @@ import type { SolidFormExtendedApi } from './createForm'
type UnwrapOrAny = [unknown] extends [T] ? any : T
type UnwrapDefaultOrAny = [DefaultT] extends [T]
? [T] extends [DefaultT]
- ? any
- : T
+ ? any
+ : T
: T
export function createFormHookContexts() {
@@ -143,7 +147,10 @@ interface CreateFormHookProps<
formContext: Context
}
-type AppFieldExtendedSolidFormApi<
+/**
+ * @private
+ */
+export type AppFieldExtendedSolidFormApi<
TFormData,
TOnMount extends undefined | FormValidateOrFn,
TOnChange extends undefined | FormValidateOrFn,
@@ -208,19 +215,19 @@ export interface WithFormProps<
TFormComponents extends Record>,
TRenderProps extends Record = Record,
> extends FormOptions<
- TFormData,
- TOnMount,
- TOnChange,
- TOnChangeAsync,
- TOnBlur,
- TOnBlurAsync,
- TOnSubmit,
- TOnSubmitAsync,
- TOnDynamic,
- TOnDynamicAsync,
- TOnServer,
- TSubmitMeta
- > {
+ TFormData,
+ TOnMount,
+ TOnChange,
+ TOnChangeAsync,
+ TOnBlur,
+ TOnBlurAsync,
+ TOnSubmit,
+ TOnSubmitAsync,
+ TOnDynamic,
+ TOnDynamicAsync,
+ TOnServer,
+ TSubmitMeta
+> {
// Optional, but adds props to the `render` function outside of `form`
props?: TRenderProps
render: (
@@ -247,6 +254,43 @@ export interface WithFormProps<
) => JSXElement
}
+export interface WithFieldGroupProps<
+ TFieldGroupData,
+ TFieldComponents extends Record>,
+ TFormComponents extends Record>,
+ TSubmitMeta,
+ TRenderProps extends Record = Record,
+> extends BaseFormOptions {
+ // Optional, but adds props to the `render` function outside of `form`
+ props?: TRenderProps
+ render: (
+ props: ParentProps<
+ NoInfer & {
+ group: AppFieldExtendedSolidFieldGroupApi<
+ unknown,
+ TFieldGroupData,
+ string | FieldsMap,
+ undefined | FormValidateOrFn,
+ undefined | FormValidateOrFn,
+ undefined | FormAsyncValidateOrFn,
+ undefined | FormValidateOrFn,
+ undefined | FormAsyncValidateOrFn,
+ undefined | FormValidateOrFn,
+ undefined | FormAsyncValidateOrFn,
+ undefined | FormValidateOrFn,
+ undefined | FormAsyncValidateOrFn,
+ undefined | FormAsyncValidateOrFn,
+ // this types it as 'never' in the render prop. It should prevent any
+ // untyped meta passed to the handleSubmit by accident.
+ unknown extends TSubmitMeta ? never : TSubmitMeta,
+ TFieldComponents,
+ TFormComponents
+ >
+ }
+ >,
+ ) => JSXElement
+}
+
export function createFormHook<
const TComponents extends Record>,
const TFormComponents extends Record>,
@@ -354,7 +398,7 @@ export function createFormHook<
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
+ ; (extendedForm as Record)[key] = value
}
return extendedForm
@@ -416,8 +460,91 @@ export function createFormHook<
return (innerProps) => render({ ...props, ...innerProps })
}
+ function withFieldGroup<
+ TFieldGroupData,
+ TSubmitMeta,
+ TRenderProps extends Record = {},
+ >({
+ render,
+ defaultValues,
+ props,
+ }: WithFieldGroupProps<
+ TFieldGroupData,
+ TComponents,
+ TFormComponents,
+ TSubmitMeta,
+ TRenderProps
+ >): <
+ TFormData,
+ TFields extends
+ | DeepKeysOfType
+ | FieldsMap,
+ 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,
+ TOnDynamic extends undefined | FormValidateOrFn,
+ TOnDynamicAsync extends undefined | FormAsyncValidateOrFn,
+ TOnServer extends undefined | FormAsyncValidateOrFn,
+ TFormSubmitMeta,
+ >(
+ params: ParentProps<
+ NoInfer & {
+ form:
+ | AppFieldExtendedSolidFormApi<
+ TFormData,
+ TOnMount,
+ TOnChange,
+ TOnChangeAsync,
+ TOnBlur,
+ TOnBlurAsync,
+ TOnSubmit,
+ TOnSubmitAsync,
+ TOnDynamic,
+ TOnDynamicAsync,
+ TOnServer,
+ unknown extends TSubmitMeta ? TFormSubmitMeta : TSubmitMeta,
+ TComponents,
+ TFormComponents
+ >
+ | AppFieldExtendedSolidFieldGroupApi<
+ // Since this only occurs if you nest it within other field groups, it can be more
+ // lenient with the types.
+ unknown,
+ TFormData,
+ string | FieldsMap,
+ any,
+ any,
+ any,
+ any,
+ any,
+ any,
+ any,
+ any,
+ any,
+ any,
+ unknown extends TSubmitMeta ? TFormSubmitMeta : TSubmitMeta,
+ TComponents,
+ TFormComponents
+ >
+ fields: TFields
+ }
+ >,
+ ) => JSXElement {
+ return function Render(innerProps) {
+ const fieldGroupProps = createMemo(() => {
+ return { form: innerProps.form, fields: innerProps.fields, defaultValues, formComponents: opts.formComponents }
+ })
+ const fieldGroupApi = createFieldGroup(fieldGroupProps() as any);
+ return render({ ...props, ...innerProps, group: fieldGroupApi as any })
+ }
+ }
return {
useAppForm,
withForm,
+ withFieldGroup
}
}
diff --git a/packages/solid-form/tests/createFormHook.test-d.tsx b/packages/solid-form/tests/createFormHook.test-d.tsx
index 067c2be1b..b7f43c002 100644
--- a/packages/solid-form/tests/createFormHook.test-d.tsx
+++ b/packages/solid-form/tests/createFormHook.test-d.tsx
@@ -2,6 +2,7 @@ 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'
+import { JSXElement } from 'solid-js'
const { fieldContext, useFieldContext, formContext, useFormContext } =
createFormHookContexts()
@@ -10,7 +11,7 @@ function Test() {
return null
}
-const { useAppForm, withForm } = createFormHook({
+const { useAppForm, withForm, withFieldGroup } = createFormHook({
fieldComponents: {
Test,
},
@@ -249,4 +250,555 @@ describe('createFormHook', () => {
)
})
+
+ it('should infer subset values and props when calling withFieldGroup', () => {
+ type Person = {
+ firstName: string
+ lastName: string
+ }
+ type ComponentProps = {
+ prop1: string
+ prop2: number
+ }
+
+ const defaultValues: Person = {
+ firstName: 'FirstName',
+ lastName: 'LastName',
+ }
+
+ const FormGroupComponent = withFieldGroup({
+ defaultValues,
+ render: function Render({ group, children, ...props }) {
+ // Existing types may be inferred
+ expectTypeOf(group.state.values.firstName).toEqualTypeOf()
+ expectTypeOf(group.state.values.lastName).toEqualTypeOf()
+
+ expectTypeOf(group.state.values).toEqualTypeOf()
+ expectTypeOf(children).toEqualTypeOf()
+ expectTypeOf(props).toEqualTypeOf<{}>()
+ return
+ },
+ })
+
+ const FormGroupComponentWithProps = withFieldGroup({
+ ...defaultValues,
+ props: {} as ComponentProps,
+ render: ({ group, children, ...props }) => {
+ expectTypeOf(props).toEqualTypeOf<{
+ prop1: string
+ prop2: number
+ }>()
+ return
+ },
+ })
+ })
+
+ it('should allow spreading formOptions when calling withFieldGroup', () => {
+ type Person = {
+ firstName: string
+ lastName: string
+ }
+
+ const defaultValues: Person = {
+ firstName: '',
+ lastName: '',
+ }
+ const formOpts = formOptions({
+ defaultValues,
+ validators: {
+ onChange: () => 'Error',
+ },
+ listeners: {
+ onBlur: () => 'Something',
+ },
+ asyncAlways: true,
+ asyncDebounceMs: 500,
+ })
+
+ // validators and listeners are ignored, only defaultValues is acknowledged
+ const FormGroupComponent = withFieldGroup({
+ ...formOpts,
+ render: function Render({ group }) {
+ // Existing types may be inferred
+ expectTypeOf(group.state.values.firstName).toEqualTypeOf()
+ expectTypeOf(group.state.values.lastName).toEqualTypeOf()
+ return
+ },
+ })
+
+ const noDefaultValuesFormOpts = formOptions({
+ onSubmitMeta: { foo: '' },
+ })
+
+ const UnknownFormGroupComponent = withFieldGroup({
+ ...noDefaultValuesFormOpts,
+ render: function Render({ group }) {
+ // group.state.values can be anything.
+ // note that T extends unknown !== unknown extends T.
+ expectTypeOf().toExtend()
+
+ // either no submit meta or of the type in formOptions
+ expectTypeOf(group.handleSubmit).parameters.toEqualTypeOf<
+ [] | [{ foo: string }]
+ >()
+ return
+ },
+ })
+ })
+
+ it('should allow passing compatible forms to withFieldGroup', () => {
+ type Person = {
+ firstName: string
+ lastName: string
+ }
+ type ComponentProps = {
+ prop1: string
+ prop2: number
+ }
+
+ const defaultValues: Person = {
+ firstName: 'FirstName',
+ lastName: 'LastName',
+ }
+
+ const FormGroup = withFieldGroup({
+ defaultValues,
+ props: {} as ComponentProps,
+ render: () => {
+ return <>>
+ },
+ })
+
+ const equalAppForm = useAppForm(() => ({
+ defaultValues,
+ }))
+
+ // -----------------
+ // Assert that an equal form is not compatible as you have no name to pass
+ const NoSubfield = (
+
+ )
+
+ // -----------------
+ // Assert that a form extending Person in a property is allowed
+
+ const extendedAppForm = useAppForm(() => ({
+ defaultValues: { person: { ...defaultValues, address: '' }, address: '' },
+ }))
+ // While it has other properties, it satisfies defaultValues
+ const CorrectComponent1 = (
+
+ )
+
+ const MissingProps = (
+ // @ts-expect-error because prop1 and prop2 are not added
+
+ )
+
+ // -----------------
+ // Assert that a form not satisfying Person errors
+ const incompatibleAppForm = useAppForm(() => ({
+ defaultValues: { person: { ...defaultValues, lastName: 0 } },
+ }))
+ const IncompatibleComponent = (
+
+ )
+ })
+
+ it('should require strict equal submitMeta if it is set in withFieldGroup', () => {
+ type Person = {
+ firstName: string
+ lastName: string
+ }
+ type SubmitMeta = {
+ correct: string
+ }
+
+ const defaultValues = {
+ person: { firstName: 'FirstName', lastName: 'LastName' } as Person,
+ }
+ const onSubmitMeta: SubmitMeta = {
+ correct: 'Prop',
+ }
+
+ const FormLensNoMeta = withFieldGroup({
+ defaultValues: {} as Person,
+ render: function Render({ group }) {
+ // Since handleSubmit always allows to submit without meta, this is okay
+ group.handleSubmit()
+
+ // To prevent unwanted meta behaviour, handleSubmit's meta should be never if not set.
+ expectTypeOf(group.handleSubmit).parameters.toEqualTypeOf<
+ [] | [submitMeta: never]
+ >()
+
+ return
+ },
+ })
+
+ const FormGroupWithMeta = withFieldGroup({
+ defaultValues: {} as Person,
+ onSubmitMeta,
+ render: function Render({ group }) {
+ // Since handleSubmit always allows to submit without meta, this is okay
+ group.handleSubmit()
+
+ // This matches the value
+ group.handleSubmit({ correct: '' })
+
+ // This does not.
+ // @ts-expect-error
+ group.handleSubmit({ wrong: 'Meta' })
+
+ return
+ },
+ })
+
+ const noMetaForm = useAppForm(() => ({
+ defaultValues,
+ }))
+
+ const CorrectComponent1 = (
+
+ )
+
+ const WrongComponent1 = (
+
+ )
+
+ const metaForm = useAppForm(() => ({
+ defaultValues,
+ onSubmitMeta,
+ }))
+
+ const CorrectComponent2 =
+ const CorrectComponent3 = (
+
+ )
+
+ const diffMetaForm = useAppForm(() => ({
+ defaultValues,
+ onSubmitMeta: { ...onSubmitMeta, something: 'else' },
+ }))
+
+ const CorrectComponent4 = (
+
+ )
+ const WrongComponent2 = (
+
+ )
+ })
+
+ it('should accept any validators for withFieldGroup', () => {
+ type Person = {
+ firstName: string
+ lastName: string
+ }
+
+ const defaultValues = {
+ person: { firstName: 'FirstName', lastName: 'LastName' } satisfies Person,
+ }
+
+ const formA = useAppForm(() => ({
+ defaultValues,
+ validators: {
+ onChange: () => 'A',
+ },
+ listeners: {
+ onChange: () => 'A',
+ },
+ }))
+ const formB = useAppForm(() => ({
+ defaultValues,
+ validators: {
+ onChange: () => 'B',
+ },
+ listeners: {
+ onChange: () => 'B',
+ },
+ }))
+
+ const FormGroup = withFieldGroup({
+ defaultValues: defaultValues.person,
+ render: function Render({ group }) {
+ return
+ },
+ })
+
+ const CorrectComponent1 =
+ const CorrectComponent2 =
+ })
+
+ it('should allow nesting withFieldGroup in other withFieldGroups', () => {
+ type Nested = {
+ firstName: string
+ }
+ type Wrapper = {
+ field: Nested
+ }
+ type FormValues = {
+ form: Wrapper
+ unrelated: { something: { lastName: string } }
+ }
+
+ const defaultValues: FormValues = {
+ form: {
+ field: {
+ firstName: 'Test',
+ },
+ },
+ unrelated: {
+ something: {
+ lastName: '',
+ },
+ },
+ }
+
+ const form = useAppForm(() => ({
+ defaultValues,
+ }))
+ const LensNested = withFieldGroup({
+ defaultValues: defaultValues.form.field,
+ render: function Render() {
+ return <>>
+ },
+ })
+ const LensWrapper = withFieldGroup({
+ defaultValues: defaultValues.form,
+ render: function Render({ group }) {
+ return (
+