T
TanStack6mo ago
fair-rose

API of Pre-bound Field Components

Could you instead of
<form.AppField
name="fullName"
children={(field) =>
<field.TextField label="Full Name" />
}
/>
<form.AppField
name="fullName"
children={(field) =>
<field.TextField label="Full Name" />
}
/>
do
<form.TextField
name="fullName"
label="Full Name"
/>
<form.TextField
name="fullName"
label="Full Name"
/>
? Idk if that is already possible or if this would need an API change. Im looking for a way to wrap the library, so that i can only use my own components, when building a form.
48 Replies
quickest-silver
quickest-silver6mo ago
There's no way we can support this proposed API without requiring you to do extensive typings.
fair-rose
fair-roseOP6mo ago
can you give me an example why this is not possible or would this go too deep
quickest-silver
quickest-silver6mo ago
form.TextField needs inferencing values about the field And our Field API has 19 generics So we'd need to infer 10 for the form And 9 for the field itself So you'd need to pass 19 generics to the TextField component's typing to support it And we plan on adding more generics in the near future, so you'd have to be constantly refactoring your code to add and rearrange new generics
fair-rose
fair-roseOP6mo ago
can't you keep the api of useFieldContext
quickest-silver
quickest-silver6mo ago
We need a provider for that; where would that be?
fair-rose
fair-roseOP6mo ago
I could imagine, that form.TextField is not the actual TextField component but a "proxy" which passes all props, except name, to the actual TextField while wrapping it with a provider. If that makes sense... I made an example implementation, to see if it would work
import type { FC } from 'react';

type AddNameProp<Component extends FC> =
FC<Parameters<Component>[0] & { name: string }>;

function createFormHook<Components extends Record<string, FC<any>>>(options: {
fieldComponents: Components
}){
const components = { ...options.fieldComponents } as any;

Object
.entries(options.fieldComponents)
.forEach(([name, Component]) => {
components[name] = ({ name, ...props }) => (
<div id='provider'>
field name: { name }
<Component { ...props }/>
</div>
);
});

return {
useAppForm: () => components as { [K in keyof Components]: AddNameProp<Components[K]>; }
};
}

const TextField = (props: { hello: string }) => (
<div>im a text field: hello { props.hello }</div>
);

const { useAppForm } = createFormHook({
fieldComponents: {
TextField
}
});

const ExampleForm: FC = () => {
const form = useAppForm();

return (
<form.TextField name='myTextField' hello='world ll'/>
);
};
import type { FC } from 'react';

type AddNameProp<Component extends FC> =
FC<Parameters<Component>[0] & { name: string }>;

function createFormHook<Components extends Record<string, FC<any>>>(options: {
fieldComponents: Components
}){
const components = { ...options.fieldComponents } as any;

Object
.entries(options.fieldComponents)
.forEach(([name, Component]) => {
components[name] = ({ name, ...props }) => (
<div id='provider'>
field name: { name }
<Component { ...props }/>
</div>
);
});

return {
useAppForm: () => components as { [K in keyof Components]: AddNameProp<Components[K]>; }
};
}

const TextField = (props: { hello: string }) => (
<div>im a text field: hello { props.hello }</div>
);

const { useAppForm } = createFormHook({
fieldComponents: {
TextField
}
});

const ExampleForm: FC = () => {
const form = useAppForm();

return (
<form.TextField name='myTextField' hello='world ll'/>
);
};
quickest-silver
quickest-silver6mo ago
And when someone wants to pass a name prop to the underlying component?
fair-rose
fair-roseOP6mo ago
sure, the trade-off would be, that the user can't have name as one of its props
quickest-silver
quickest-silver6mo ago
Sure but it's not just that one prop It's all properties we allow passed to the Field API And making a new argument in Field API would then be a breaking change, as we can't reliably ensure that the user hasn't namespaced that property name already
fair-rose
fair-roseOP6mo ago
how about a prefix for the Field API props like $name? Although I don't know how I would feel about that Could the provider be at the form level like AppForm? The provider would then have the data from all form fields and useFieldContext would have a parameter for name, so the hook knows which data to use. The component could then pass the name from its props. ok wait... you can actually already do this:
function TextField({ label, name }: { label: string, name: string }) {
const form = useFormContext()

return (
<form.Field
name={ name }
children={ field => {
const errors = useStore(field.store, (state) => state.meta.errors)

return (
<div>
<label>
<div>{label}</div>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</label>
{errors.map((error: string) => (
<div key={error} style={{ color: 'red' }}>
{error}
</div>
))}
</div>
)
} }
/>
)
}
function TextField({ label, name }: { label: string, name: string }) {
const form = useFormContext()

return (
<form.Field
name={ name }
children={ field => {
const errors = useStore(field.store, (state) => state.meta.errors)

return (
<div>
<label>
<div>{label}</div>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</label>
{errors.map((error: string) => (
<div key={error} style={{ color: 'red' }}>
{error}
</div>
))}
</div>
)
} }
/>
)
}
Would this be an acceptable use of the library? Although you loose the type safety of the field name...
unwilling-turquoise
unwilling-turquoise2mo ago
I was actually looking for a way to do pre-bound array fields and I either use this way with useFormContext or useFieldContext<string[]> but then I can't use the sub-field <form.Field name={`${name}[${index}]`} to bind to the text field, I would have to manipulate the array directly, so both sides have negatives about them, but the type safety lost seems worse to me...
other-emerald
other-emerald2mo ago
Pre-bound array fields? Could you show a snippet of what you'd like for the usage to look like? It doesn't need to be correct, but it's difficult to figure out what the desired outcome is
unwilling-turquoise
unwilling-turquoise2mo ago
Here's the field, + to add more items, validated by Zod.
No description
unwilling-turquoise
unwilling-turquoise2mo ago
Here's the code atm:
interface StringArrayFieldProps extends React.ComponentProps<typeof Input> {
label: string;
}
export const StringArrayField = ({
label,
...props
}: StringArrayFieldProps) => {
const field = useFieldContext<string[]>();

const errors = useStore(field.store, (state) => state.meta.errors);
console.log(field.state.value, errors);

return (
<div className="grid gap-2">
<div className="grid gap-2">
<Label
htmlFor={field.name}
className="flex flex-row gap-2 justify-between"
>
{label}
<Button
variant="outline"
size="icon"
onClick={() => {
field.pushValue('');
}}
>
<PlusIcon className="size-4" />
</Button>
</Label>

<div className="grid gap-2">
{field.state.value.map((value, index) => (
<Input
key={index}
value={value}
onChange={(e) => field.replaceValue(index, e.target.value)}
{...props}
/>
))}
</div>
</div>
<FieldErrors meta={field.state.meta} />
</div>
);
};
interface StringArrayFieldProps extends React.ComponentProps<typeof Input> {
label: string;
}
export const StringArrayField = ({
label,
...props
}: StringArrayFieldProps) => {
const field = useFieldContext<string[]>();

const errors = useStore(field.store, (state) => state.meta.errors);
console.log(field.state.value, errors);

return (
<div className="grid gap-2">
<div className="grid gap-2">
<Label
htmlFor={field.name}
className="flex flex-row gap-2 justify-between"
>
{label}
<Button
variant="outline"
size="icon"
onClick={() => {
field.pushValue('');
}}
>
<PlusIcon className="size-4" />
</Button>
</Label>

<div className="grid gap-2">
{field.state.value.map((value, index) => (
<Input
key={index}
value={value}
onChange={(e) => field.replaceValue(index, e.target.value)}
{...props}
/>
))}
</div>
</div>
<FieldErrors meta={field.state.meta} />
</div>
);
};
unwilling-turquoise
unwilling-turquoise2mo ago
Having issues with the errors because the Zod validation put's the erros with the key like this:
No description
other-emerald
other-emerald2mo ago
Right, so the issue is that a compound field doesn't receive the nested errors but based on this text here, you're looking for a way to group fields? like in an ideal scenario, the Input would each be one field, but you can group them into one array accessible as component
unwilling-turquoise
unwilling-turquoise2mo ago
I was looking for a way to make a reusable string[] type of field
other-emerald
other-emerald2mo ago
there's something in the works for that, if you're up to try a PR version and give feedback before it's implemented it's essentially done, just ironing out some minor issues
unwilling-turquoise
unwilling-turquoise2mo ago
I have done grouped fields with withForm, I was just hoping this one was simple enough that I could make it reusable, unlike this one:
No description
other-emerald
other-emerald2mo ago
yes, the proposed solution is similar to withForm, but widened so that multiple forms can use the same group
unwilling-turquoise
unwilling-turquoise2mo ago
I have a deadline for the end of this month so maybe next month I could try it, is it the Group something PR I saw somewhere? withFieldGroup?
other-emerald
other-emerald2mo ago
yep! If it's with a deadline, then you're right, I wouldn't go for it yet. Every PR has scripts in them which tell you how to install the PR version of the package, so you can revisit it whenever
unwilling-turquoise
unwilling-turquoise2mo ago
Hmm for now I think I can hack it with the useFormContext() and useStore & then filter by key that starts with the field name...
other-emerald
other-emerald2mo ago
that's one way. Another is that assuming string[] is the field value, then ${field.name}[${index}] must exist even though the current typing will complain
unwilling-turquoise
unwilling-turquoise2mo ago
Ah yes, now that I think about it, each input needs it's own error, great catch
other-emerald
other-emerald2mo ago
that, as well as meta state would be lost with compound fields (which field has been touched etc.)
unwilling-turquoise
unwilling-turquoise2mo ago
Which means I would have to track it manually per field..., honestly the other way with the form.Field with type safety gone is just looking better by the second
other-emerald
other-emerald2mo ago
well, either way, the zod schema issue is also going to be addressed at some point whether or not it fits the situation here, you should be able to make object-level fields
unwilling-turquoise
unwilling-turquoise2mo ago
interface StringArrayFieldProps extends React.ComponentProps<typeof Input> {
label: string;
}
export const StringArrayField = ({
label,
...props
}: StringArrayFieldProps) => {
const form = useFormContext();
const field = useFieldContext<string[]>();

return (
<div className="grid gap-2">
<div className="grid gap-2">
<Label
htmlFor={field.name}
className="flex flex-row gap-2 justify-between"
>
{label}
<Button
variant="outline"
size="icon"
onClick={() => {
field.pushValue('');
}}
>
<PlusIcon className="size-4" />
</Button>
</Label>

<div className="grid gap-2">
{field.state.value.map((_, index) => (
<form.Field key={index} name={`${field.name}[${index}]` as never}>
{(itemField) => (
<>
<div className="flex flex-row gap-2">
<Input
value={itemField.state.value}
onBlur={itemField.handleBlur}
onChange={(e) =>
itemField.handleChange(e.target.value as never)
}
{...props}
/>
<Button
variant="outline"
size="icon"
onClick={() => field.removeValue(index)}
>
<TrashIcon className="size-4" />
</Button>
</div>
<FieldErrors meta={itemField.state.meta} />
</>
)}
</form.Field>
))}
</div>
</div>
<FieldErrors meta={field.state.meta} />
</div>
);
};
interface StringArrayFieldProps extends React.ComponentProps<typeof Input> {
label: string;
}
export const StringArrayField = ({
label,
...props
}: StringArrayFieldProps) => {
const form = useFormContext();
const field = useFieldContext<string[]>();

return (
<div className="grid gap-2">
<div className="grid gap-2">
<Label
htmlFor={field.name}
className="flex flex-row gap-2 justify-between"
>
{label}
<Button
variant="outline"
size="icon"
onClick={() => {
field.pushValue('');
}}
>
<PlusIcon className="size-4" />
</Button>
</Label>

<div className="grid gap-2">
{field.state.value.map((_, index) => (
<form.Field key={index} name={`${field.name}[${index}]` as never}>
{(itemField) => (
<>
<div className="flex flex-row gap-2">
<Input
value={itemField.state.value}
onBlur={itemField.handleBlur}
onChange={(e) =>
itemField.handleChange(e.target.value as never)
}
{...props}
/>
<Button
variant="outline"
size="icon"
onClick={() => field.removeValue(index)}
>
<TrashIcon className="size-4" />
</Button>
</div>
<FieldErrors meta={itemField.state.meta} />
</>
)}
</form.Field>
))}
</div>
</div>
<FieldErrors meta={field.state.meta} />
</div>
);
};
It works better than expected, the 2 never kind of hurts but it maintain key level type safety on the form.Field outside. The only issue I see is that when the first Input is created, it starts with 1 validation already erroring out
other-emerald
other-emerald2mo ago
you're using a form-level zod schema, right?
unwilling-turquoise
unwilling-turquoise2mo ago
Yes
other-emerald
other-emerald2mo ago
if so, then you definitely want a isTouched guard in your field errors
unwilling-turquoise
unwilling-turquoise2mo ago
Hmm first time this is an issue, I thought it would not be an issue since I have it on onSubmit validation
other-emerald
other-emerald2mo ago
oh, so you only have it on submit? Then how come the new array already has an error in it? :HmmNoted:
unwilling-turquoise
unwilling-turquoise2mo ago
That's the million dollar question
other-emerald
other-emerald2mo ago
there's a 'debug' component you could add real quick, perhaps it helps. One sec
unwilling-turquoise
unwilling-turquoise2mo ago
Already using it, funny thing is it starts without errors, and the moment I add the first input the error shows:
No description
other-emerald
other-emerald2mo ago
can you share your form hook? this is strange perhaps what's happening is that annoying default HTML feature where a select menu triggers form submission on select?
unwilling-turquoise
unwilling-turquoise2mo ago
Would you like a call? would be faster than typing every step
other-emerald
other-emerald2mo ago
I maybe, perhaps -# am at work right now I can call in about 10 hours if that suits you
unwilling-turquoise
unwilling-turquoise2mo ago
I think that lands a bit tight in between 2 meetings, I'll let you know when I'm available Thanks
other-emerald
other-emerald2mo ago
no rush :Prayge: my suggestion at the moment is to console log the onSubmit event, because your previous screenshot included a dropdown it might submit by default. I've had that problem with React Bootstrap before, I don't know if it's the same for the library you're using
unwilling-turquoise
unwilling-turquoise2mo ago
You know what, it was the button, e.preventDefault() did the trick. Also the dropdown is the other input that I have with withForm, has nothing to do with this string array one. And now I realize any non submit buttons I use inside my fields will need e.preventDefault() 🤦‍♂️
other-emerald
other-emerald2mo ago
If a button is inside <form>, it's type property defaults to "submit" unless you specify it's a type="button"
other-emerald
other-emerald2mo ago
MDN Web Docs
<button>: The Button element - HTML | MDN
The <button> HTML element is an interactive element activated by a user with a mouse, keyboard, finger, voice command, or other assistive technology. Once activated, it then performs an action, such as submitting a form or opening a dialog.
unwilling-turquoise
unwilling-turquoise2mo ago
type="button" is the answer, I guess I missed it since the other buttons would never leave to a state were it could submit and show an error at the same time.
other-emerald
other-emerald2mo ago
well, it's one of those decisions made in the 80s that just irritates devs 😅 but yeah, glad to hear that was the problem
unwilling-turquoise
unwilling-turquoise2mo ago
Ah the other buttons are not a problem probably because the parent component disables that behavior by default:
<PopoverTrigger asChild>
<Button
id={field.name}
variant="outline"
className={cn(
'w-full justify-start text-left font-normal',
!field.state.value && 'text-muted-foreground',
)}
>
<CalendarIcon className="mr-2 size-4" />

{field.state.value && isValid(field.state.value)
? field.state.value.toLocaleDateString()
: label}
</Button>
</PopoverTrigger>
<PopoverTrigger asChild>
<Button
id={field.name}
variant="outline"
className={cn(
'w-full justify-start text-left font-normal',
!field.state.value && 'text-muted-foreground',
)}
>
<CalendarIcon className="mr-2 size-4" />

{field.state.value && isValid(field.state.value)
? field.state.value.toLocaleDateString()
: label}
</Button>
</PopoverTrigger>

Did you find this page helpful?