T
TanStackโ€ข2y ago
optimistic-gold

creating a reusable styled form component

Hey, so I am working on creating a styled and aria friendly reusable form component using tanstack/react-form. I havent done that much yet because I am unsure of the best approach here. I am generally wondering if it is a bad approach to create a separate context for the form, and pass in formOptions. The plan is to create different styled form fields for a labeled input, checkbox etc with all predefined aria ids and styles. This is what I have so far, and it would be nice with some feedback if I am using the library correctly for this. I am also not completely sure if my type definition for the fromOptions is correct. Thanks for any help :)
type FormProps = HTMLAttributes<HTMLFormElement> & {
formOptions: FormOptions<Record<string, unknown>, typeof zodValidator>;
};

type FormTextInputProps = HTMLAttributes<HTMLInputElement> & {
name: string;
label: string;
};

type FormComponentProps = ForwardRefExoticComponent<FormProps> & {
TextInput: ForwardRefExoticComponent<FormTextInputProps>;
};
type FormContextProps = FormApi<Record<string, unknown>, typeof zodValidator>;

const FormContext = createContext<FormContextProps>({} as FormContextProps);

const Form = forwardRef<HTMLFormElement, FormProps>(
({ className, formOptions, ...props }, ref) => {
const HeadlessForm = useForm({
validatorAdapter: zodValidator,
...formOptions,
});

return (
<FormContext.Provider value={HeadlessForm}>
<HeadlessForm.Provider>
<form
className={className}
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
void HeadlessForm.handleSubmit();
}}
ref={ref}
{...props}
/>
</HeadlessForm.Provider>
</FormContext.Provider>
);
},
) as FormComponentProps;
type FormProps = HTMLAttributes<HTMLFormElement> & {
formOptions: FormOptions<Record<string, unknown>, typeof zodValidator>;
};

type FormTextInputProps = HTMLAttributes<HTMLInputElement> & {
name: string;
label: string;
};

type FormComponentProps = ForwardRefExoticComponent<FormProps> & {
TextInput: ForwardRefExoticComponent<FormTextInputProps>;
};
type FormContextProps = FormApi<Record<string, unknown>, typeof zodValidator>;

const FormContext = createContext<FormContextProps>({} as FormContextProps);

const Form = forwardRef<HTMLFormElement, FormProps>(
({ className, formOptions, ...props }, ref) => {
const HeadlessForm = useForm({
validatorAdapter: zodValidator,
...formOptions,
});

return (
<FormContext.Provider value={HeadlessForm}>
<HeadlessForm.Provider>
<form
className={className}
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
void HeadlessForm.handleSubmit();
}}
ref={ref}
{...props}
/>
</HeadlessForm.Provider>
</FormContext.Provider>
);
},
) as FormComponentProps;
29 Replies
optimistic-gold
optimistic-goldOPโ€ข2y ago
Here is the rest of the component:
Form.TextInput = forwardRef<HTMLInputElement, FormTextInputProps>(
({ className, name, label, ...props }, ref) => {
const HeadlessForm = useContext(FormContext);
return (
<HeadlessForm.Field
name={name}
validators={{
onChange: z.string().min(3, 'name must be at least 3 characters'),
onChangeAsyncDebounceMs: 500,
}}
children={(field) => {
return (
<>
<label htmlFor={field.name}>{label}</label>
<input
className={className}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
ref={ref}
{...props}
/>
</>
);
}}
/>
);
},
);

export { Form, type FormComponentProps, type FormTextInputProps };
Form.TextInput = forwardRef<HTMLInputElement, FormTextInputProps>(
({ className, name, label, ...props }, ref) => {
const HeadlessForm = useContext(FormContext);
return (
<HeadlessForm.Field
name={name}
validators={{
onChange: z.string().min(3, 'name must be at least 3 characters'),
onChangeAsyncDebounceMs: 500,
}}
children={(field) => {
return (
<>
<label htmlFor={field.name}>{label}</label>
<input
className={className}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
ref={ref}
{...props}
/>
</>
);
}}
/>
);
},
);

export { Form, type FormComponentProps, type FormTextInputProps };
`
evolutionary-blush
evolutionary-blushโ€ข2y ago
This looks like a good start! This might sound weird, but do you wanna submit your WIP to the official TanStack Form repo as examples in a PR? Even if it doesn't work 100%, it's a usecase we wanna support and by doing things this way we can have an easier time doing a back-n-forth and code review ๐Ÿ™‚
optimistic-gold
optimistic-goldOPโ€ข2y ago
I will do! Iโ€™ll create two different PRs then. One for a reusable component and one for fixing the IDs. I have done some more research and I may have a bad understanding of some of this, but the way I am planning to use this component I seem to have to create a seperate field component and the styled variants underneath it. This seems to be a limitation of how you are using children as a renderprop. So when the reusable component is finished I think it will be used like this in someway:
<Form>
<Form.Field
children={(field) => {
return (
<Form.Field.Text field={field} />
);
}}
/>
</Form>
<Form>
<Form.Field
children={(field) => {
return (
<Form.Field.Text field={field} />
);
}}
/>
</Form>
I wrote this on my phone so I hope it looks correct. I honestly thinks this looks a little ugly, but the reason we have to seperate the Field component from the text and then pass it to it as a parameter is if we would want to change any values in the styled Text component that needs to use the field it needs to know of it. Also we pass the field for any "default" values that use the field. I hope this explanation comes across somewhat understandable. I've also had some difficulties working with types for this component, but I will see what I can do for a PR
optimistic-gold
optimistic-goldOPโ€ข2y ago
@crutchcorn There is an empty .prettierrc file inside of each of the examples. This causes vscode to use that configuration instead of the global prettier.config.js, which uses double quotes instead of single quotes and semi colons. I would think that you would like to use the config in the global prettier config. Should I delete the per example prettier configs, replace their config so they match the global one, just let prettier use the per example config (which are all empty so default prettier settings) or just disable prettier?
No description
evolutionary-blush
evolutionary-blushโ€ข2y ago
Oh good catch. @Raytuzio any preference for how we wanna handle this?
optimistic-gold
optimistic-goldOPโ€ข2y ago
I also have some questions, sorry if I am asking too much its my first time contributing to open source. I don't have any experience working with vue and solid, but I see that they don't have any equivalent alternative to the react useId hook. Even though I have never used those frameworks I think I can just fix the Id issue for them aswell, but would you want me to give them a simple predefined id like "first-name-id" or use a counter based solution. I am leaning towards the first alternative since it's used for a basic example.
evolutionary-blush
evolutionary-blushโ€ข2y ago
No worries about "too many questions", you're golden. So a few things about the ID: - We don't need useId, we have an internal id for forms and fields that we can use instead if we need a unique field id - Ignoring that tho, I don't think we need that id. Instead, add id as an HTML attribute to input So it'd be like (on phone, forgive):
<label htmlFor={field.name}/>
<input id={field.name}/>
<label htmlFor={field.name}/>
<input id={field.name}/>
optimistic-gold
optimistic-goldOPโ€ข2y ago
Yea that will work, but cant there be a situation where the name can have a value that is not valid as an ID? I know that wont matter for this case since the name is firstName or lastName, but given that someone uses the example and for some random reason sets the name to something with a space. That will mess up the id since I dont think they can contain spaces.
evolutionary-blush
evolutionary-blushโ€ข2y ago
Name shouldn't ever have spaces ๐Ÿ˜… Not really, anyway Keep in mind that name will match the key of the object
optimistic-gold
optimistic-goldOPโ€ข2y ago
I cant get my changes to show when running the docs locally. I followed the CONTRIBUTING.md. Structured like this: tanstack/tanstack.com running with pnpm run dev and tanstack/form running with pnpm run watch. Am i missing something, I dont really understand how the docs website will acess the local form, I am probably missing something
evolutionary-blush
evolutionary-blushโ€ข2y ago
CC @fuko
optimistic-gold
optimistic-goldOPโ€ข2y ago
Should I wait for answers here or add the PR and then update it accordingly
evolutionary-blush
evolutionary-blushโ€ข2y ago
Feel free to PR and update it ๐Ÿ™‚
optimistic-gold
optimistic-goldOPโ€ข2y ago
I have been working on the reusable component example for a while now, and I am struggling with getting the correct types for the components. So for the Form wrapper component I want to have some default options that wont be changed in the application. For example the validatorAdapter, with the rest of the formOptions passed as a parameter. I think this type is mostly correct, but I am a little unsure on the Record<string, unknown>part:
type FormProps = HTMLAttributes<HTMLFormElement> & {
formOptions: FormOptions<Record<string, unknown>, typeof zodValidator>;
};

type FormComponentProps = ForwardRefExoticComponent<FormProps> & {
Field: FormFieldProps & {
Text: ForwardRefExoticComponent<FormFieldTextProps>;
Checkbox: ForwardRefExoticComponent<FormFieldCheckboxProps>;
Select: ForwardRefExoticComponent<FormFieldSelectProps>;
};
};

type FormContextProps = FormApi<Record<string, unknown>, typeof zodValidator>;

const FormContext = createContext<FormContextProps>({} as FormContextProps);

const Form = forwardRef<HTMLFormElement, FormProps>(
({ className, formOptions, ...props }, ref) => {
const HeadlessForm = useForm({
validatorAdapter: zodValidator,
...formOptions,
});

return (
<FormContext.Provider value={HeadlessForm}>
<HeadlessForm.Provider>
<form
className={className}
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
void HeadlessForm.handleSubmit();
}}
ref={ref}
{...props}
/>
</HeadlessForm.Provider>
</FormContext.Provider>
);
},
) as FormComponentProps;
type FormProps = HTMLAttributes<HTMLFormElement> & {
formOptions: FormOptions<Record<string, unknown>, typeof zodValidator>;
};

type FormComponentProps = ForwardRefExoticComponent<FormProps> & {
Field: FormFieldProps & {
Text: ForwardRefExoticComponent<FormFieldTextProps>;
Checkbox: ForwardRefExoticComponent<FormFieldCheckboxProps>;
Select: ForwardRefExoticComponent<FormFieldSelectProps>;
};
};

type FormContextProps = FormApi<Record<string, unknown>, typeof zodValidator>;

const FormContext = createContext<FormContextProps>({} as FormContextProps);

const Form = forwardRef<HTMLFormElement, FormProps>(
({ className, formOptions, ...props }, ref) => {
const HeadlessForm = useForm({
validatorAdapter: zodValidator,
...formOptions,
});

return (
<FormContext.Provider value={HeadlessForm}>
<HeadlessForm.Provider>
<form
className={className}
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
void HeadlessForm.handleSubmit();
}}
ref={ref}
{...props}
/>
</HeadlessForm.Provider>
</FormContext.Provider>
);
},
) as FormComponentProps;
Now when it comes to the Field component I can't get the types working at all. I tried using the FieldOptions exported from the library, tried using typeof Field from the Field component that is also exported and I tried picking the value from the FormAPI type with whatever this is: Pick<FormApi<Record<string, unknown>, typeof zodValidator>, 'Field'>. This is how the component looks, it should pass all props through so I can use the children render prop and pass in the styled field component inside it, but I can't get the type working.
Form.Field = ({ ...props }: FormFieldProps) => {
const HeadlessForm = useContext(FormContext);
return <HeadlessForm.Field {...props} />;
};
Form.Field = ({ ...props }: FormFieldProps) => {
const HeadlessForm = useContext(FormContext);
return <HeadlessForm.Field {...props} />;
};
As for the the different styled Field components that should be used in the renderprop of the component above, I need the type for the field value used as a parameter below:
Form.Field.Text = forwardRef<HTMLInputElement, FormFieldTextProps>(
({ className, field, label, ...props }, ref) => {
return (
<div className='flex'>
<Input
className={cx('placeholder:text-background', className)}
invalid={field.state.meta.errors}
type='text'
id={field.name}
name={field.name}
placeholder={label}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
ref={ref}
{...props}
/>
Form.Field.Text = forwardRef<HTMLInputElement, FormFieldTextProps>(
({ className, field, label, ...props }, ref) => {
return (
<div className='flex'>
<Input
className={cx('placeholder:text-background', className)}
invalid={field.state.meta.errors}
type='text'
id={field.name}
name={field.name}
placeholder={label}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
ref={ref}
{...props}
/>
So an example usecase for the reusable component would be something like this:
<Form
formOptions={{
defaultValues: {
test: '',
},
}}
>
<Form.Field
name='test'
children={(field) => {
return <Form.Field.Text field={field} label='test' />;
}}
/>
</Form>
<Form
formOptions={{
defaultValues: {
test: '',
},
}}
>
<Form.Field
name='test'
children={(field) => {
return <Form.Field.Text field={field} label='test' />;
}}
/>
</Form>
quickest-silver
quickest-silverโ€ข2y ago
Hmmm, this directory structure looks okay, and I think see the changes, wonder what the problem is... @ker4lis Do you see any errors? What do you see on http://localhost:3000/form/latest/docs/guides/basic-concepts ๐Ÿค”
No description
optimistic-gold
optimistic-goldOPโ€ข2y ago
Ahh I am stupid. It is working. It is just that I was checking the examples. See:
No description
No description
optimistic-gold
optimistic-goldOPโ€ข2y ago
I overlooked adding the id to the docs first so I did that in a later commit, but if you see above, the id doesnt show in the sandbox example. I guess that is how it is supposed to be?
quickest-silver
quickest-silverโ€ข2y ago
wow, that browser looks nice! Honestly, I'm not super familiar with the examples, but I'll check it
optimistic-gold
optimistic-goldOPโ€ข2y ago
https://arc.net/ I never thought I would change browser but just try it :) Do you know anything about how I can set the types correctly? https://discord.com/channels/719702312431386674/1193461203545096202/1194020844297535611
quickest-silver
quickest-silverโ€ข2y ago
Okay, checked it and yes, it is how it's supposed to be. In the form's case, this is the embedded code sandbox (https://github.com/TanStack/tanstack.com/blob/a552b123fdf8e5b55507291a966c9c05e8164373/app/routes/form.%24version.docs.framework.%24framework.examples.%24.tsx#L47-L54)
<iframe
src={`https://codesandbox.io/embed/github/${repo}/tree/${branch}/examples/${examplePath}?autoresize=1&fontsize=14&theme=${
isDark ? 'dark' : 'light'
}`}
title={`${repo}: ${examplePath}`}
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
className="flex-1 w-full overflow-hidden lg:rounded-lg shadow-xl shadow-gray-700/20 bg-white dark:bg-black"
/>
<iframe
src={`https://codesandbox.io/embed/github/${repo}/tree/${branch}/examples/${examplePath}?autoresize=1&fontsize=14&theme=${
isDark ? 'dark' : 'light'
}`}
title={`${repo}: ${examplePath}`}
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
className="flex-1 w-full overflow-hidden lg:rounded-lg shadow-xl shadow-gray-700/20 bg-white dark:bg-black"
/>
If you track down the branch variable, you'll see that it'll be main for v0, which is the latest and only version. Hmmm, not off the top of my head... ๐Ÿ˜… I'll check your code though quickly now and if I find the answer in 10 mins, I'll share it!
optimistic-gold
optimistic-goldOPโ€ข2y ago
thank you
quickest-silver
quickest-silverโ€ข2y ago
Okay, so honestly in 10 mins I couldn't solve the issue. ๐Ÿคฃ Actually, I couldn't even read everything, because it was kinda hard to copy your code into my local env.... Could you draft a PR with the file you're working on? It'd be a lot easier for me to check it... (I think this is also what @crutchcorn advised: https://discord.com/channels/719702312431386674/1193461203545096202/1193957055019634768) It's okay if it has errors Actually, I'm most interested in the errors ๐Ÿคฃ
optimistic-gold
optimistic-goldOPโ€ข2y ago
Will do, I think the errors are mostly related to my incompetence with using react and the library and not anything wrong with the library itself just so you know. The approach I am taking may also be completely redundant
quickest-silver
quickest-silverโ€ข2y ago
I'm not sure about the approach yet, but the problem you're trying to solve is very valid! โ˜บ๏ธ So I hope that eventually it'll be solved...
optimistic-gold
optimistic-goldOPโ€ข2y ago
@fuko Finally got time to work on this and add a PR. I don't know if it is even needed in the docs, but I learnt a lot while making it about the Tanstack Form library. Take a look: https://github.com/TanStack/form/pull/583
GitHub
docs: Reusable styled form component by michaelbrusegard ยท Pull Req...
This is currently a WIP. Please close the pull request if you feel like this addition is unnecessary, I don't want to waste anybody's time. Current issues Current issues I have been unable ...
quickest-silver
quickest-silverโ€ข2y ago
Hey, thanks for your work, I'll check it out later today! โ˜บ๏ธ I checked a PR and wow, it looks like you worked a lot on it! โ˜บ๏ธ That said, I had a feeling that maybe this could be too much abstraction when I read the PR. For example, if I understood it correctly there are 2 context providers (<Provider>, <FormContext.Provider>) in the Form component (examples/react/reusable-styled-form-component/src/Form.tsx), and it looks like that they are storing very similar data. I was thinking that maybe the type issues are a result of this (i.e. too much abstraction), and we'd be better off choosing another approach that requires us to write a bit more code. I wanted to write an example to show what I mean, but I ran out of time (yeah... types are tricky ๐Ÿคฃ ), so I'll try to re-visit this issue during the week! Unfortunately, I'll have some other tasks in the tanstack .com repo that I have to finish, so I won't be fast. ๐Ÿ˜ข
optimistic-gold
optimistic-goldOPโ€ข2y ago
Hey, yeah donโ€™t worry about it. I am just trying to learn. For my part I wanted to have a styled reusable component to make it easier to create similar forms without duplicating code. Since I have a multi page auth solution with 4-5 forms. Then again my approach may be bad because i donโ€™t understand the ins and outs of the library. If it is possible to avoid having a second provider that would be better. Do you want me to close the PR?
evolutionary-blush
evolutionary-blushโ€ข2y ago
If you'rer alright with it - let's keep it open ๐Ÿ™‚ I want to both capture your work but also it serves as a basis for us to jump off of when we pick up the work again
optimistic-gold
optimistic-goldOPโ€ข2y ago
๐Ÿ‘

Did you find this page helpful?