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

bash
pnpm dlx shadcn@latest add https://auradesignsystem.com/r/form.json

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

Property/Method

Type/Parameters

Description

types

Record<string, FieldType>

The types of the form fields (text, textarea, select, checkbox)

value

Record<string, string | boolean>

Current values of all form fields

error

string | null

Current error message (form-level)

touch

Record<string, boolean>

Touch state for each field (true if field has been interacted with)

setTouch

(touchState: Record<string, boolean>) => void

Function to update touch states for multiple fields

setValue

(values: Record<string, string | boolean>) => void

Function to update values for multiple fields

setError

(error: string | null) => void

Function to set the form-level error message

fetchStatus

"idle" | "loading" | "success" | "error"

Current status of form submission

setFetchStatus

(status: "idle" | "loading" | "success" | "error") => void

Function to update the submission status

field(name)

(name: string) => FieldProps

Returns props for a specific field (see FieldProps table below)

getFields()

() => Record<string, FieldProps>

Returns props for all fields

getValues()

() => Record<string, string | boolean>

Returns current values of all fields

resetForm(formRef, initialValues)

(React.RefObject<HTMLFormElement>, Record<string, string | boolean>) => void

Resets all fields to their initial values

touchForm()

() => void

Marks all fields as touched

FieldProps (returned by field() and getFields())

Property

Type

Description

name

string

Field name

type

"text" | "textarea" | "select" | "checkbox"

Field type

value

string | boolean

Current field value

setValue

(value: string | boolean) => void

Directly set field value

setFormFieldValue

(formRef: React.RefObject<HTMLFormElement>, value: string | boolean) => void

Updates both DOM and state value

onChange

React.ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>

Change handler for text/textarea/select

onCheckedChange

(checked: boolean) => void & React.ChangeEventHandler<HTMLInputElement>

Change handler for checkboxes

touch

boolean

Whether field has been touched

setTouch

(value: boolean) => void

Set touch state for field

reset

() => void

Reset field to initial value and untouched state

ts
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

tsx
const formData = useFormDynamic({ email: "text", acceptTerms: "checkbox" });

const {email, acceptTerms} = formData.getFields();

return (
  <Form onSubmit={...}>
    <FormField label="Email" field={email}>
      <input type="email" />
    </FormField>
    
    <FormCheckbox label="Accept Terms" field={acceptTerms} />
    
    <FormSubmit fetchStatus={formData.fetchStatus}>Submit</FormSubmit>
  </Form>
);

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:

bash
pnpm install ajv ajv-errors ajv-formats
ts
import Ajv from "ajv";
import addFormats from "ajv-formats";
import addErrors from "ajv-errors";

const ajv = new Ajv({ allErrors: true, $data: true });
addFormats(ajv);
addErrors(ajv);

export function validateFormData(schema: object, data: unknown) {
  const validate = ajv.compile(schema);
  const isValid = validate(data);

  if (!isValid) {
    return {
      isValid: false,
      errors: validate.errors
    };
  }

  return {
    isValid: true,
    errors: null
  };
}

Integration AJV Schema errors with Form Components

ts
// @/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,
};
tsx
import { validateFormData } from "@/utils/validation";
import { createTicketSchema } from "@/schemas/ticketSchema";

const { isValid, errors } = validateFormData(
  createTicketSchema,
  formData.getValues()
);

// Pass errors to Form component
<Form errors={errors}>
  {/* form fields */}
</Form>

Complete Form Implementation

tsx
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>
  );
};
Form | Aura Design System