diff --git a/.changeset/sixty-hands-peel.md b/.changeset/sixty-hands-peel.md
new file mode 100644
index 000000000..4bbe31e88
--- /dev/null
+++ b/.changeset/sixty-hands-peel.md
@@ -0,0 +1,5 @@
+---
+'@tanstack/solid-form': minor
+---
+
+add withFieldGroup API Solid Form Composition
diff --git a/docs/framework/solid/guides/form-composition.md b/docs/framework/solid/guides/form-composition.md
index 9d2c92e7c..fe36134e8 100644
--- a/docs/framework/solid/guides/form-composition.md
+++ b/docs/framework/solid/guides/form-composition.md
@@ -159,7 +159,7 @@ Sometimes forms get very large; it's just how it goes sometimes. While TanStack
To solve this, we support breaking forms into smaller pieces using the `withForm` higher-order component.
```tsx
-const { useAppForm, withForm } = createFormHook({
+const { useAppForm, withForm, withFieldGroup } = createFormHook({
fieldComponents: {
TextField,
},
@@ -213,8 +213,243 @@ function App() {
### `withForm` FAQ
> Why a higher-order component instead of a hook?
+>
+> 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(props) {
+ // access reactive values using the group store
+ const password = useStore(
+ props.group.store,
+ (state) => state.values.password,
+ )
+ // or the form itself
+ const isSubmitting = useStore(
+ props.group.form.store,
+ (state) => state.isSubmitting,
+ )
+
+ return (
+
+
{props.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 !== props.group.getFieldValue('password')) {
+ return 'Passwords do not match'
+ }
+ return undefined
+ },
+ }}
+ >
+ {(field) => (
+
+
+
+
+ )}
+
+
+ )
+ },
+})
+```
-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.
+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
@@ -316,7 +551,7 @@ function SubscribeButton(props: { label: string }) {
)
}
-const { useAppForm, withForm } = createFormHook({
+const { useAppForm, withForm, withFieldGroup } = createFormHook({
fieldComponents: {
TextField,
},
@@ -345,7 +580,7 @@ const ChildForm = withForm({
render: (props) => {
return (
-
{title}
+
{props.title}
}
@@ -360,9 +595,9 @@ const ChildForm = withForm({
// /src/features/people/page.ts
const Parent = () => {
- const form = useAppForm({
+ const form = useAppForm(() => ({
...formOpts,
- })
+ }))
return
}
diff --git a/examples/solid/large-form/src/features/people/emergency-contact.tsx b/examples/solid/large-form/src/features/people/emergency-contact.tsx
new file mode 100644
index 000000000..69cc88160
--- /dev/null
+++ b/examples/solid/large-form/src/features/people/emergency-contact.tsx
@@ -0,0 +1,22 @@
+import { withFieldGroup } from '../../hooks/form'
+
+export const FieldGroupEmergencyContact = withFieldGroup({
+ defaultValues: {
+ phone: '',
+ fullName: '',
+ },
+ render: function Render({ group }) {
+ return (
+ <>
+ }
+ />
+ }
+ />
+ >
+ )
+ },
+})
diff --git a/examples/solid/large-form/src/features/people/page.tsx b/examples/solid/large-form/src/features/people/page.tsx
index b7b6426ff..3323da564 100644
--- a/examples/solid/large-form/src/features/people/page.tsx
+++ b/examples/solid/large-form/src/features/people/page.tsx
@@ -1,5 +1,6 @@
import { useAppForm } from '../../hooks/form.tsx'
import { AddressFields } from './address-fields.tsx'
+import { FieldGroupEmergencyContact } from './emergency-contact.tsx'
import { peopleFormOpts } from './shared-form.tsx'
export const PeoplePage = () => {
@@ -57,14 +58,7 @@ export const PeoplePage = () => {
/>
Emergency Contact
- }
- />
- }
- />
+
diff --git a/examples/solid/large-form/src/hooks/form.tsx b/examples/solid/large-form/src/hooks/form.tsx
index e26451d86..8ff2360d2 100644
--- a/examples/solid/large-form/src/hooks/form.tsx
+++ b/examples/solid/large-form/src/hooks/form.tsx
@@ -15,7 +15,7 @@ function SubscribeButton(props: { label: string }) {
)
}
-export const { useAppForm, withForm } = createFormHook({
+export const { useAppForm, withForm, withFieldGroup } = createFormHook({
fieldComponents: {
TextField,
},
diff --git a/packages/solid-form/src/createField.tsx b/packages/solid-form/src/createField.tsx
index de263f00c..a48bc8f96 100644
--- a/packages/solid-form/src/createField.tsx
+++ b/packages/solid-form/src/createField.tsx
@@ -11,12 +11,13 @@ import type {
DeepValue,
FieldAsyncValidateOrFn,
FieldValidateOrFn,
+ FieldValidators,
FormAsyncValidateOrFn,
FormValidateOrFn,
Narrow,
} from '@tanstack/form-core'
-import type { Accessor, Component, JSX, JSXElement } from 'solid-js'
+import type { Accessor, JSX, JSXElement } from 'solid-js'
import type { CreateFieldOptions, CreateFieldOptionsBound } from './types'
interface SolidFieldApi<
@@ -625,6 +626,94 @@ interface FieldComponentProps<
) => JSXElement
}
+/**
+ * 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[]
+ }
+}) => JSX.Element
+
export function Field<
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..0310dd9d9
--- /dev/null
+++ b/packages/solid-form/src/createFieldGroup.tsx
@@ -0,0 +1,239 @@
+import { FieldGroupApi, functionalUpdate } from '@tanstack/form-core'
+import { useStore } from '@tanstack/solid-store'
+import { onCleanup, onMount } from 'solid-js'
+import type { Component, JSX, ParentProps } from 'solid-js'
+import type {
+ DeepKeysOfType,
+ FieldGroupState,
+ FieldsMap,
+ FormAsyncValidateOrFn,
+ FormValidateOrFn,
+} from '@tanstack/form-core'
+import type { LensFieldComponent } from './createField'
+import type { AppFieldExtendedSolidFormApi } from './createFormHook'
+
+/**
+ * @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: Component
+ /**
+ * A solid 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 solid 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
+ },
+): AppFieldExtendedSolidFieldGroupApi<
+ TFormData,
+ TFieldGroupData,
+ TFields,
+ TOnMount,
+ TOnChange,
+ TOnChangeAsync,
+ TOnBlur,
+ TOnBlurAsync,
+ TOnSubmit,
+ TOnSubmitAsync,
+ TOnDynamic,
+ TOnDynamicAsync,
+ TOnServer,
+ TSubmitMeta,
+ TComponents,
+ TFormComponents
+> {
+ const options = opts()
+ const api = new FieldGroupApi(options)
+ const form =
+ options.form instanceof FieldGroupApi
+ ? (options.form.form as AppFieldExtendedSolidFormApi<
+ TFormData,
+ TOnMount,
+ TOnChange,
+ TOnChangeAsync,
+ TOnBlur,
+ TOnBlurAsync,
+ TOnSubmit,
+ TOnSubmitAsync,
+ TOnDynamic,
+ TOnDynamicAsync,
+ TOnServer,
+ TSubmitMeta,
+ TComponents,
+ TFormComponents
+ >)
+ : options.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 = (appFormProps) =>
+ extendedApi.AppField = (props) => (
+
+ )
+ extendedApi.Field = (props) => (
+
+ )
+ extendedApi.Subscribe = (props) => {
+ const data = useStore(api.store, props.selector)
+
+ return functionalUpdate(props.children, data()) as Element
+ }
+
+ let mounted = false
+ onMount(() => {
+ const cleanupFn = api.mount()
+ mounted = true
+ onCleanup(() => {
+ cleanupFn()
+ mounted = false
+ })
+ })
+
+ return Object.assign(extendedApi, {
+ ...options.formComponents,
+ }) as AppFieldExtendedSolidFieldGroupApi<
+ TFormData,
+ TFieldGroupData,
+ TFields,
+ TOnMount,
+ TOnChange,
+ TOnChangeAsync,
+ TOnBlur,
+ TOnBlurAsync,
+ TOnSubmit,
+ TOnSubmitAsync,
+ TOnDynamic,
+ TOnDynamicAsync,
+ TOnServer,
+ TSubmitMeta,
+ TComponents,
+ TFormComponents
+ >
+}
diff --git a/packages/solid-form/src/createFormHook.tsx b/packages/solid-form/src/createFormHook.tsx
index 9b02050e1..cf2a389ab 100644
--- a/packages/solid-form/src/createFormHook.tsx
+++ b/packages/solid-form/src/createFormHook.tsx
@@ -1,9 +1,14 @@
import { createContext, splitProps, useContext } from 'solid-js'
import { createForm } from './createForm'
+import { createFieldGroup } from './createFieldGroup'
+import type { AppFieldExtendedSolidFieldGroupApi } from './createFieldGroup'
import type {
AnyFieldApi,
AnyFormApi,
+ BaseFormOptions,
+ DeepKeysOfType,
FieldApi,
+ FieldsMap,
FormAsyncValidateOrFn,
FormOptions,
FormValidateOrFn,
@@ -142,8 +147,10 @@ interface CreateFormHookProps<
formComponents: TFormComponents
formContext: Context
}
-
-type AppFieldExtendedSolidFormApi<
+/**
+ * @private
+ */
+export type AppFieldExtendedSolidFormApi<
TFormData,
TOnMount extends undefined | FormValidateOrFn,
TOnChange extends undefined | FormValidateOrFn,
@@ -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>,
@@ -416,8 +460,96 @@ export function createFormHook<
return (innerProps) => render({ ...props, ...innerProps })
}
+ function withFieldGroup<
+ TFieldGroupData,
+ TSubmitMeta,
+ TRenderProps extends Record = {},
+ >({
+ render,
+ props,
+ defaultValues,
+ }: 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 = {
+ form: innerProps.form,
+ fields: innerProps.fields,
+ defaultValues,
+ formComponents: opts.formComponents,
+ }
+ const fieldGroupApi = createFieldGroup(() => fieldGroupProps)
+
+ return render({ ...props, ...innerProps, group: fieldGroupApi as any })
+ }
+ }
+
return {
useAppForm,
withForm,
+ withFieldGroup,
}
}
diff --git a/packages/solid-form/src/index.tsx b/packages/solid-form/src/index.tsx
index 311e83a27..22acfe2fd 100644
--- a/packages/solid-form/src/index.tsx
+++ b/packages/solid-form/src/index.tsx
@@ -4,5 +4,6 @@ export { useStore } from '@tanstack/solid-store'
export * from './createField'
export * from './createForm'
+export * from './createFieldGroup'
export * from './createFormHook'
export * from './types'
diff --git a/packages/solid-form/tests/createFormHook.test.tsx b/packages/solid-form/tests/createFormHook.test.tsx
index 60025aa9c..d6eea7b98 100644
--- a/packages/solid-form/tests/createFormHook.test.tsx
+++ b/packages/solid-form/tests/createFormHook.test.tsx
@@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest'
import { render } from '@solidjs/testing-library'
import { formOptions } from '@tanstack/form-core'
-import { createFormHook, createFormHookContexts } from '../src'
+import userEvent from '@testing-library/user-event'
+import { createFormHook, createFormHookContexts, useStore } from '../src'
+
+const user = userEvent.setup()
const { fieldContext, useFieldContext, formContext, useFormContext } =
createFormHookContexts()
@@ -28,7 +31,7 @@ function SubscribeButton({ label }: { label: string }) {
)
}
-const { useAppForm, withForm } = createFormHook({
+const { useAppForm, withForm, withFieldGroup } = createFormHook({
fieldComponents: {
TextField,
},
@@ -112,4 +115,469 @@ describe('createFormHook', () => {
expect(input).toHaveValue('John')
expect(getByText('Testing')).toBeInTheDocument()
})
+
+ it('should handle withFieldGroup types properly', () => {
+ const formOpts = formOptions({
+ defaultValues: {
+ person: {
+ firstName: 'John',
+ lastName: 'Doe',
+ },
+ },
+ })
+
+ const ChildForm = withFieldGroup({
+ defaultValues: formOpts.defaultValues.person,
+ // Optional, but adds props to the `render` function outside of `form`
+ props: {
+ title: 'Child Form',
+ },
+ render: ({ group, 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()
+ })
+
+ it('should use the correct field name in Field with withFieldGroup', () => {
+ const formOpts = formOptions({
+ defaultValues: {
+ person: {
+ firstName: 'John',
+ lastName: 'Doe',
+ },
+ people: [
+ {
+ firstName: 'Jane',
+ lastName: 'Doe',
+ },
+ {
+ firstName: 'Robert',
+ lastName: 'Doe',
+ },
+ ],
+ },
+ })
+
+ const ChildFormAsField = withFieldGroup({
+ defaultValues: formOpts.defaultValues.person,
+ render: ({ group }) => {
+ return (
+
+ }
+ />
+
+
+
+
+ )
+ },
+ })
+ const ChildFormAsArray = withFieldGroup({
+ defaultValues: [formOpts.defaultValues.person],
+ props: {
+ title: '',
+ },
+ render: ({ group, title }) => {
+ return (
+
+
{title}
+
}
+ />
+
+
+
+
+ )
+ },
+ })
+
+ const Parent = () => {
+ const form = useAppForm(() => ({
+ ...formOpts,
+ }))
+
+ return (
+ <>
+
+
+
+ >
+ )
+ }
+
+ const { getByLabelText, getByText } = render(() => )
+ const inputField1 = getByLabelText('person.firstName')
+ const inputArray = getByLabelText('people[0].firstName')
+ const inputField2 = getByLabelText('people[1].firstName')
+ expect(inputField1).toHaveValue('John')
+ expect(inputArray).toHaveValue('Jane')
+ expect(inputField2).toHaveValue('Robert')
+ expect(getByText('Testing')).toBeInTheDocument()
+ })
+
+ it('should forward Field and Subscribe to the form', () => {
+ const formOpts = formOptions({
+ defaultValues: {
+ person: {
+ firstName: 'John',
+ lastName: 'Doe',
+ },
+ },
+ })
+
+ const ChildFormAsField = withFieldGroup({
+ defaultValues: formOpts.defaultValues.person,
+ render: ({ group }) => {
+ return (
+
+
(
+
+ {field().name}
+ field().handleChange(e.target.value)}
+ />
+
+ )}
+ />
+ state.values.lastName}>
+ {(lastName) => {lastName}
}
+
+
+ )
+ },
+ })
+
+ const Parent = () => {
+ const form = useAppForm(() => ({
+ ...formOpts,
+ }))
+ return
+ }
+
+ const { getByLabelText, getByText } = render(() => )
+ const input = getByLabelText('person.firstName')
+ expect(input).toHaveValue('John')
+ expect(getByText('Doe')).toBeInTheDocument()
+ })
+
+ it('should not lose focus on update with withFieldGroup', async () => {
+ const formOpts = formOptions({
+ defaultValues: {
+ person: {
+ firstName: 'John',
+ lastName: 'Doe',
+ },
+ },
+ })
+
+ const ChildForm = withFieldGroup({
+ defaultValues: formOpts.defaultValues.person,
+ render: function Render({ group }) {
+ const firstName = useStore(
+ group.store,
+ (state) => state.values.firstName,
+ )
+ return (
+
+
{firstName()}
+
(
+
+ {field().name}
+ field().handleChange(e.target.value)}
+ />
+
+ )}
+ />
+ state.values.lastName}>
+ {(lastName) => {lastName}
}
+
+
+ )
+ },
+ })
+
+ const Parent = () => {
+ const form = useAppForm(() => ({
+ ...formOpts,
+ }))
+ return
+ }
+
+ const { getByLabelText } = render(() => )
+
+ const input = getByLabelText('person.firstName')
+ input.focus()
+ expect(input).toHaveFocus()
+
+ await user.clear(input)
+ await user.type(input, 'Something')
+
+ expect(input).toHaveFocus()
+ })
+
+ 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 LensNested = withFieldGroup({
+ defaultValues: defaultValues.form.field,
+ render: function Render({ group }) {
+ return (
+
+ {(field) => {field().name}
}
+
+ )
+ },
+ })
+ const LensWrapper = withFieldGroup({
+ defaultValues: defaultValues.form,
+ render: function Render({ group }) {
+ return (
+
+
+
+ )
+ },
+ })
+
+ const Parent = () => {
+ const form = useAppForm(() => ({
+ defaultValues,
+ }))
+ return
+ }
+
+ const { getByText } = render(() => )
+
+ expect(getByText('form.field.firstName')).toBeInTheDocument()
+ })
+
+ it('should allow mapping withFieldGroup to different values', () => {
+ const formOpts = formOptions({
+ defaultValues: {
+ unrelated: 'John',
+ values: '',
+ },
+ })
+
+ const ChildFormAsField = withFieldGroup({
+ defaultValues: { firstName: '', lastName: '' },
+ render: ({ group }) => {
+ return (
+
+ }
+ />
+
+ )
+ },
+ })
+
+ const Parent = () => {
+ const form = useAppForm(() => ({
+ ...formOpts,
+ }))
+
+ return (
+
+ )
+ }
+
+ const { getByLabelText } = render(() => )
+ const inputField1 = getByLabelText('unrelated')
+ expect(inputField1).toHaveValue('John')
+ })
+
+ it('should remap FieldGroupApi.Field validators to the correct names', () => {
+ const FieldGroupString = withFieldGroup({
+ defaultValues: { password: '', confirmPassword: '' },
+ render: function Render({ group }) {
+ return (
+ null,
+ onChangeListenTo: ['password'],
+ onBlur: () => null,
+ onBlurListenTo: ['confirmPassword'],
+ }}
+ >
+ {(field) => {
+ expect(
+ field().options.validators?.onChangeListenTo,
+ ).toStrictEqual(['account.password'])
+ expect(field().options.validators?.onBlurListenTo).toStrictEqual([
+ 'account.confirmPassword',
+ ])
+ return <>>
+ }}
+
+ )
+ },
+ })
+
+ const FieldGroupObject = withFieldGroup({
+ defaultValues: { password: '', confirmPassword: '' },
+ render: function Render({ group }) {
+ return (
+ null,
+ onChangeListenTo: ['password'],
+ onBlur: () => null,
+ onBlurListenTo: ['confirmPassword'],
+ }}
+ >
+ {(field) => {
+ expect(
+ field().options.validators?.onChangeListenTo,
+ ).toStrictEqual(['userPassword'])
+ expect(field().options.validators?.onBlurListenTo).toStrictEqual([
+ 'userConfirmPassword',
+ ])
+ return <>>
+ }}
+
+ )
+ },
+ })
+
+ const Parent = () => {
+ const form = useAppForm(() => ({
+ defaultValues: {
+ account: {
+ password: '',
+ confirmPassword: '',
+ },
+ userPassword: '',
+ userConfirmPassword: '',
+ },
+ }))
+
+ return (
+ <>
+
+
+ >
+ )
+ }
+
+ render(() => )
+ })
+
+ it('should accept formId and return it', async () => {
+ function Submit() {
+ const form = useFormContext()
+
+ return (
+
+ {form.formId}
+
+ )
+ }
+
+ function Comp() {
+ const form = useAppForm(() => ({
+ formId: 'test',
+ }))
+
+ return (
+
+
+
+ state.submissionAttempts}
+ children={(submissionAttempts) => (
+ {submissionAttempts()}
+ )}
+ />
+
+
+
+ )
+ }
+
+ const { getByTestId } = render(() => )
+ const target = getByTestId('formId-target')
+ const result = getByTestId('formId-result')
+
+ await user.click(target)
+ expect(result).toHaveTextContent('1')
+ })
})