Form
The Form component is a structured collection of input fields, controls, and actions that allow users to submit and manipulate data. It ensures a consistent and accessible way to gather user input, validate information, and guide users through workflows.
Installation
Note: Currently, the Form component does not support direct composition with Radix’s form primitives (such as Checkbox, Select, etc.). The Radix team is working on an official solution for this. As an alternative, you can use the useDynamicForm aura's custom hook to manage form state while keeping styling and behavior aligned with the design system.
useFormDynamic Hook
The useFormDynamic hook is a flexible form state management solution designed to handle dynamic forms with various field types (text, textarea, select, checkbox, etc.). It provides built-in state tracking for values, validation errors, and touch states, along with utilities to reset fields, batch-update values, and synchronize with DOM elements. Unlike Radix’s form primitives, this standalone hook offers a lightweight alternative while maintaining compatibility with your design system components. Use it to simplify complex form logic, manage dynamic fields, or bridge gaps until Radix’s official form composition API is available.
API reference for the useFormDynamic
FieldProps (returned by field() and getFields())
import { useState, useRef } from "react";
type FieldType = "text" | "textarea" | "select" | "checkbox";
interface useFormDynamicProps {
[key: string]: FieldType;
}
const initialValueResolver = {
text: "",
textarea: "",
select: "",
checkbox: false,
};
const useInputValueFields = (initialValues: useFormDynamicProps = {}) => {
const resolvedInitialValues = Object.entries(initialValues).reduce(
(acc, [key, type]) => {
acc[key] = initialValueResolver[type];
return acc;
},
{} as Record<string, any>
);
const [value, setValue] = useState(resolvedInitialValues);
const [error, setError] = useState<string>(null);
const [touch, setTouch] = useState<Record<string, boolean>>({});
return {
types: initialValues,
value,
error,
touch,
setTouch,
setValue,
setError,
};
};
export type FieldProps = {
name: string;
type: FieldType;
value: string | boolean;
setValue: (value: string | boolean) => void;
setFormFieldValue: (
formRef: React.RefObject<HTMLFormElement>,
value: string | boolean
) => void;
onChange: (
event: React.ChangeEvent<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
>
) => void;
onCheckedChange: React.ChangeEventHandler<HTMLInputElement> &
((checked: boolean) => void);
touch: boolean;
setTouch: (value: boolean) => void;
reset: () => void;
};
export const useFormDynamic = (initialValues: useFormDynamicProps) => {
const [fetchStatus, setFetchStatus] = useState<
"idle" | "loading" | "success" | "error"
>("idle");
const fields = useInputValueFields(initialValues);
const updateField = (
name: string,
updates: Partial<Record<keyof typeof fields, any>>
) => {
fields.setValue((prev) => {
const newValues = { ...prev, [name]: updates.value };
return newValues;
});
fields.setTouch((prev) => ({ ...prev, [name]: updates.touch }));
};
const field = (name: string): FieldProps => {
const fieldType = fields.value[name];
const defaultValue = initialValueResolver[fieldType];
const handleChange = (
event: React.ChangeEvent<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
>
) => {
const value = event.target.value;
updateField(name, {
value,
touch: true,
});
};
const handleOnCheckedChange = (event: boolean): any => {
updateField(name, {
value: event,
touch: true,
});
};
const setFormFieldValue = (formRef, value) => {
const input = formRef?.current?.querySelector(`[name="${name}"]`);
if (input) {
input.value = value;
}
updateField(name, {
value: value,
});
};
return {
name,
type: fields.types[name],
value: fields.value[name] ?? defaultValue,
setValue: (value: string | boolean) =>
updateField(name, { value, touch: true }),
setFormFieldValue,
onChange: handleChange,
onCheckedChange:
handleOnCheckedChange as React.ChangeEventHandler<HTMLInputElement> &
((checked: boolean) => void),
touch: fields.touch[name],
setTouch: (value: boolean) => updateField(name, { touch: value }),
reset: () =>
updateField(name, {
value: defaultValue,
touch: false,
}),
};
};
const getFields = () => {
return Object.keys(fields.value).reduce(
(acc, key) => {
acc[key] = field(key);
return acc;
},
{} as Record<string, ReturnType<typeof field>>
);
};
const resetForm = (formRef, initialValues) => {
const fields = getFields();
for (const field in fields) {
fields[field].reset();
fields[field].setFormFieldValue(
formRef,
initialValues?.[field] ?? initialValueResolver[fields[field].type]
);
}
};
const touchForm = () => {
const fields = getFields();
for (const field in fields) {
updateField(field, { value: fields[field].value, touch: true });
}
};
const getValues = () => {
return Object.keys(fields.value).reduce(
(acc, key) => {
acc[key] = fields.value[key];
return acc;
},
{} as Record<string, string | boolean>
);
};
return {
...fields,
resetForm,
touchForm,
field,
getFields,
getValues,
fetchStatus,
setFetchStatus,
};
};
Form Components with Radix Primitives
This UI layer bridges Radix's unstyled form primitives (Form, Field, Switch, etc.) with aura design system's styled components and the useFormDynamic hook. It provides:
Seamless Integration
- Wraps Radix primitives (FormRadix.Root, FormRadix.Field) with your design system's styles and behaviors.
- Automatically connects to useFormDynamic via the field prop (e.g., field.onChange, field.value).
Enhanced Features
- Error Handling: Displays validation errors (AJV-compatible) when fields are touched (field.touch).
- Loading States: Submit buttons show loading indicators via fetchStatus.
- Accessibility: Radix’s built-in accessibility (e.g., labels, ARIA attributes) is preserved.
Key Components
- <Form>: Root wrapper that processes children to inject errors and field props.
- <FormField>: Handles layout, labels, and error messages for inputs.
- <FormSwitch> / <FormCheckbox>: Custom-styled controls linked to useFormDynamic’s boolean state.
- <FormSubmit>: Submit button with loading states.
Workflow
- Define fields in useFormDynamic({ name: "text", active: "checkbox" }).
- Pass the field prop to components (e.g., <FormField field={hooks.field("name")}>).
- Radix manages form submission/validation, while useFormDynamic manages state.
Example Usage
validateFormData
The validateFormData function provides seamless AJV validation for your Form components. This utility handles schema validation and formats errors in a way that's automatically compatible with your Form system.
Note: Error Handling with AJV
This form system uses AJV (Another JSON Schema Validator) to validate and format error messages. To enable this feature:
Integration AJV Schema errors with Form Components
// @/schemas/ticketSchema
export const ticketSchema = {
type: "object",
properties: {
firstName: {
type: "string",
minLength: 1,
errorMessage: {
minLength: "First name is required",
},
},
lastName: { type: "string", minLength: 1 },
email: { type: "string", format: "email" },
department: {
type: "string",
enum: ["sales", "marketing", "engineering", "support"],
},
accept: { type: "boolean" },
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
description: { type: "string", minLength: 1 },
verified: { type: "boolean" },
updates: { type: "boolean" },
notifications: { type: "boolean" },
autoReply: { type: "boolean" },
tracking: { type: "boolean" },
statusUpdates: { type: "boolean" },
},
};
export const createTicketSchema = {
type: "object",
properties: {
...ticketSchema.properties,
notifications: {
type: "boolean",
const: true,
errorMessage: {
const: "Notifications are required.",
},
},
accept: {
type: "boolean",
const: true,
errorMessage: {
const: "Accept terms and conditions is required.",
},
},
verified: { type: "boolean", const: true },
updates: { type: "boolean", const: true },
},
required: [
"firstName",
"lastName",
"email",
"department",
"accept",
"priority",
"description",
"verified",
"updates",
"notifications",
"autoReply",
"tracking",
"statusUpdates",
],
additionalProperties: false,
};
Complete Form Implementation
import React, { useEffect, useRef } from "react";
import { createTicketSchema } from "@/schemas/ticketSchema";
import { validateFormData } from "@/utils/web-validation";
import { useFormDynamic } from "@/utils/forms";
import {
Form,
FormField,
FormCheckbox,
FormSwitch,
FormSubmit,
FormAlert,
} from "@/components/ui/Form";
import Button from "@/components/ui/Button";
//import { toast } from "sonner";
export const FormDemo = () => {
const formRef = useRef(null);
const formData = useFormDynamic({
firstName: "text",
lastName: "text",
email: "text",
department: "select",
accept: "checkbox",
priority: "select",
description: "textarea",
verified: "checkbox",
updates: "checkbox",
notifications: "checkbox",
autoReply: "checkbox",
tracking: "checkbox",
statusUpdates: "checkbox",
});
const {
firstName,
lastName,
email,
department,
accept,
priority,
description,
verified,
updates,
notifications,
autoReply,
tracking,
statusUpdates,
} = formData.getFields();
const { isValid, errors } = validateFormData(
createTicketSchema,
formData.getValues()
);
const handleOnSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
formData.setFetchStatus("loading");
if (!isValid) {
formData.setFetchStatus("error");
formData.touchForm();
formData.setError("Please fill in all fields");
return;
}
try {
await new Promise((resolve) => setTimeout(resolve, 1000));
const res = await fetch("http://localhost:61001/api/tickets", {
method: "POST",
body: JSON.stringify(formData.getValues()),
});
if (res.ok) {
formData.setFetchStatus("success");
//toast.success("Ticket created successfully");
} else {
formData.setFetchStatus("error");
formData.setError("Something went wrong");
}
} catch (error) {
console.error(error);
formData.setFetchStatus("error");
formData.setError("Something went wrong");
}
};
const handleOnReset = () => {
if (formData.fetchStatus === "loading") return;
formData.resetForm(formRef, {
verified: true,
notifications: true,
autoReply: true,
priority: "low",
});
};
const handleOnInit = () => {
if (formData.fetchStatus === "loading") return;
formData.resetForm(formRef, {
verified: true,
notifications: true,
autoReply: true,
priority: "low",
});
};
useEffect(() => {
if (formRef?.current) {
handleOnInit();
}
}, []);
return (
<Form
className="grid gap-2 p-4 max-w-2xl mx-auto"
onSubmit={handleOnSubmit}
ref={formRef}
errors={errors}
>
<div className="grid grid-cols-2 gap-2">
<FormField label="First Name" field={firstName} />
<FormField label="Last Name" field={lastName} />
</div>
<FormField label="Email Address" field={email} />
<FormField label="Department" field={department}>
<select>
<option value="">Select a department</option>
<option value="sales">Sales</option>
<option value="marketing">Marketing</option>
<option value="engineering">Engineering</option>
<option value="support">Customer Support</option>
</select>
</FormField>
<FormField label="Priority Level" field={priority}>
<select>
<option value="">Select priority</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</FormField>
<FormField label="Issue Description" field={description}>
<textarea
placeholder="Please describe your issue in detail..."
className="min-h-[120px]"
/>
</FormField>
<div className="border border-black-4 rounded-1 p-2 space-y-2 bg-black-1">
<FormSwitch label="Enable notifications" field={notifications} />
<FormSwitch label="Enable auto-replies" field={autoReply} />
<FormSwitch
label="Enable ticket tracking"
name="tracking"
field={tracking}
/>
<FormSwitch label="Enable status updates" field={statusUpdates} />
</div>
<div className="space-y-2">
<FormCheckbox
label="I have verified this information is correct"
field={verified}
/>
<FormCheckbox
label="Send me email updates about this ticket"
field={updates}
/>
<FormCheckbox
label="I agree to the terms and conditions"
field={accept}
/>
</div>
<div className="flex gap-2 justify-end">
<Button
mode="pill"
label="Reset Form"
type="button"
onClick={handleOnReset}
/>
<FormSubmit
asChild
fetchStatus={formData.fetchStatus}
buttonProps={{ label: "Submit Ticket" }}
/>
</div>
<FormAlert formData={formData} />
</Form>
);
};