-
Notifications
You must be signed in to change notification settings - Fork 37
add async form config article #105
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,288 @@ | ||||||
| --- | ||||||
| title: Asynchronous form configuration | ||||||
| description: "Define zod form schema with asynchronous data" | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Zod is a proper noun and they also use capitalisation on their own website, so we should do that too throughout the article (not highlighting every occurrence ;) ) |
||||||
| shortDescription: "Define zod form schema with asynchronous data" | ||||||
| released: '2025-08-12T09:00:00.000Z' | ||||||
| cover: images/cover.jpeg | ||||||
| author: Tram Anh Duong | ||||||
| tags: | ||||||
| - form | ||||||
| - typescript | ||||||
| - react | ||||||
| - react-hook-form | ||||||
| - zod | ||||||
| - frontend-development | ||||||
| publishAs: tduong992 | ||||||
| hideFromHashnodeCommunity: false | ||||||
| saveAsDraft: true | ||||||
| --- | ||||||
|
|
||||||
| Recently, I implemented a custom form validation that takes a configuration value from the backend as validation | ||||||
| criteria. Meaning, I need to get the async value before setting up the form. | ||||||
|
|
||||||
| In this blog post, I will show the example using React and TypeScript. | ||||||
| To manage my forms and form validations I work with react-hook-form and zod. | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: it might be good to have 1-2 sentences about what Zod is While Zod's fairly popular it's still good to give a little context to those who haven't heard of it yet. Also to understand how it's relevant to the problem presented (react-hook-form's obvious name helps with not requiring this as much). |
||||||
|
|
||||||
| ## Basic Form Configuration | ||||||
|
|
||||||
| Let's consider a form with two (required) datetime form fields: `dateStart` and `dateEnd`. | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
unnecessary parenthesis imo |
||||||
| The following is the zod form schema definition with validations. | ||||||
| _Note_: `z.date()` has an implicit "required" validation rule. | ||||||
|
|
||||||
| ```ts | ||||||
| import { z } from 'zod'; | ||||||
|
|
||||||
|
|
||||||
| // zod schema declaration | ||||||
| const FormSchema = z.object( | ||||||
| { | ||||||
| dateStart: z.date(), | ||||||
| dateEnd: z.date(), | ||||||
| }, | ||||||
| ); | ||||||
| type FormData = z.infer<typeof FormSchema>; | ||||||
| ``` | ||||||
|
|
||||||
| Then, the form schema can be passed to react-hook-form, which will handle the validations for us and | ||||||
| make the errors accessible from its `formState.errors` API. | ||||||
|
|
||||||
| ```ts | ||||||
| import { zodResolver } from '@hookform/resolvers/zod'; | ||||||
| import { useForm } from 'react-hook-form'; | ||||||
|
|
||||||
|
|
||||||
| // react-hook-form setup in a functional component or custom hook | ||||||
| const form = useForm<FormData>( | ||||||
| { | ||||||
| resolver: zodResolver(FormSchema), | ||||||
| defaultValues: { | ||||||
| dateStart: '' as unknown as Date, | ||||||
| dateEnd: '' as unknown as Date, | ||||||
| }, | ||||||
| } | ||||||
| ); | ||||||
|
|
||||||
| // get all validation violations: form.formState.errors | ||||||
| ``` | ||||||
|
|
||||||
| Let's see it all together with UI elements. | ||||||
| _Note_: change the class name "error" with the error style of your preference, | ||||||
| e.g. with red border to highlight the input field that has an error. | ||||||
|
|
||||||
| ```tsx | ||||||
| import { zodResolver } from '@hookform/resolvers/zod'; | ||||||
| import { useForm } from 'react-hook-form'; | ||||||
| import { z } from 'zod'; | ||||||
|
|
||||||
|
|
||||||
| // zod schema declaration | ||||||
| const FormSchema = z.object( | ||||||
| { | ||||||
| dateStart: z.date(), | ||||||
| dateEnd: z.date(), | ||||||
| }, | ||||||
| ); | ||||||
| type FormData = z.infer<typeof FormSchema>; | ||||||
|
|
||||||
| function MyForm() { | ||||||
|
|
||||||
| // react-hook-form setup in a functional component or custom hook | ||||||
| const form = useForm<FormData>( | ||||||
| { | ||||||
| resolver: zodResolver(FormSchema), | ||||||
| defaultValues: { | ||||||
| dateStart: '' as unknown as Date, | ||||||
| dateEnd: '' as unknown as Date, | ||||||
| }, | ||||||
| } | ||||||
| ); | ||||||
|
|
||||||
| function onSubmit(data: FormData): void { | ||||||
| console.log(data); | ||||||
| } | ||||||
|
|
||||||
| return ( | ||||||
| <form submit={ form.handleSubmit(onSubmit) }> | ||||||
| <div> | ||||||
| <MyDatePicker | ||||||
| { ...form.register('dateStart') } | ||||||
| className={ !!form.formState.errors?.dateStart ? 'error' : '' } | ||||||
| /> | ||||||
| { form.formState.errors?.dateStart && ( | ||||||
| <p>{ form.formState.errors.dateStart.message }</p> // <p>Start date is required</p> | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question: where is the actual text that's shown in the comment coming from? is that automatic from react-hook-form, or is that a configuration that's left out for brevity? |
||||||
| ) } | ||||||
| </div> | ||||||
| <div> | ||||||
| <MyDatePicker | ||||||
| { ...form.register('dateEnd') } | ||||||
| className={ !!form.formState.errors?.dateEnd ? 'error' : '' } | ||||||
| /> | ||||||
| { form.formState.errors?.dateEnd && ( | ||||||
| <p>{ form.formState.errors.dateEnd.message }</p> // <p>End date is required</p> | ||||||
| ) } | ||||||
| </div> | ||||||
| <button type={ 'submit' }>Submit</button> | ||||||
| </form> | ||||||
| ); | ||||||
| } | ||||||
| ``` | ||||||
|
|
||||||
| ## Form Schema Definition with External Data | ||||||
|
|
||||||
| Now, the requirement is that the specified `dateEnd - dateStart` should not exceed a certain time range. | ||||||
| For best practice, the submitted values should be also validated in the backend and/or maybe this time range criteria is | ||||||
| used for other use cases as well. In addition, the time range criteria needs to be configurable at deployment time. | ||||||
| Therefore, it would make sense to set this time range value in some configuration file, which both the frontend and | ||||||
| backend can read. | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. thought: I don't have good recommendations off the top of my head, but I notice that a lot of your sentences start with a transition word/phrase. In this case it's every sentence, which makes the structure feel repetitive. |
||||||
|
|
||||||
| Here is how I form the schema definition to satisfy the requirements. | ||||||
|
|
||||||
| To pass the (async) values to define the form validation, I need to define the schema via a function. | ||||||
| Then, I can define my custom form validation using [`.superRefine()`](https://v3.zod.dev/?id=superrefine) | ||||||
| (or [`.check()`](https://zod.dev/api?id=superrefine) in zod v4). | ||||||
|
|
||||||
| _Note_: in order to distinguish between an error of the individual field vs the combined fields | ||||||
| (i.e. an error that affects both), and to not show the same combined error message twice (i.e. for both form fields), | ||||||
| I add an undefined form field only for the purpose to assign the combined error to something. | ||||||
|
|
||||||
| ```ts | ||||||
| import { z } from 'zod'; | ||||||
|
|
||||||
|
|
||||||
| // zod schema declaration | ||||||
| const BaseFormSchema = z.object( | ||||||
| { | ||||||
| dateRange: z.undefined(), | ||||||
| dateStart: z.date(), | ||||||
| dateEnd: z.date(), | ||||||
| }, | ||||||
| ); | ||||||
|
|
||||||
| function getFormSchema(rangeDays: number) { | ||||||
| return BaseFormSchema | ||||||
| .superRefine( | ||||||
| ({ dateStart, dateEnd }, ctx) => { | ||||||
| if (!isWithinValidDatetimeRange(dateStart, dateEnd, rangeDays)) { | ||||||
| ctx.addIssue( | ||||||
| { | ||||||
| path: ['dateRange'], | ||||||
| code: z.ZodIssueCode.custom, | ||||||
| message: `Date range exceeded (max ${ rangeDays } days)`, | ||||||
| } | ||||||
| ) | ||||||
| } | ||||||
| } | ||||||
| ); | ||||||
| } | ||||||
|
|
||||||
| function isWithinValidDatetimeRange(dateStart: Date | string, dateEnd: Date | string, rangeDays: number): boolean { | ||||||
| const diffInMilliseconds = new Date(dateEnd).getTime() - new Date(dateStart).getTime(); | ||||||
| // magic number DAYS_IN_MILLISECONDS = 24 * 60 * 60 * 1000 | ||||||
| return diffInMilliseconds < (rangeDays * DAYS_IN_MILLISECONDS); | ||||||
| } | ||||||
|
|
||||||
| type FormSchema = ReturnType<typeof getFormSchema>; | ||||||
| type FormData = z.infer<typeof FormSchema>; | ||||||
| ``` | ||||||
|
|
||||||
| Then, in the form definition I call the form schema function, which expects to receive the configuration values from | ||||||
| the backend synchronously. | ||||||
|
|
||||||
| ```ts | ||||||
| import { zodResolver } from '@hookform/resolvers/zod'; | ||||||
| import { useForm } from 'react-hook-form'; | ||||||
|
|
||||||
|
|
||||||
| // react-hook-form setup in a functional component or custom hook | ||||||
| const form = useForm<FormData>( | ||||||
| { | ||||||
| resolver: zodResolver(getFormSchema(serverConfig.rangeDays)), | ||||||
| defaultValues: { | ||||||
| dateStart: '' as unknown as Date, | ||||||
| dateEnd: '' as unknown as Date, | ||||||
| }, | ||||||
| } | ||||||
| ); | ||||||
| ``` | ||||||
|
|
||||||
| ## Handle Asynchronicity / Wrap-Up | ||||||
|
|
||||||
| To satisfy the rule "a hook cannot be called conditionally", I simply call the `useForm()` hook | ||||||
| from a functional component that is rendered only after a successfully fetched data response, | ||||||
| otherwise I show some loading screen. | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. thought: After finishing reading I feel a little bit tricked. 😄 Based on the title I was expecting some elaborate solution/hack to make Zod work with async parameters/options, but then the async aspect is solved "outside", in a manner where it doesn't really matter that there is a form and form validation. Don't get me wrong, this doesn't mean the article isn't interesting or the solution bad. On the contrary, but maybe we should frame the article slightly differently? I think the custom combined field validation with a parameter is the actual hero of the story, but maybe that's just because I haven't used Zod myself yet. 😅
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. thanks for the review, all valid points! 👍🏻 as i was addressing your feedback, i figured that this PR is actually two mini-articles in one, as i am still seeing it as a single user story.. will keep you posted! |
||||||
|
|
||||||
| ```tsx | ||||||
| import { zodResolver } from '@hookform/resolvers/zod'; | ||||||
| import { useForm } from 'react-hook-form'; | ||||||
|
|
||||||
|
|
||||||
| // handle asynchronicity with hooks | ||||||
| function MyConfiguration() { | ||||||
| const serverConfig = useServerConfig(); // some custom hook using react-query to fetch the data | ||||||
|
|
||||||
| if (serverConfig.data == undefined) { | ||||||
| return (<p>Loading...</p>); | ||||||
| } | ||||||
|
|
||||||
| return (<MyForm serverConfig={ serverConfig.data }/>); | ||||||
| } | ||||||
| ``` | ||||||
|
|
||||||
| Then, I pass the fetched data from the backend to the form schema function. | ||||||
| Final touch: I handle the combined error message by checking on my `dateRange` undefined form field | ||||||
| and set the appropriate style accordingly. | ||||||
|
|
||||||
| ```tsx | ||||||
| import { zodResolver } from '@hookform/resolvers/zod'; | ||||||
| import { useForm } from 'react-hook-form'; | ||||||
|
|
||||||
|
|
||||||
| function MyForm({ serverConfig }: { serverConfig: { rangeDays: number } }) { | ||||||
|
|
||||||
| // react-hook-form setup in a functional component or custom hook | ||||||
| const form = useForm<FormData>( | ||||||
| { | ||||||
| resolver: zodResolver(getFormSchema(serverConfig.rangeDays)), | ||||||
| defaultValues: { | ||||||
| dateStart: '' as unknown as Date, | ||||||
| dateEnd: '' as unknown as Date, | ||||||
| }, | ||||||
| } | ||||||
| ); | ||||||
|
|
||||||
| function onSubmit(data: FormData): void { | ||||||
| console.log(data); | ||||||
| } | ||||||
|
|
||||||
| return ( | ||||||
| <form submit={ form.handleSubmit(onSubmit) }> | ||||||
| <div> | ||||||
| <MyDatePicker | ||||||
| { ...form.register('dateStart') } | ||||||
| className={ !!form.formState.errors?.dateStart || !!form.formState.errors?.dateRange ? 'error' : '' } | ||||||
| /> | ||||||
| { form.formState.errors?.dateStart && ( | ||||||
| <p>{ form.formState.errors.dateStart.message }</p> // <p>Start date is required</p> | ||||||
| ) } | ||||||
| </div> | ||||||
| <div> | ||||||
| <MyDatePicker | ||||||
| { ...form.register('dateEnd') } | ||||||
| className={ !!form.formState.errors?.dateEnd || !!form.formState.errors?.dateRange ? 'error' : '' } | ||||||
| /> | ||||||
| { form.formState.errors?.dateEnd && ( | ||||||
| <p>{ form.formState.errors.dateEnd.message }</p> // <p>End date is required</p> | ||||||
| ) } | ||||||
| </div> | ||||||
| <> | ||||||
| { form.formState.errors?.dateRange && ( | ||||||
| <p>{ form.formState.errors.dateRange.message }</p> // <p>Date range exceeded (max { serverConfig.rangeDays } days)</p> | ||||||
| ) } | ||||||
| </> | ||||||
| <button type={ 'submit' }>Submit</button> | ||||||
| </form> | ||||||
| ); | ||||||
| } | ||||||
| ``` | ||||||
|
|
||||||
| This is how I configured a zod form schema with asynchronous data. Would you solve it differently? | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
for titles we should/can use capitalisation on nouns