Graneet Form LogoGraneet form

Form Validation

Complete guide to implementing robust form validation with graneet-form's rule-based validation system

Form Validation

Complete guide to implementing robust form validation with graneet-form's rule-based validation system.

Overview

Graneet-form uses a component-based validation approach where Rule components define validation logic for fields. This system provides:

  • Declarative validation - Rules are defined as JSX components
  • Real-time feedback - Validation runs as users interact with fields
  • Async validation - Support for server-side validation
  • Debounced validation - Optimize expensive validation operations
  • Type safety - Full TypeScript support for validation functions

Basic Validation with Rules

The Rule Component

The Rule component is the foundation of validation in graneet-form:

<Rule
  validationFn={(value) => /* validation logic */}
  message="Error message when validation fails"
  isDebounced={false} // Optional: debounce expensive validations
/>

Simple Validation Example

<Field<FormData, 'email'>
  name="email"
  render={(fieldProps, fieldState) => (
    <div>
      <input
        type="email"
        value={fieldProps.value || ''}
        onChange={(e) => fieldProps.onChange(e.target.value)}
        onBlur={fieldProps.onBlur}
        onFocus={fieldProps.onFocus}
      />
      {!fieldState.isPristine && fieldState.validationStatus.status === 'invalid' && (
        <span className="error">{fieldState.validationStatus.message}</span>
      )}
    </div>
  )}
>
  <Rule
    validationFn={(value) => !!value && value.includes('@')}
    message="Please enter a valid email address"
  />
  <Rule
    validationFn={(value) => !value || value.length <= 100}
    message="Email must be less than 100 characters"
  />
</Field>

Common Validation Patterns

Required Fields

const isRequired = (value: unknown) => {
  if (value === null || value === undefined) return false;
  if (typeof value === 'string') return value.trim().length > 0;
  if (typeof value === 'number') return !isNaN(value);
  if (Array.isArray(value)) return value.length > 0;
  return true;
};

<Rule
  validationFn={isRequired}
  message="This field is required"
/>

Email Validation

const isValidEmail = (email: string) => {
  if (!email) return true; // Allow empty for optional fields
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailRegex.test(email);
};

<Rule
  validationFn={isValidEmail}
  message="Please enter a valid email address"
/>

Password Strength

const isStrongPassword = (password: string) => {
  if (!password) return false;
  return password.length >= 8 && 
         /[A-Z]/.test(password) && 
         /[a-z]/.test(password) && 
         /\d/.test(password);
};

<Rule
  validationFn={isStrongPassword}
  message="Password must be at least 8 characters with uppercase, lowercase, and number"
/>

Number Range

<Rule
  validationFn={(age: number) => age >= 18 && age <= 120}
  message="Age must be between 18 and 120"
/>

String Length

<Rule
  validationFn={(value: string) => !value || (value.length >= 3 && value.length <= 50)}
  message="Name must be between 3 and 50 characters"
/>

Advanced Validation

Multiple Rules per Field

<Field<FormData, 'username'>
  name="username"
  render={/* render function */}
>
  <Rule
    validationFn={(value) => !!value && value.length > 0}
    message="Username is required"
  />
  <Rule
    validationFn={(value) => !value || value.length >= 3}
    message="Username must be at least 3 characters"
  />
  <Rule
    validationFn={(value) => !value || value.length <= 20}
    message="Username must be less than 20 characters"
  />
  <Rule
    validationFn={(value) => !value || /^[a-zA-Z0-9_]+$/.test(value)}
    message="Username can only contain letters, numbers, and underscores"
  />
</Field>

Async Validation with Debouncing

For expensive operations like API calls:

const checkUsernameAvailability = async (username: string): Promise<boolean> => {
  if (!username || username.length < 3) return true;
  
  try {
    const response = await fetch(`/api/check-username/${username}`);
    const { available } = await response.json();
    return available;
  } catch {
    return false; // Assume unavailable on error
  }
};

<Rule
  validationFn={checkUsernameAvailability}
  message="Username is already taken"
  isDebounced={true} // Debounces the API call
/>

Cross-field Validation

Use form context to validate against other fields:

function PasswordConfirmField() {
  const form = useFormContext<{ password: string; confirmPassword: string }>();
  const { password } = useFieldsWatch(form, ['password']);

  return (
    <Field<FormData, 'confirmPassword'>
      name="confirmPassword"
      render={/* render function */}
    >
      <Rule
        validationFn={(confirmPassword) => {
          if (!confirmPassword) return false;
          return confirmPassword === password;
        }}
        message="Passwords do not match"
      />
    </Field>
  );
}

Validation Status and Field State

Field Validation States

Each field has a validation status with three possible values:

  • VALID - Field passes all validation rules
  • INVALID - Field fails one or more validation rules
  • PENDING - Validation is in progress (for async rules)

Field State Properties

The fieldState object contains:

interface FieldState {
  isPristine: boolean; // true if field hasn't been focused/modified
  validationStatus: {
    status: 'valid' | 'invalid' | 'pending';
    message?: string; // Error message if status is invalid
  };
}

Using Field State for UI Feedback

<Field
  name="email"
  render={(fieldProps, fieldState) => {
    const { isPristine, validationStatus } = fieldState;
    
    return (
      <div className="field-container">
        <input
          className={`
            input 
            ${!isPristine && validationStatus.status === 'invalid' ? 'error' : ''}
            ${!isPristine && validationStatus.status === 'valid' ? 'success' : ''}
          `}
          {...fieldProps}
        />
        
        {/* Show loading indicator for async validation */}
        {validationStatus.status === 'pending' && (
          <span className="validation-pending">⏳ Checking...</span>
        )}
        
        {/* Show error message */}
        {!isPristine && validationStatus.status === 'invalid' && (
          <span className="validation-error">❌ {validationStatus.message}</span>
        )}
        
        {/* Show success indicator */}
        {!isPristine && validationStatus.status === 'valid' && (
          <span className="validation-success">✅ Valid</span>
        )}
      </div>
    );
  }}
>
  {/* Rules */}
</Field>

Watching Validation Status

useValidations Hook

Watch validation status of specific fields or all fields:

import { useValidations, useFormContext } from 'graneet-form';

function ValidationSummary() {
  const form = useFormContext<FormData>();
  
  // Watch specific fields
  const { email, password } = useValidations(form, ['email', 'password']);
  
  // Watch all fields
  const allValidations = useValidations(form, undefined);
  
  const hasErrors = Object.values(allValidations).some(
    validation => validation?.status === 'invalid'
  );
  
  return (
    <div className="validation-summary">
      <h3>Form Validation Status</h3>
      
      {hasErrors && (
        <div className="error-summary">
          <p>Please fix the following errors:</p>
          <ul>
            {Object.entries(allValidations).map(([field, validation]) => 
              validation?.status === 'invalid' && (
                <li key={field}>{field}: {validation.message}</li>
              )
            )}
          </ul>
        </div>
      )}
      
      <div className="field-status">
        <div className={`status ${email?.status?.toLowerCase()}`}>
          Email: {email?.status || 'UNDETERMINED'}
        </div>
        <div className={`status ${password?.status?.toLowerCase()}`}>
          Password: {password?.status || 'UNDETERMINED'}
        </div>
      </div>
    </div>
  );
}

useFormStatus Hook

Get overall form validation status:

import { useFormStatus, useFormContext } from 'graneet-form';

function SubmitButton() {
  const form = useFormContext<FormData>();
  const { formStatus, isValid } = useFormStatus(form);

  return (
    <div className="submit-section">
      <div className="form-status">
        Status: <span className={formStatus.toLowerCase()}>{formStatus}</span>
      </div>
      
      <button 
        type="submit" 
        disabled={!isValid}
        className={`submit-btn ${isValid ? 'enabled' : 'disabled'}`}
      >
        {formStatus === 'PENDING' ? 'Validating...' : 'Submit Form'}
      </button>
    </div>
  );
}

Form status values:

  • valid - All fields are valid
  • invalid - At least one field is invalid
  • undetermined - Some fields haven't been validated yet

Reusable Validation Rules

Creating Custom Rule Components

// Reusable required field rule
export function RequiredRule({ message = "This field is required" } = {}) {
  return (
    <Rule
      validationFn={(value) => {
        if (value === null || value === undefined) return false;
        if (typeof value === 'string') return value.trim().length > 0;
        if (Array.isArray(value)) return value.length > 0;
        return true;
      }}
      message={message}
    />
  );
}

// Reusable email validation rule
export function EmailRule({ message = "Please enter a valid email" } = {}) {
  return (
    <Rule
      validationFn={(email: string) => {
        if (!email) return true; // Allow empty
        return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
      }}
      message={message}
    />
  );
}

// Usage
<Field name="email" render={/* ... */}>
  <RequiredRule />
  <EmailRule />
</Field>

Complex Validation Rule Factory

interface LengthRuleOptions {
  min?: number;
  max?: number;
  message?: string;
}

export function LengthRule({ min, max, message }: LengthRuleOptions) {
  const validationFn = (value: string) => {
    if (!value) return true; // Allow empty unless required
    
    const length = value.length;
    if (min !== undefined && length < min) return false;
    if (max !== undefined && length > max) return false;
    return true;
  };

  const defaultMessage = (() => {
    if (min && max) return `Must be between ${min} and ${max} characters`;
    if (min) return `Must be at least ${min} characters`;
    if (max) return `Must be less than ${max} characters`;
    return "Invalid length";
  })();

  return (
    <Rule
      validationFn={validationFn}
      message={message || defaultMessage}
    />
  );
}

// Usage
<Field name="username" render={/* ... */}>
  <RequiredRule />
  <LengthRule min={3} max={20} />
</Field>

Best Practices

1. Validation Order

Rules are executed in the order they appear. Place cheaper validations first:

<Field name="email" render={/* ... */}>
  {/* Fast validations first */}
  <Rule validationFn={isRequired} message="Email is required" />
  <Rule validationFn={isValidEmailFormat} message="Invalid email format" />
  
  {/* Expensive async validation last */}
  <Rule 
    validationFn={checkEmailExists}
    message="Email already exists"
    isDebounced={true}
  />
</Field>

2. Error Message Strategy

Keep error messages user-friendly and actionable:

// ❌ Technical/vague messages
<Rule validationFn={validate} message="Invalid input" />
<Rule validationFn={validate} message="Regex failed" />

// ✅ Clear, actionable messages
<Rule validationFn={validate} message="Name must be at least 2 characters" />
<Rule validationFn={validate} message="Please enter a valid phone number (10 digits)" />

3. Performance Optimization

Use debounced validation for expensive operations:

// ❌ API call on every keystroke
<Rule validationFn={expensiveApiValidation} message="..." />

// ✅ Debounced API call
<Rule 
  validationFn={expensiveApiValidation} 
  message="..."
  isDebounced={true}
/>

4. Conditional Validation

function ConditionalValidationField() {
  const form = useFormContext<FormData>();
  const { accountType } = useFieldsWatch(form, ['accountType']);

  return (
    <Field name="taxId" render={/* ... */}>
      {accountType === 'business' && (
        <Rule
          validationFn={(value) => !!value}
          message="Tax ID is required for business accounts"
        />
      )}
    </Field>
  );
}