Forms
Forms are essential to web applications—they’re the primary way to collect user input. By providing a consistent structure for capturing and validating data, we ensure a reliable user experience across different applications.
Form Components
The UI Kit includes a set of form components designed to offer a consistent API and integrate smoothly with native HTML forms, React forms, and form libraries.
All form components are built on top of HvFormElement
, which provides a shared API to handle events, manage status
, and expose a value
prop to support controlled components.
Not all UI Kit form components support native form data serialization or validation.
For a complete list of available components, check out HvFormElement
’s related components.
Building Forms
There are several ways to build forms in React, each with its own trade-offs. Below are the most common approaches when using the UI Kit.
Native Form
The simplest method is using an uncontrolled native form
, taking advantage of built-in browser validation and accessing form values via FormData
.
This approach is ideal for basic forms using native-compatible components (e.g., input
), where custom validation is not needed.
import { HvButton, HvCheckBox, HvDatePicker, HvGrid, HvInput, HvRadio, HvRadioGroup, HvTextArea, HvTimePicker, } from "@hitachivantara/uikit-react-core"; import { Map, Phone } from "@hitachivantara/uikit-react-icons"; const countries = [ { id: "pt", label: "Portugal" }, { id: "es", label: "Spain" }, { id: "fr", label: "France" }, { id: "de", label: "Germany" }, { id: "us", label: "United States" }, ]; export default () => ( <form autoComplete="on" onSubmit={(event) => { event.preventDefault(); const formData = new FormData(event.currentTarget); alert(JSON.stringify(Object.fromEntries(formData), null, 2)); }} > <HvGrid container maxWidth="md" rowSpacing="xs"> <HvGrid item xs={12} sm={6}> <HvInput required name="name" label="Full Name" /> </HvGrid> <HvGrid item xs={12} sm={6}> <HvInput required type="email" name="email" label="Email" /> </HvGrid> <HvGrid item xs={12} sm={6}> <HvInput name="street-address" label="Address" /> </HvGrid> <HvGrid item xs={12} sm={6}> <HvInput name="country-name" label="Country" inputProps={{ autoComplete: "off" }} endAdornment={<Map />} validation={(val) => !!countries.find((c) => c.label === val)} validationMessages={{ error: "Invalid country" }} suggestionListCallback={(val) => countries.filter((c) => c.label === val) } /> </HvGrid> <HvGrid item xs={12} sm={6}> <HvInput name="tel" label="Phone number" endAdornment={<Phone />} /> </HvGrid> <HvGrid item xs={12} sm={6}> <HvInput type="password" name="password" label="Password" /> </HvGrid> <HvGrid item xs={12} sm={6}> <HvDatePicker name="bday" label="Birthday" placeholder="Select date" /> </HvGrid> <HvGrid item xs={12} sm={6}> <HvTimePicker name="startTime" label="Start time" /> </HvGrid> <HvGrid item xs={12}> <HvTextArea required name="description" description="Write a short description" label="Description" rows={3} minCharQuantity={16} maxCharQuantity={128} /> </HvGrid> <HvGrid item xs={12}> <HvRadioGroup required name="sex" label="Sex" orientation="horizontal"> <HvRadio value="male" label="Male" /> <HvRadio value="female" label="Female" /> <HvRadio value="other" label="Other" /> </HvRadioGroup> </HvGrid> <HvGrid item xs={12}> <HvCheckBox defaultChecked name="subscribe" label="Subscribe to newsletter" value="yes" /> </HvGrid> <HvGrid item xs={12}> <HvButton type="submit">Submit</HvButton> </HvGrid> </HvGrid> </form> );
React form
Whenever you need to manage internal form values or apply custom validation, using controlled components with React state is a good approach. In this setup, each form input is tied to a state variable, and changes are handled via the onChange
event.
import { useState } from "react"; import { typeToFlattenedError, z } from "zod"; import { HvButton, HvCheckBox, HvDatePicker, HvGrid, HvInput, HvRadio, HvRadioGroup, HvTextArea, HvTimePicker, } from "@hitachivantara/uikit-react-core"; import { Map } from "@hitachivantara/uikit-react-icons"; type Country = "Portugal" | "Spain" | "France" | "Germany" | "United States"; const passwordSchema = z .string() .min(6, "Password is too short") .max(32, "Password is too long") .refine((pwd) => /[!@#$&*]/.test(pwd), "Must include special characters."); const formSchema = z .object({ name: z .string({ required_error: "Name is required" }) .min(3, "Name is too short") .refine((n) => !/\d/.test(n), "Name must not contain numbers"), email: z .string({ required_error: "Email is required" }) .email("Invalid email") .refine( (email) => email.endsWith("@hitachivantara.com"), "Email must be from @hitachivantara.com", ), address: z.string().optional(), country: z .enum(["Portugal", "Spain", "France", "Germany", "United States"], { errorMap: (issue) => ({ message: issue.code === "invalid_enum_value" ? `Invalid country. Must be: ${issue.options}` : "Invalid country.", }), }) .optional(), tel: z.string().optional(), password: passwordSchema, repeatPassword: passwordSchema, bday: z .date() .min(new Date("1900-01-01"), "Too old!") .max(new Date("2000-12-31"), "Too young!") .optional(), startTime: z .object({ hours: z.number(), minutes: z.number(), seconds: z.number() }) .optional(), description: z .string() .min(16, "Description is too short") .max(128, "Description is too long"), sex: z.enum(["male", "female", "other"]), subscribe: z.boolean().optional(), }) .refine((data) => data.password === data.repeatPassword, { message: "Passwords do not match", path: ["repeatPassword"], }); type FormSchema = z.infer<typeof formSchema>; export default () => { const [data, setData] = useState<Partial<FormSchema>>({}); const setValue = <K extends keyof FormSchema>( name: K, value: FormSchema[K], ) => setData((prev) => ({ ...prev, [name]: value })); const [errors, setErrors] = useState< typeToFlattenedError<FormSchema>["fieldErrors"] >({}); return ( <form autoComplete="on" onSubmit={(event) => { event.preventDefault(); const parsedData = formSchema.safeParse(data); if (parsedData.success) { const { data: formData } = parsedData; alert(JSON.stringify(formData, null, 2)); } else { const formErrors = parsedData.error.formErrors.fieldErrors; setErrors(formErrors); } }} > <HvGrid container maxWidth="md" rowSpacing="xs"> <HvGrid item xs={12} sm={6}> <HvInput required inputProps={{ required: false }} // disable default browser behavior name="name" label="Full Name" status={errors.name ? "invalid" : "valid"} statusMessage={errors.name?.[0] || ""} onChange={(evt, val) => setValue("name", val)} /> </HvGrid> <HvGrid item xs={12} sm={6}> <HvInput required inputProps={{ required: false }} // disable default browser behavior type="email" name="email" label="Email" status={errors.email ? "invalid" : "valid"} statusMessage={errors.email?.[0] || ""} onChange={(evt, val) => setValue("email", val)} /> </HvGrid> <HvGrid item xs={12} sm={6}> <HvInput name="address" label="Address" status={errors.address ? "invalid" : "valid"} statusMessage={errors.address?.[0] || ""} onChange={(evt, val) => setValue("address", val)} /> </HvGrid> <HvGrid item xs={12} sm={6}> <HvInput name="country" label="Country" inputProps={{ autoComplete: "off" }} status={errors.country ? "invalid" : "valid"} statusMessage={errors.country?.[0] || ""} onChange={(evt, val) => setValue("country", val as Country)} endAdornment={<Map />} /> </HvGrid> <HvGrid item xs={12} sm={6}> <HvInput required inputProps={{ required: false }} // disable default browser behavior type="password" name="password" label="Password" status={errors.password ? "invalid" : "valid"} statusMessage={errors.password?.[0] || ""} onChange={(evt, val) => setValue("password", val)} /> </HvGrid> <HvGrid item xs={12} sm={6}> <HvInput required inputProps={{ required: false }} // disable default browser behavior type="password" name="repeatPassword" label="Repeat password" status={errors.repeatPassword ? "invalid" : "valid"} statusMessage={errors.repeatPassword?.[0] || ""} onChange={(evt, val) => setValue("repeatPassword", val)} /> </HvGrid> <HvGrid item xs={12} sm={6}> <HvDatePicker name="bday" label="Birthday" placeholder="Select date" onChange={(val) => setValue("bday", val)} /> </HvGrid> <HvGrid item xs={12} sm={6}> <HvTimePicker name="startTime" label="Start time" onChange={(val) => setValue("startTime", val)} /> </HvGrid> <HvGrid item xs={12}> <HvTextArea required inputProps={{ required: false }} // disable default browser behavior name="description" description="Write a short description" label="Description" rows={3} minCharQuantity={16} maxCharQuantity={128} status={errors.description ? "invalid" : "valid"} statusMessage={errors.description?.[0] || ""} onChange={(evt, val) => setValue("description", val)} /> </HvGrid> <HvGrid item xs={12}> <HvRadioGroup required name="sex" label="Sex" orientation="horizontal" status={errors.sex ? "invalid" : "valid"} statusMessage={errors.sex?.[0] || ""} onChange={(evt, val) => setValue("sex", val)} > <HvRadio value="male" label="Male" /> <HvRadio value="female" label="Female" /> <HvRadio value="other" label="Other" /> </HvRadioGroup> </HvGrid> <HvGrid item xs={12}> <HvCheckBox defaultChecked name="subscribe" label="Subscribe to newsletter" value="yes" onChange={(evt, val) => setValue("subscribe", val)} /> </HvGrid> <HvGrid item xs={12}> <HvButton type="submit">Submit</HvButton> </HvGrid> </HvGrid> </form> ); };
Form libraries
Working with forms in React can be challenging—handling validation, verbosity, and state management often adds complexity.
Libraries like React Hook Form and Formik help simplify form handling. Both address similar problems, and the UI Kit does not enforce the use of either.
Our Form Components are compatible with these libraries. However, we recommend using each library’s imperative set value functions when handling the onChange
events of our components. While the APIs are not fully aligned, this approach provides the most consistent and predictable experience.
React Hook Form
import { Controller, ControllerProps, FieldError, useController, useForm, } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { HvButton, HvCheckBox, HvDatePicker, HvFormElementProps, HvGrid, HvInput, HvInputProps, HvRadio, HvRadioGroup, HvTextArea, HvTimePicker, } from "@hitachivantara/uikit-react-core"; import { Map } from "@hitachivantara/uikit-react-icons"; const passwordSchema = z .string() .min(6, "Password is too short") .max(32, "Password is too long") .refine((pwd) => /[!@#$&*]/.test(pwd), "Must include special characters."); const formSchema = z .object({ name: z .string({ required_error: "Name is required" }) .min(3, "Name is too short") .refine((n) => !/\d/.test(n), "Name must not contain numbers"), email: z .string({ required_error: "Email is required" }) .email("Invalid email") .refine( (email) => email.endsWith("@hitachivantara.com"), "Email must be from @hitachivantara.com", ), address: z.string().optional(), country: z .enum(["Portugal", "Spain", "France", "Germany", "United States"], { errorMap: (issue) => ({ message: issue.code === "invalid_enum_value" ? `Invalid country. Must be: ${issue.options}` : "Invalid country.", }), }) .optional(), tel: z.string().optional(), password: passwordSchema, repeatPassword: passwordSchema, bday: z .date() .min(new Date("1900-01-01"), "Too old!") .max(new Date("2000-12-31"), "Too young!") .optional(), startTime: z .object({ hours: z.number(), minutes: z.number(), seconds: z.number() }) .optional(), description: z .string() .min(16, "Description is too short") .max(128, "Description is too long"), sex: z.enum(["male", "female", "other"]), subscribe: z.boolean().optional(), }) .refine((data) => data.password === data.repeatPassword, { message: "Passwords do not match", path: ["repeatPassword"], }); type FormSchema = z.infer<typeof formSchema>; const getStatusProps = (error?: FieldError) => { return { status: error ? "invalid" : "valid", statusMessage: error?.message || "", } satisfies HvFormElementProps; }; interface InputControlProps extends Pick<ControllerProps<FormSchema>, "name" | "control">, Omit<HvInputProps, "name">, Record<string, any> { component?: any; } const InputControl = ({ name, control, label, component: Component = HvInput, ...others }: InputControlProps) => { const { field, fieldState } = useController({ name, control }); return ( <Component {...field} value={field.value ?? ""} // ensure controlled behavior label={label || name} {...getStatusProps(fieldState.error)} {...others} /> ); }; export default () => { const { handleSubmit, control } = useForm<FormSchema>({ mode: "onBlur", reValidateMode: "onBlur", resolver: zodResolver(formSchema), defaultValues: { sex: "male", subscribe: true, startTime: { hours: 9, minutes: 0, seconds: 0 }, }, }); return ( <form autoComplete="on" onSubmit={handleSubmit( (data) => alert(JSON.stringify(data, null, 2)), (errors) => console.error("Form errors", errors), )} > <HvGrid container maxWidth="md" rowSpacing="xs"> <HvGrid item xs={12} sm={6}> <InputControl control={control} name="name" label="Full Name" /> </HvGrid> <HvGrid item xs={12} sm={6}> <InputControl control={control} name="email" label="Email" /> </HvGrid> <HvGrid item xs={12} sm={6}> <InputControl control={control} name="address" label="Address" /> </HvGrid> <HvGrid item xs={12} sm={6}> <InputControl control={control} name="country" label="Country" inputProps={{ autoComplete: "off" }} endAdornment={<Map />} /> </HvGrid> <HvGrid item xs={12} sm={6}> <InputControl control={control} type="password" name="password" label="Password" /> </HvGrid> <HvGrid item xs={12} sm={6}> <InputControl control={control} type="password" name="repeatPassword" label="Repeat password" /> </HvGrid> <HvGrid item xs={12} sm={6}> <Controller control={control} name="bday" render={({ field, fieldState: { error } }) => ( <HvDatePicker {...field} label="Birthday" placeholder="Select date" {...getStatusProps(error)} /> )} /> </HvGrid> <HvGrid item xs={12} sm={6}> <InputControl control={control} component={HvTimePicker} name="startTime" label="Start time" // TODO: onchange? /> </HvGrid> <HvGrid item xs={12}> <InputControl control={control} component={HvTextArea} name="description" label="Description" description="Write a short description" rows={3} minCharQuantity={16} maxCharQuantity={128} /> </HvGrid> <HvGrid item xs={12}> <Controller control={control} name="sex" render={({ field, fieldState: { error } }) => ( <HvRadioGroup {...field} label="Sex" orientation="horizontal" {...getStatusProps(error)} > <HvRadio value="male" label="Male" /> <HvRadio value="female" label="Female" /> <HvRadio value="other" label="Other" /> </HvRadioGroup> )} /> </HvGrid> <HvGrid item xs={12}> <Controller control={control} name="subscribe" render={({ field, fieldState: { error } }) => ( <HvCheckBox {...field} value="yes" checked={field.value} label="Subscribe to newsletter" {...getStatusProps(error)} /> )} /> </HvGrid> <HvGrid item xs={12}> <HvButton type="submit">Submit</HvButton> </HvGrid> </HvGrid> </form> ); };