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 rulesINVALID- Field fails one or more validation rulesPENDING- 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 validinvalid- At least one field is invalidundetermined- 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>
);
}