AniUI

Form

Form context with validation, error messages, and compound components.

Installation#

npx @aniui/cli add form
Web preview — components render natively on iOS & Android
import { Form, FormField, FormItem, FormMessage, useFormField } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";

function ValidatedInput({ rules }: { rules?: FieldRules }) {
  const { name, error, validateField } = useFormField();
  return (
    <Input
      variant={error ? "destructive" : "default"}
      onBlur={(e) => validateField(name, e.nativeEvent.text, rules)}
    />
  );
}

export function MyScreen() {
  return (
    <Form>
      <FormField name="email">
        <FormItem>
          <Label>Email</Label>
          <ValidatedInput rules={{ required: "Email is required", pattern: { value: /@/, message: "Invalid email" } }} />
          <FormMessage />
        </FormItem>
      </FormField>
      <Button onPress={() => console.log("submit")}>Submit</Button>
    </Form>
  );
}

Usage#

app/index.tsx
import { Form, FormField, FormItem, FormMessage, useFormField } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";

function ValidatedInput({ rules }: { rules?: FieldRules }) {
  const { name, error, validateField } = useFormField();
  return (
    <Input
      variant={error ? "destructive" : "default"}
      onBlur={(e) => validateField(name, e.nativeEvent.text, rules)}
    />
  );
}

export function MyScreen() {
  return (
    <Form>
      <FormField name="email">
        <FormItem>
          <Label>Email</Label>
          <ValidatedInput rules={{ required: "Email is required", pattern: { value: /@/, message: "Invalid email" } }} />
          <FormMessage />
        </FormItem>
      </FormField>
      <Button onPress={() => console.log("submit")}>Submit</Button>
    </Form>
  );
}

useFormField Hook#

Access form context and field state from within a FormField. Returns the field name, error, all errors, and validation helpers.

Using useFormField
import { useFormField } from "@/components/ui/form";

// Inside a FormField context:
const { name, error, errors, setFieldError, validateField } = useFormField();

// Validate a field with rules
validateField("email", value, {
  required: "Email is required",
  pattern: { value: /^[^@]+@[^@]+$/, message: "Invalid email" },
  validate: (val) => val.includes("test") ? "No test emails" : undefined,
});

FieldRules Type#

PropTypeDefault
required
string | boolean
-
pattern
{ value: RegExp; message: string }
-
validate
(value: string) => string | undefined
-

Props#

Form#

PropTypeDefault
className
string
-
children
React.ReactNode
-

FormField#

PropTypeDefault
name
string
-
children
React.ReactNode
-

FormItem#

PropTypeDefault
className
string
-
children
React.ReactNode
-

FormMessage#

PropTypeDefault
className
string
-

Form and FormItem also accept all View props.

Accessibility#

  • Form validation with FormField, FormItem, and FormMessage for error announcements.
  • Error messages are associated with their fields for screen readers.

Source#

components/ui/form.tsx
import React, { createContext, useContext, useState, useCallback } from "react";
import { View, Text } from "react-native";
import { cn } from "@/lib/utils";

type FieldError = string | undefined;
type Validator = (value: string) => FieldError;
type FormErrors = Record<string, FieldError>;

const FormContext = createContext<{
  errors: FormErrors;
  setFieldError: (field: string, error: FieldError) => void;
  validateField: (field: string, value: string, rules?: FieldRules) => boolean;
}>({ errors: {}, setFieldError: () => {}, validateField: () => true });

const FormFieldContext = createContext<{ name: string; error: FieldError }>({
  name: "",
  error: undefined,
});

export type FieldRules = {
  required?: string | boolean;
  pattern?: { value: RegExp; message: string };
  validate?: Validator;
};

export interface FormProps extends React.ComponentPropsWithoutRef<typeof View> {
  className?: string;
  children?: React.ReactNode;
}

export function Form({ className, children, ...props }: FormProps) {
  const [errors, setErrors] = useState<FormErrors>({});

  const setFieldError = useCallback((field: string, error: FieldError) => {
    setErrors((prev) => ({ ...prev, [field]: error }));
  }, []);

  const validateField = useCallback(
    (field: string, value: string, rules?: FieldRules): boolean => {
      if (!rules) return true;
      if (rules.required && !value.trim()) {
        const msg = typeof rules.required === "string" ? rules.required : "This field is required";
        setErrors((prev) => ({ ...prev, [field]: msg }));
        return false;
      }
      if (rules.pattern && !rules.pattern.value.test(value)) {
        setErrors((prev) => ({ ...prev, [field]: rules.pattern!.message }));
        return false;
      }
      if (rules.validate) {
        const error = rules.validate(value);
        setErrors((prev) => ({ ...prev, [field]: error }));
        return !error;
      }
      setErrors((prev) => ({ ...prev, [field]: undefined }));
      return true;
    },
    []
  );

  return (
    <FormContext.Provider value={{ errors, setFieldError, validateField }}>
      <View className={cn("gap-4", className)} {...props}>
        {children}
      </View>
    </FormContext.Provider>
  );
}

export function useFormField() {
  const form = useContext(FormContext);
  const field = useContext(FormFieldContext);
  return { ...field, ...form };
}

export interface FormFieldProps {
  name: string;
  children: React.ReactNode;
}

export function FormField({ name, children }: FormFieldProps) {
  const { errors } = useContext(FormContext);
  return (
    <FormFieldContext.Provider value={{ name, error: errors[name] }}>
      {children}
    </FormFieldContext.Provider>
  );
}

export interface FormItemProps extends React.ComponentPropsWithoutRef<typeof View> {
  className?: string;
  children?: React.ReactNode;
}

export function FormItem({ className, children, ...props }: FormItemProps) {
  return <View className={cn("gap-1", className)} {...props}>{children}</View>;
}

export function FormMessage({ className, ...props }: { className?: string }) {
  const { error } = useContext(FormFieldContext);
  if (!error) return null;
  return (
    <Text className={cn("text-sm text-destructive", className)} accessibilityRole="alert" {...props}>
      {error}
    </Text>
  );
}