form icon indicating copy to clipboard operation
form copied to clipboard

Form Composition - withForm - TypeScript error

Open philippe-desplats opened this issue 10 months ago • 1 comments

Describe the bug

Hey 👋,

I am in the process of updating my applications to TanStack Form v1 and testing to fully understand the new features/APIs.

Having seen that it is now possible to split up large forms using withForm (which is great), I decided to do a relatively simple test. Unfortunately, I am encountering a TypeScript error. So I would like to see if anyone knows if this is due to a problem of understanding on my part, or a potential bug that could be fixed.

form-context.tsx :

import React from 'react'
import { createFormHook, createFormHookContexts } from '@tanstack/react-form'

const SubmitButton = React.lazy(() => import('@/modules/form/components/actions/SubmitButton'))
const TextInput = React.lazy(() => import('@/modules/form/components/inputs/TextInput'))

export const { fieldContext, formContext, useFieldContext, useFormContext } = createFormHookContexts()
export const { useAppForm, withForm } = createFormHook({
    fieldComponents: {
        TextInput,
    },
    formComponents: {
        SubmitButton,
    },
    fieldContext,
    formContext,
})

UserInfoForm.tsx :

import { z } from 'zod'
import { formOptions } from '@tanstack/react-form'
import { withForm } from '@/modules/form/form-context'

const userInfoSchema = z.object({
    firstName: z.string().min(1, 'First name is required'),
    lastName: z.string().min(1, 'Last name is required'),
})
export type UserInfoFormData = z.input<typeof userInfoSchema>

const userInfoFormOpt = formOptions({
    defaultValues: {
        firstName: '',
        lastName: '',
    } as UserInfoFormData,
    validators: {
        onChange: userInfoSchema,
    },
})

const UserInfoForm = withForm({
    ...userInfoFormOpt,
    props: {
        title: 'User Information',
    },
    render: function Render({ form, title }) {
        return (
            <div>
                <h2>{title}</h2>
                <form.AppField name="firstName" children={(field) => <field.TextInput label="First Name" />} />
                <form.AppField name="lastName" children={(field) => <field.TextInput label="Last Name" />} />
                <form.AppField name="email" children={(field) => <field.TextInput label="Email" />} />
            </div>
        )
    },
})

export default UserInfoForm

AdressForm :

import { z } from 'zod'
import { formOptions } from '@tanstack/react-form'
import { withForm } from '@/modules/form/form-context'

const addressSchema = z.object({
    street: z.string().min(1, 'Street is required'),
    city: z.string().min(1, 'City is required'),
    zipCode: z.string().min(1, 'Zip code is required'),
})

export type AddressFormData = z.input<typeof addressSchema>

const addressFormOpt = formOptions({
    defaultValues: {
        street: '',
        city: '',
        zipCode: '',
    } as AddressFormData,
    validators: {
        onChange: addressSchema,
    },
})

const AddressForm = withForm({
    ...addressFormOpt,
    props: {
        title: 'Address Information',
    },
    render: function Render({ form, title }) {
        return (
            <div>
                <h2>{title}</h2>
                <form.AppField name="street" children={(field) => <field.TextInput label="Street" />} />
                <form.AppField name="city" children={(field) => <field.TextInput label="City" />} />
                <form.AppField name="zipCode" children={(field) => <field.TextInput label="Zip Code" />} />
            </div>
        )
    },
})

export default AddressForm

UserEditFormScreen.tsx :

import UserInfoForm, { UserInfoFormData } from './UserInfoForm'
import AddressForm, { AddressFormData } from './AddressForm'
import { useAppForm } from '@/modules/form/form-context'

const UserProfileForm = () => {
    const form = useAppForm({
        defaultValues: {
            firstName: '',
            lastName: '',
            email: '',
            street: '',
            city: '',
            zipCode: '',
        } as UserInfoFormData & AddressFormData,
        onSubmit: ({ value }) => {
            console.log(value)
        },
    })

    return (
        <form.AppForm>
            <h1>User profile</h1>

            { // TypeScript error here }
            <UserInfoForm form={form} title="User information" />

            { // TypeScript error here }
            <AddressForm form={form} title="Address information" />

            <form.AppForm>
                <form.SubmitButton>Submit</form.SubmitButton>
            </form.AppForm>
        </form.AppForm>
    )
}

export default UserProfileForm

The error I encounter :

The expected type comes from property 'form' which is declared here on type 'IntrinsicAttributes & NoInfer<{ title: string; }> & { form: AppFieldExtendedReactFormApi<{ firstName: string; lastName: string; email: string; }, FormValidateOrFn<{ firstName: string; lastName: string; email: string; }> | undefined, ... 9 more ..., { ...; }>; } & { ...; }'

The only way I've found so far to avoid the TypeScript error is to add an as any to form.

return (
  <UserInfoForm form={form as any} title="User information" />

  <AddressForm form={form as any} title="Address information" />
)

Thank you in advance for your help 😉.

Your minimal, reproducible example

https://github.com/philippe-desplats/tanstack-form-withform

Steps to reproduce

  1. Clone the example repository: https://github.com/philippe-desplats/tanstack-form-withform.
  2. Install the dependencies pnpm install.
  3. Go to src/modules/form/examples/UserEditForm.tsx to see the TypeScript error.

Expected behavior

Import the subforms made with withForm without having to put an as any to avoid a TypeScript error.

How often does this bug happen?

Every time

Screenshots or Videos

No response

Platform

  • OS : MacOS / Windows
  • Browser : Chromium / Firefox
  • NodeJS : v22.14.0
  • Package manager : pnpm

TanStack Form adapter

react-form

TanStack Form version

1.0.0

TypeScript version

v5.8.2

Additional context

Currently version 5.8.2. This is my configuration :

{
  "compilerOptions": {
    "forceConsistentCasingInFileNames": true,
    "target": "ES2017",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": [
        "./src/*"
      ]
    }
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts"
  ],
  "exclude": [
    "node_modules"
  ]
}

philippe-desplats avatar Mar 05 '25 17:03 philippe-desplats

Hey! I notice that you're defining unique formOptions for each form section, this causes an issue because the form instance you pass into the subform expects formOptions to be the exact same.

So when you pass a form instance created using these default opts:

    const form = useAppForm({
        defaultValues: {
            firstName: '',
            lastName: '',
            email: '',
            street: '',
            city: '',
            zipCode: '',
        } as UserInfoFormData & AddressFormData,
        onSubmit: ({ value }) => {
            console.log(value)
        },
    })

you can't pass it to the sub form which only accepts form instances created using default options:

const addressFormOpt = formOptions({
    defaultValues: {
        street: '',
        city: '',
        zipCode: '',
    } as AddressFormData,
    validators: {
        onChange: addressSchema,
    },
})

For now, the way I think you'd want to handle this is having a single formOptions at the top level with ALL the fields, (user options, address options...), then pass that down. If you still want to have unique validators for each section.

It's not possible to have the withForms have unique formOptions different from the parent that also have different validators.

I think what you're trying to do is closer to using FormGroups which is not something supported right now.

juanvilladev avatar Mar 05 '25 20:03 juanvilladev

Hey @juanvilladev,

Thanks a lot for your help and detailed explanations! I’ve run multiple tests with different combinations, but no matter what I try, I keep running into TypeScript errors.

From what I understand from your response, withForm doesn’t seem to work the way I initially thought. However, based on the documentation (Breaking big forms into smaller pieces), its purpose seems to be precisely to help split a large form into smaller subforms. But in practice, it doesn’t seem to work that way, or maybe it’s just not designed for that.

I’ve set up a test, which you can check out here: GitHub - Test.

If the withForm API is not entirely stable or finalized yet, that’s totally fine. My goal was simply to report a potential bug and see if this was expected behavior.

Again, thanks a lot for your help and time! 😉

philippe-desplats avatar Mar 06 '25 14:03 philippe-desplats

Hi @philippe-desplats,

This issue should be solved with #1239. Can you try updating the form package to 1.0.2? I tried it on your branch and typescript doesn't complain.

pavle99 avatar Mar 07 '25 17:03 pavle99

Should be fixed now in the latest patch. Thanks for the report

crutchcorn avatar Mar 08 '25 12:03 crutchcorn

Hi,

Sorry for the delay in replying, I wasn't available. I have just tested on version 1.0.5 of TanStack Form and unfortunately I still have the TypeScript error.

createFormHook.d.ts(56, 9): The expected type comes from property 'form' which is declared here on type 'IntrinsicAttributes & NoInfer<{ title: string; }> & { form: AppFieldExtendedReactFormApi<any, any, any, FormAsyncValidateOrFn<unknown> | undefined, any, ... 6 more ..., { ...; }>; } & { ...; }’

I have updated my branch so you can see if you have time to take care of it.

Thank you for your feedback and your time.

philippe-desplats avatar Mar 11 '25 09:03 philippe-desplats

I am still facing this issue.

Here is my form piece.

export const ExtendedUserForm = withForm({

    defaultValues: { user: { password: "", username: "" } } as {
        user?: genApi.UserDto,
        [key: string]: any
    },
    props: {},
    render({ form, children }) {
        return <Card variant="plain" className="flex " >
            <CardContent className="flex flex-col">
                <form.Field
                    name="user.username"
                    children={(field) => (
                        <FormInput field={field} label="Username" className="grow" />
                    )}
                />
                <form.Field
                    name="user.password"
                    children={(field) => (
                        <FormInput field={field} label="Password" className="grow" />
                    )}
                />
            </CardContent>
        </Card>
    },

})

kavyantic avatar Sep 23 '25 09:09 kavyantic