A lightweight, flexible, and type-safe React form validation hook with zero dependencies (except React).
In the landscape of React form validation libraries, most solutions force you into one of two extremes:
- Heavy frameworks (react-hook-form, Formik) - Full form management with opinionated state control
- Schema-only validators (Yup, Zod standalone) - No React integration, manual glue code required
react-validate-hook
fills the gap between these extremes with a validation-first approach:
┌─────────────────────────────────────────────────────┐
│ │
│ Heavy Form Frameworks │
│ ├─ Form state management (controlled/uncontrolled)│
│ ├─ Field registration │
│ ├─ Validation engine ◄─── You want this │
│ ├─ Submit handling │
│ └─ Reset/dirty tracking │
│ │
└─────────────────────────────────────────────────────┘
vs.
┌─────────────────────────────────────────────────────┐
│ │
│ react-validate-hook │
│ └─ Validation engine only │
│ ├─ Render prop pattern │
│ ├─ Type-safe generics │
│ └─ Schema adapter pattern │
│ │
└─────────────────────────────────────────────────────┘
Perfect for:
- Custom form architectures - You control state, this handles validation
- Incrementally validating existing forms - Drop in
ValidateWrapper
without refactoring - Multi-step wizards - Validate individual steps independently
- Non-form validation - Validate any user input (search, filters, configuration)
- Design system builders - Provide validation as a primitive, not a framework
Consider alternatives if you need:
- Full form state management → Use
react-hook-form
orFormik
- Complex field dependencies → Use
Final Form
with field-level subscriptions - Server-side validation only → Use
Remix
form actions orNext.js
server actions - No validation at all → Use controlled components with
useState
npm install react-validate-hook
# or
yarn add react-validate-hook
# or
pnpm add react-validate-hook
Peer Dependencies:
react
: ^18.0.0
For straightforward validation logic without schemas:
import { useValidator } from 'react-validate-hook';
function LoginForm() {
const { ValidateWrapper, validate, errors, reset } = useValidator();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
validate();
if (errors.length === 0) {
// Submit form
}
};
return (
<form onSubmit={handleSubmit}>
<ValidateWrapper
setValue={setEmail}
fn={(value) => {
if (!value) return "Email is required";
if (!/\S+@\S+\.\S+/.test(value)) return "Invalid email format";
return true;
}}
>
{({ error, setValue }) => (
<div>
<input
type="email"
value={email}
onChange={(e) => setValue(e.target.value)}
/>
{error && <span className="error">{error}</span>}
</div>
)}
</ValidateWrapper>
<ValidateWrapper
setValue={setPassword}
fn={(value) => value && value.length >= 8 ? true : "Min 8 characters"}
>
{({ error, setValue }) => (
<div>
<input
type="password"
value={password}
onChange={(e) => setValue(e.target.value)}
/>
{error && <span className="error">{error}</span>}
</div>
)}
</ValidateWrapper>
<button type="submit">Login</button>
{errors.length > 0 && (
<div className="summary-errors">
{errors.map((err, i) => <div key={i}>{err}</div>)}
</div>
)}
</form>
);
}
For complex validation rules using Zod, Yup, or custom schemas:
import { useValidator } from 'react-validate-hook';
import { z } from 'zod';
const emailSchema = z.string().email("Invalid email");
const passwordSchema = z.string().min(8, "Min 8 characters");
function SignupForm() {
const { ValidateWrapper, validate, errors } = useValidator(
(data, schema) => {
const result = schema.safeParse(data);
return result.success ? true : result.error.errors[0].message;
}
);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
return (
<form onSubmit={(e) => { e.preventDefault(); validate(); }}>
<ValidateWrapper setValue={setEmail} fn={emailSchema}>
{({ error, setValue }) => (
<input
type="email"
onChange={(e) => setValue(e.target.value)}
aria-invalid={!!error}
/>
)}
</ValidateWrapper>
<ValidateWrapper setValue={setPassword} fn={passwordSchema}>
{({ error, setValue }) => (
<input
type="password"
onChange={(e) => setValue(e.target.value)}
aria-invalid={!!error}
/>
)}
</ValidateWrapper>
<button type="submit" disabled={errors.length > 0}>
Sign Up
</button>
</form>
);
}
Support any validation library by writing a thin adapter:
// Yup Adapter
import * as Yup from 'yup';
const yupAdapter = (data: any, schema: Yup.AnySchema) => {
try {
schema.validateSync(data);
return true;
} catch (error) {
return error.message;
}
};
const { ValidateWrapper } = useValidator(yupAdapter);
// Joi Adapter
import Joi from 'joi';
const joiAdapter = (data: any, schema: Joi.Schema) => {
const result = schema.validate(data);
return result.error ? result.error.message : true;
};
// Custom Validator
const customAdapter = (data: any, rules: ValidationRules) => {
// Your custom validation logic
return rules.validate(data) ? true : rules.getError();
};
Create a validator with inline validation functions.
Returns: SimpleValidatorReturn
ValidateWrapper
- Component wrapper for validated fieldsvalidate()
- Trigger validation for all wrapped fieldsreset()
- Clear validation state and errorserrors
- Array of current error messages
Create a validator with schema-based validation.
Parameters:
validationFactory: (value, schema) => ValidationResult
- Factory function to validate values against schemas
Returns: FactoryValidatorReturn<TSchema>
ValidateWrapper
- Component wrapper accepting schema infn
propvalidate()
- Trigger validationreset()
- Clear stateerrors
- Current errors
Prop | Type | Description |
---|---|---|
setValue |
(value: T) => void |
Callback to update parent state |
children |
Render function | Receives { error, setValue } |
Prop | Type | Description |
---|---|---|
fn |
(value) => ValidationResult |
Validation function |
Prop | Type | Description |
---|---|---|
fn |
TSchema |
Schema object (e.g., Zod schema) |
Validate each step independently:
function Wizard() {
const step1Validator = useValidator();
const step2Validator = useValidator();
const [step, setStep] = useState(1);
const nextStep = () => {
if (step === 1) {
step1Validator.validate();
if (step1Validator.errors.length === 0) setStep(2);
}
};
return (
<>
{step === 1 && (
<Step1Form validator={step1Validator} onNext={nextStep} />
)}
{step === 2 && (
<Step2Form validator={step2Validator} onSubmit={handleSubmit} />
)}
</>
);
}
Enable/disable validation based on conditions:
function ConditionalForm() {
const { ValidateWrapper, validate } = useValidator();
const [isRequired, setIsRequired] = useState(false);
return (
<>
<label>
<input
type="checkbox"
checked={isRequired}
onChange={(e) => setIsRequired(e.target.checked)}
/>
Make field required
</label>
<ValidateWrapper
setValue={setFieldValue}
fn={(value) => {
if (!isRequired) return true;
return value ? true : "This field is required";
}}
>
{({ error, setValue }) => (
<input onChange={(e) => setValue(e.target.value)} />
)}
</ValidateWrapper>
</>
);
}
Wrap async logic in your validation function:
const { ValidateWrapper } = useValidator();
const [isChecking, setIsChecking] = useState(false);
<ValidateWrapper
setValue={setUsername}
fn={async (value) => {
if (!value) return "Username required";
setIsChecking(true);
const exists = await checkUsernameExists(value);
setIsChecking(false);
return exists ? "Username already taken" : true;
}}
>
{({ error, setValue }) => (
<>
<input onChange={(e) => setValue(e.target.value)} />
{isChecking && <span>Checking...</span>}
{error && <span>{error}</span>}
</>
)}
</ValidateWrapper>
Feature | react-validate-hook | react-hook-form | Formik | Final Form |
---|---|---|---|---|
Bundle Size | ~2KB | ~9KB | ~13KB | ~5KB |
Form State | ❌ You control | ✅ Built-in | ✅ Built-in | ✅ Built-in |
Validation Only | ✅ Core focus | ❌ Coupled | ❌ Coupled | ❌ Coupled |
Schema Support | ✅ Any via adapter | ✅ Zod/Yup | ✅ Yup | |
Type Safety | ✅ Full generics | ✅ Good | ||
Learning Curve | Low | Moderate | Moderate | High |
Render Props | ✅ Yes | ❌ Ref-based | ✅ Yes |
Use react-validate-hook when:
- Building a custom form library or design system
- Need validation in non-form contexts (filters, search, config)
- Want minimal bundle impact with maximum flexibility
- Already have state management (Redux, Zustand, Context)
Use react-hook-form when:
- Building standard CRUD forms quickly
- Want performant uncontrolled forms
- Need battle-tested DevTools integration
Use Formik when:
- Migrating from class components
- Need Formik's ecosystem (plugins, integrations)
- Prefer explicit form-level state
Use Final Form when:
- Need fine-grained field-level subscriptions
- Complex multi-step forms with field dependencies
- Want framework-agnostic core (also works with Vue, Angular)
Fully typed with generics for maximum type safety:
// Type-safe value inference
const { ValidateWrapper } = useValidator();
<ValidateWrapper<number> // Explicitly typed
setValue={setAge}
fn={(value) => {
// `value` is `number | undefined | null`
if (!value) return "Required";
if (value < 18) return "Must be 18+";
return true;
}}
>
{({ error, setValue }) => {
// `setValue` accepts `number`
return <input type="number" onChange={e => setValue(+e.target.value)} />
}}
</ValidateWrapper>
// Type-safe schemas
const { ValidateWrapper } = useValidator((data: User, schema: z.ZodType<User>) => {
return schema.safeParse(data).success ? true : "Invalid user";
});
Contributions welcome!
# Install dependencies
npm install
# Run tests
npm test
# Run tests in watch mode
npm run test:watch
# Build
npm run build
MIT © [Kabui Charles]