T
TanStack2mo ago
conscious-sapphire

Nesting Field Array in Child Component

Hi folks! I'm trying to create an array field with the ability to to add items to the array. There is an example of how to do this in the docs (https://tanstack.com/form/latest/docs/framework/react/guides/arrays#full-example) but the docs always seem to assume that I'm rendering all my fields in the root form component where the useForm hook is called.
<form.Field name="people" mode="array">
{(field) => {
// I want to move everything in this return to another component
return (
<div>
{field.state.value.map((_, i) => {
return (
<form.Field key={i} name={`people[${i}].name`}>
{(subField) => {
return (
<div>
<label>
<div>Name for person {i}</div>
<input
value={subField.state.value}
onChange={(e) =>
subField.handleChange(e.target.value)
}
/>
</label>
</div>
)
}}
</form.Field>
)
})}
<button
onClick={() => field.pushValue({ name: '', age: 0 })}
type="button"
>
Add person
</button>
</div>
)
}}
</form.Field>
<form.Field name="people" mode="array">
{(field) => {
// I want to move everything in this return to another component
return (
<div>
{field.state.value.map((_, i) => {
return (
<form.Field key={i} name={`people[${i}].name`}>
{(subField) => {
return (
<div>
<label>
<div>Name for person {i}</div>
<input
value={subField.state.value}
onChange={(e) =>
subField.handleChange(e.target.value)
}
/>
</label>
</div>
)
}}
</form.Field>
)
})}
<button
onClick={() => field.pushValue({ name: '', age: 0 })}
type="button"
>
Add person
</button>
</div>
)
}}
</form.Field>
I want to render my own component for the array field, but I cannot figure out how to get access to <form.Field> from my child component. I tried this:
const form = useFormContext();

<form.Field name="myArrayFieldName">
const form = useFormContext();

<form.Field name="myArrayFieldName">
But I get the following typescript error on the name prop: Type 'string' is not assignable to type 'never'. I've tried a few other things to try get this working and I'm kinda stuck now. I searched github issues and this discord but still have not found a definitive answer... Is there a straightforward way to render <form.Field> from inside child components?
18 Replies
quickest-silver
quickest-silver2mo ago
withForm is intended to get specific form access from different places in this case, you can use it (alongside an index property) to figure out which item section you‘re currently using serving as namespace for the fields inside
conscious-sapphire
conscious-sapphireOP2mo ago
Thanks for your quick respone Luca! Appreciate you taking the time 🙂 Could you provide a link to an example of this? In the docs or elsewhere. I would have thought this would be a very common pattern but I've not seen any examples that cover this use case.
quickest-silver
quickest-silver2mo ago
I‘m on mobile, so that‘s tough. It‘s in the form composition section I can send a snippet of what I‘m referring to here in about 15 minutes
conscious-sapphire
conscious-sapphireOP2mo ago
Yeah, I've been reading that page but I'm still not sure exactly how to adapt the example to my use case. No worries, a snippet when you get chance would be great, thanks! Some more context if it helps... My form schema (reduced to the parts that are relevant) looks something like this:
const schema = z.object({
schedule: z.object({
timeRanges: z.array(z.object({
startTime: z.string().time(),
endTime: z.string().time()
})),
dateRanges: z.array(z.object({
startDate: z.string().date(),
endDate: z.string().date()
}))
})
});
const schema = z.object({
schedule: z.object({
timeRanges: z.array(z.object({
startTime: z.string().time(),
endTime: z.string().time()
})),
dateRanges: z.array(z.object({
startDate: z.string().date(),
endDate: z.string().date()
}))
})
});
I currently have a Schedule.tsx component that uses withFieldGroup* for the schedule property, and in turn, I render separate components for the timeRanges array field and the dateRanges array field. Something like this:
<group.Field
name="timeRanges"
mode="array"
>
{field => (
<ScheduleTimeRanges />
)}
</group.Field>
<group.Field
name="dateRanges"
mode="array"
>
{field => (
<ScheduleDateRanges />
)}
</group.Field>
<group.Field
name="timeRanges"
mode="array"
>
{field => (
<ScheduleTimeRanges />
)}
</group.Field>
<group.Field
name="dateRanges"
mode="array"
>
{field => (
<ScheduleDateRanges />
)}
</group.Field>
And then in ScheduleTimeRanges and ScheduleDateRanges I'd like to render the UI for those parts of the form. *I'm not sure what the key differences are between withForm and withFieldGroup so I may be incorrectly using withFieldGroup
quickest-silver
quickest-silver2mo ago
* withForm: I want this form, and this form only. Useful for huge forms across multiple files * withFieldGroup: I have fields that depend on other fields, but I want to reuse it. Useful for object fields with multiple subfields, or for dependent fields so the final result will depend on what you need. If you just want to organize your single form and don't intend to reuse these fields later:
// validators and defaultValues should go in here.
// That way, it's easier to sync between `withForm`
// and your form hook
const commonOptions = formOptions({ /* ... */ })

// It still allows you to overwrite it in the hook
const form = useAppForm({
...commonOptions,
defaultValues: myAsyncValues ?? commonOptions.defaultValues
})

// field-group.tsx
const Fields = withFieldGroup({
...commonOptions,
props: {
// variables in the field name should be passed as prop
index: 0
},
render: function Render({ form, index }) {
// as const infers it as `foo[${number}]`
// instead of simple 'string'
const namespace = `foo[${index}]` as const;

// === `foo[${index}].bar`
<form.AppField name={`${namespace}.bar`}>
}
})
// validators and defaultValues should go in here.
// That way, it's easier to sync between `withForm`
// and your form hook
const commonOptions = formOptions({ /* ... */ })

// It still allows you to overwrite it in the hook
const form = useAppForm({
...commonOptions,
defaultValues: myAsyncValues ?? commonOptions.defaultValues
})

// field-group.tsx
const Fields = withFieldGroup({
...commonOptions,
props: {
// variables in the field name should be passed as prop
index: 0
},
render: function Render({ form, index }) {
// as const infers it as `foo[${number}]`
// instead of simple 'string'
const namespace = `foo[${index}]` as const;

// === `foo[${index}].bar`
<form.AppField name={`${namespace}.bar`}>
}
})
https://stackblitz.com/edit/vitejs-vite-vmdpv51k?file=README.md here's an example for field groups and how they can be used in ways withForm can't
conscious-sapphire
conscious-sapphireOP2mo ago
So, using withForm, do I just use the defaultValues option to tell Form which fields the component is for?
quickest-silver
quickest-silver2mo ago
yeah, if you go with withForm, you'd always work from the top of the field values to the bottom
conscious-sapphire
conscious-sapphireOP2mo ago
I'm not sure I understand what you mean. When you use withForm, do you need to set defaultValues that matches the entire form schema or can it be for a subset of fields? In a withForm component, are you always working with the whole form schema and you use the name prop on <formField> to say which fields you want to manage?
quickest-silver
quickest-silver2mo ago
no, withForm is very strict. If it doesn't match the form hook you're using, it will throw an error. such as validator mismatches or default values withFieldGroup only requires that the listed fields exist of the expected type. The form using it can then decide what fields to use at runtime at the expense of type safety within withFieldGroup of course Perhaps I can phrase it differently. When you do:
const MySection = withForm({
...commonOptions,
render: function Render({ form }) {
return <p>Hi</p>;
}
})
const MySection = withForm({
...commonOptions,
render: function Render({ form }) {
return <p>Hi</p>;
}
})
It's equivalent to this:
function App() {
const form = useAppForm({
...commonOptions
})

return <p>Hi</p>;
}
function App() {
const form = useAppForm({
...commonOptions
})

return <p>Hi</p>;
}
except that instead of managing the hook in MySection, it will use the form that's passed to it <MySection form={form} /> should be okay, as long as formOptions has the same schema and defaultValues between both
conscious-sapphire
conscious-sapphireOP2mo ago
Hmmm I suspect that the fact that my form schema is a discriminated union (zod v3) would potentially cause issues with using withForm (whatever I set defaultValues as won't match all union members). Do you know if this is the case? Sorry, I might have caused your last reply to disappear. I'm new to discord. My apologies!
quickest-silver
quickest-silver2mo ago
though you might have to give typescript a nudge by extracting defaultValues using const defaultValues: z.input<typeof yourSchema> = {}
conscious-sapphire
conscious-sapphireOP2mo ago
This isn't working for me unfortunately. 🙁
quickest-silver
quickest-silver2mo ago
which part?
conscious-sapphire
conscious-sapphireOP2mo ago
I didn't catch the first part of your last response before it got deleted (my bad!) but I tried your suggestion of setting the type of defaultValues to z.input<typeof yourSchema> to help it with the discriminated union, but I'm getting a typescript error on the form prop on my Schedule component. I've forked your stackblitz example and modified it to demonstrate a minimal form with a discriminated union schema and using withForm for the form variants. It runs but typescript isn't happy with something. https://stackblitz.com/edit/vitejs-vite-xeboxvrb?file=src%2FApp.tsx
Andy Cochrane
StackBlitz
Simple tanstack form with zod discriminated union schema - StackBlitz
Next generation frontend tooling. It&#39;s fast!
conscious-sapphire
conscious-sapphireOP2mo ago
I didn't include any array fields (as per my original question) because I'm currently focusing on whether discriminated unions and withForm can play nice
quickest-silver
quickest-silver2mo ago
hmm, that's a bit irritating. TypeScript thinks "z.input is a union, but since it's a constant I'll just narrow it for you" So the input is not actually applied. You can force the assignment (in a type safe way), but it's bulky:
type Values = z.input<typeof myFormSchema>;

const defaultValues = {
/* ... */
} satisfies Values as Values // 'satisfies' checks validity, 'as' asserts
type Values = z.input<typeof myFormSchema>;

const defaultValues = {
/* ... */
} satisfies Values as Values // 'satisfies' checks validity, 'as' asserts
alternatively, something we like to use at the workplace (for string unions too since they tend to suffer from this as well) is an identity function:
// instead of "satisfies X as X" you can use this helper

function implement<T>(input: T): T {
return input;
}

// or the name we ended up choosing
function oneof<T>(input: T): T {
return input;
}

// Usage:
const test = oneof<MyType>({})
// instead of "satisfies X as X" you can use this helper

function implement<T>(input: T): T {
return input;
}

// or the name we ended up choosing
function oneof<T>(input: T): T {
return input;
}

// Usage:
const test = oneof<MyType>({})
conscious-sapphire
conscious-sapphireOP2mo ago
Yes, a little bit frustrating but the type assertion is working for me and isn't too painful. I'm unblocked now! Thanks for your help @Luca | LeCarbonator 🙂
quickest-silver
quickest-silver2mo ago
Glad to hear! I was surprised by the activity on this particular topic. If you want to have a read, here's the TypeScript issue talking about it (closed as not planned) https://github.com/microsoft/TypeScript/issues/61789
GitHub
Skip assignment narrowing when declaring & initializing a variable ...
🔍 Search Terms declare, assign, declaration, assignment, narrow, narrowing, union, variable, &quot;control flow analysis&quot; ✅ Viability Checklist This wouldn&#39;t be a breaking change in existi...

Did you find this page helpful?