Installation
pnpm add @hookraft/useformUsage
import { useForm } from "@hookraft/useform"const form = useForm({
fields: {
email: {
value: "",
rules: [
{ required: true, message: "Email is required" },
{ pattern: /^\S+@\S+\.\S+$/, message: "Invalid email address" },
],
},
password: {
value: "",
rules: [
{ required: true, message: "Password is required" },
{ minLength: 8, message: "Must be at least 8 characters" },
],
},
},
onSubmit: async (values) => await loginUser(values),
onSuccess: () => console.log("Logged in!"),
onError: (err) => console.error("Failed:", err),
})The problem it solves
Every form in React makes you write the same boilerplate — field state, error state, loading state, validation logic, submit handling. For every single form.
// Without useForm — 40+ lines before a single input
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [emailError, setEmailError] = useState("")
const [passwordError, setPasswordError] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
const validate = () => { /* ... */ }
const handleSubmit = async (e) => {
e.preventDefault()
if (!validate()) return
setIsSubmitting(true)
try { await login({ email, password }) }
catch (err) { /* ... */ }
finally { setIsSubmitting(false) }
}// With useForm — everything in one place
const form = useForm({
fields: {
email: { value: "", rules: [{ required: true }] },
password: { value: "", rules: [{ required: true }, { minLength: 8 }] },
},
onSubmit: async (values) => await login(values),
})How it works
You define your fields once — with their initial values and validation rules. useForm gives you back everything you need: field state, error messages, submit handling, dirty tracking, and reset. Your component just reads from it and reacts.
Every field in form.fields has its own state:
form.fields.email.value // current value
form.fields.email.error // error message or undefined
form.fields.email.touched // true if the user has interacted with it
form.fields.email.isDirty // true if the value changed from initial
form.fields.email.isValid // true if the field passes all rules
form.fields.email.disabled // true if the field is disabledConnecting a field to an input is always the same pattern:
<input
value={form.fields.email.value as string}
onChange={(e) => form.setValue("email", e.target.value)}
onBlur={() => form.setTouched("email")}
/>
{form.fields.email.error && <p>{form.fields.email.error}</p>}Why
as string? Field values are typed asstring | number | booleanto support all input types. Since HTML inputs always work with strings, the cast tells TypeScript you know it's a string here. If you defined the field withvalue: "", the cast is safe.
Full Examples
Login form
The simplest case — two fields, validate on blur, redirect on success.
import { useForm } from "@hookraft/useform"
function LoginForm() {
const form = useForm({
fields: {
email: {
value: "",
rules: [
{ required: true, message: "Email is required" },
{ pattern: /^\S+@\S+\.\S+$/, message: "Enter a valid email" },
],
},
password: {
value: "",
rules: [
{ required: true, message: "Password is required" },
{ minLength: 8, message: "Must be at least 8 characters" },
],
},
},
validateOn: "blur",
onSubmit: async (values) => {
await fetch("/api/login", {
method: "POST",
body: JSON.stringify(values),
})
},
onSuccess: () => {
window.location.href = "/dashboard"
},
onError: (err) => {
console.error("Login failed:", err)
},
})
return (
<form onSubmit={form.handleSubmit}>
<div>
<input
type="email"
placeholder="Email"
value={form.fields.email.value as string}
onChange={(e) => form.setValue("email", e.target.value)}
onBlur={() => form.setTouched("email")}
/>
{form.fields.email.error && <p>{form.fields.email.error}</p>}
</div>
<div>
<input
type="password"
placeholder="Password"
value={form.fields.password.value as string}
onChange={(e) => form.setValue("password", e.target.value)}
onBlur={() => form.setTouched("password")}
/>
{form.fields.password.error && <p>{form.fields.password.error}</p>}
</div>
<button type="submit" disabled={form.isSubmitting}>
{form.isSubmitting ? "Logging in..." : "Login"}
</button>
{form.isSuccess && <p>Welcome back!</p>}
{form.isError && <p>Login failed. Please try again.</p>}
</form>
)
}Signup form with cross-field validation
The validate function in a rule receives the current field value AND all other field values — use it for things like confirming passwords match.
import { useForm } from "@hookraft/useform"
function SignupForm() {
const form = useForm({
fields: {
username: {
value: "",
rules: [
{ required: true, message: "Username is required" },
{ minLength: 3, message: "Must be at least 3 characters" },
{ maxLength: 20, message: "Must be 20 characters or less" },
{
pattern: /^[a-zA-Z0-9_]+$/,
message: "Only letters, numbers, and underscores",
},
],
},
email: {
value: "",
rules: [
{ required: true, message: "Email is required" },
{ pattern: /^\S+@\S+\.\S+$/, message: "Invalid email" },
],
},
password: {
value: "",
rules: [
{ required: true, message: "Password is required" },
{ minLength: 8, message: "Must be at least 8 characters" },
],
},
confirmPassword: {
value: "",
rules: [
{ required: true, message: "Please confirm your password" },
{
// validate receives (value, allValues) — allValues has every field
validate: (value, allValues) =>
value === allValues.password || "Passwords do not match",
},
],
},
},
validateOn: "blur",
onSubmit: async (values) => {
await fetch("/api/signup", {
method: "POST",
body: JSON.stringify(values),
})
},
onSuccess: () => {
window.location.href = "/welcome"
},
})
return (
<form onSubmit={form.handleSubmit}>
{["username", "email", "password", "confirmPassword"].map((name) => (
<div key={name}>
<input
type={
name.includes("password") || name.includes("Password")
? "password"
: "text"
}
placeholder={name}
value={form.fields[name].value as string}
onChange={(e) => form.setValue(name as any, e.target.value)}
onBlur={() => form.setTouched(name as any)}
/>
{form.fields[name].error && (
<p style={{ color: "red", fontSize: 12 }}>
{form.fields[name].error}
</p>
)}
</div>
))}
<button type="submit" disabled={form.isSubmitting}>
{form.isSubmitting ? "Creating account..." : "Sign Up"}
</button>
</form>
)
}Contact form with reset
Use form.reset() to clear all fields back to their initial values. To call reset inside onSuccess, hold a ref to the form so it's available in the closure.
import { useRef } from "react"
import { useForm } from "@hookraft/useform"
import type { UseFormReturn } from "@hookraft/useform"
function ContactForm() {
// Hold a ref so we can call reset() inside onSuccess
const formRef = useRef<UseFormReturn<any>>(null!)
const form = useForm({
fields: {
name: {
value: "",
rules: [{ required: true, message: "Name is required" }],
},
email: {
value: "",
rules: [
{ required: true, message: "Email is required" },
{ pattern: /^\S+@\S+\.\S+$/, message: "Invalid email" },
],
},
message: {
value: "",
rules: [
{ required: true, message: "Message is required" },
{ minLength: 20, message: "Message must be at least 20 characters" },
{ maxLength: 500, message: "Message must be 500 characters or less" },
],
},
},
onSubmit: async (values) => {
await fetch("/api/contact", {
method: "POST",
body: JSON.stringify(values),
})
},
onSuccess: () => {
// Use the ref to reset — form variable isn't in scope here yet
formRef.current.reset()
},
})
// Keep the ref in sync
formRef.current = form
return (
<form onSubmit={form.handleSubmit}>
<input
placeholder="Your name"
value={form.fields.name.value as string}
onChange={(e) => form.setValue("name", e.target.value)}
onBlur={() => form.setTouched("name")}
/>
{form.fields.name.error && <p>{form.fields.name.error}</p>}
<input
placeholder="Your email"
value={form.fields.email.value as string}
onChange={(e) => form.setValue("email", e.target.value)}
onBlur={() => form.setTouched("email")}
/>
{form.fields.email.error && <p>{form.fields.email.error}</p>}
<textarea
placeholder="Your message (20–500 characters)"
value={form.fields.message.value as string}
onChange={(e) => form.setValue("message", e.target.value)}
onBlur={() => form.setTouched("message")}
/>
{form.fields.message.error && <p>{form.fields.message.error}</p>}
<div>
<button type="submit" disabled={form.isSubmitting}>
{form.isSubmitting ? "Sending..." : "Send Message"}
</button>
{/* reset a single field */}
<button
type="button"
onClick={() => form.resetField("message")}
disabled={!form.fields.message.isDirty}
>
Clear message
</button>
{/* reset the whole form */}
<button
type="button"
onClick={form.reset}
disabled={!form.isDirty}
>
Reset all
</button>
</div>
{form.isSuccess && <p>Message sent successfully!</p>}
</form>
)
}Validate on change
By default validation fires on blur. Set validateOn: "change" to validate on every keystroke — good for live feedback like username availability or password strength.
const form = useForm({
fields: {
username: {
value: "",
rules: [
{ required: true },
{ minLength: 3, message: "Too short" },
{ pattern: /^[a-z0-9]+$/, message: "Lowercase letters and numbers only" },
],
},
},
validateOn: "change",
})
<input
value={form.fields.username.value as string}
onChange={(e) => form.setValue("username", e.target.value)}
/>
{form.fields.username.error && <p>{form.fields.username.error}</p>}Validate on submit only
Use validateOn: "submit" when you don't want to show errors until the user tries to submit. No errors appear while typing or on blur — only after the first submit attempt.
const form = useForm({
fields: {
email: {
value: "",
rules: [
{ required: true, message: "Email is required" },
{ pattern: /^\S+@\S+\.\S+$/, message: "Invalid email" },
],
},
password: {
value: "",
rules: [{ required: true, message: "Password is required" }],
},
},
validateOn: "submit", // errors only appear after submit is clicked
onSubmit: async (values) => await login(values),
})
<form onSubmit={form.handleSubmit}>
<input
value={form.fields.email.value as string}
onChange={(e) => form.setValue("email", e.target.value)}
// no onBlur needed — validation won't run on blur anyway
/>
{form.fields.email.error && <p>{form.fields.email.error}</p>}
<button type="submit">Login</button>
</form>Server-side errors
Use setError to apply errors returned from your API to specific fields after submission.
const form = useForm({
fields: {
email: { value: "", rules: [{ required: true }] },
password: { value: "", rules: [{ required: true }] },
},
onSubmit: async (values) => {
const res = await fetch("/api/login", {
method: "POST",
body: JSON.stringify(values),
})
if (!res.ok) {
const data = await res.json()
// Map API errors back to specific fields
if (data.field === "email") form.setError("email", data.message)
if (data.field === "password") form.setError("password", data.message)
throw new Error(data.message) // triggers onError and sets isError = true
}
},
})Disabled fields
Set disabled: true on a field to mark it as disabled. Read it from form.fields.name.disabled to pass it to your input.
const form = useForm({
fields: {
email: {
value: "user@example.com",
disabled: true, // pre-filled and locked
},
displayName: {
value: "",
rules: [{ required: true, message: "Display name is required" }],
},
},
onSubmit: async (values) => await updateProfile(values),
})
<form onSubmit={form.handleSubmit}>
<input
type="email"
value={form.fields.email.value as string}
disabled={form.fields.email.disabled}
onChange={(e) => form.setValue("email", e.target.value)}
/>
<input
placeholder="Display name"
value={form.fields.displayName.value as string}
disabled={form.fields.displayName.disabled}
onChange={(e) => form.setValue("displayName", e.target.value)}
onBlur={() => form.setTouched("displayName")}
/>
{form.fields.displayName.error && <p>{form.fields.displayName.error}</p>}
<button type="submit">Save</button>
</form>Checking form status with is()
Use form.is() as an alternative to the boolean flags when you want to check status in one place — useful for switch statements or conditional rendering based on multiple states.
const form = useForm({
fields: {
email: { value: "", rules: [{ required: true }] },
},
onSubmit: async (values) => await subscribe(values),
})
// These are equivalent:
form.isSubmitting // boolean flag
form.is("submitting") // same thing via is()
form.isSuccess // boolean flag
form.is("success") // same thing via is()
// Useful for rendering different UI per status:
{form.is("idle") && <p>Fill in your email to subscribe.</p>}
{form.is("submitting") && <p>Subscribing...</p>}
{form.is("success") && <p>You're subscribed!</p>}
{form.is("error") && <p>Something went wrong. Try again.</p>}Validation Rules
Every field can have an array of rules. Rules are checked in order — the first one that fails stops validation and returns its error message.
rules: [
{ required: true, message: "This field is required" },
{ minLength: 3, message: "Too short" },
{ maxLength: 50, message: "Too long" },
{ min: 18, message: "Must be at least 18" },
{ max: 99, message: "Must be 99 or less" },
{ pattern: /^[a-z]+$/, message: "Lowercase only" },
{
validate: (value, allValues) => {
if (value === allValues.username) return "Cannot match your username"
return true // return true = valid, return string = error message
},
},
]The validate function receives the current field value and all other field values — use it for cross-field rules like confirming passwords match or ensuring two fields are not the same.
Options
| Prop | Type | Default |
|---|---|---|
fields | FieldsConfig | - |
onSubmit | (values: FormValues) => Promise<void> | void | - |
onSuccess | (values: FormValues) => void | - |
onError | (error: unknown, values: FormValues) => void | - |
onChange | (values: FormValues, fieldName: string) => void | - |
validateOn | change | blur | submit | blur |
Field Options
| Prop | Type | Default |
|---|---|---|
value | string | number | boolean | - |
rules | FieldRule[] | - |
disabled | boolean | false |
Returns
| Prop | Type | Default |
|---|---|---|
fields | FieldsState | - |
values | FormValues | - |
errors | FormErrors | - |
status | FormStatus | - |
isValid | boolean | - |
isDirty | boolean | - |
isSubmitting | boolean | - |
isSuccess | boolean | - |
isError | boolean | - |
submitError | unknown | - |
is(status) | (s: FormStatus) => boolean | - |
setValue(name, value) | (name, value) => void | - |
setTouched(name) | (name) => void | - |
setError(name, error) | (name, error) => void | - |
clearError(name) | (name) => void | - |
validateField(name) | (name) => boolean | - |
validateAll() | () => boolean | - |
handleSubmit(e?) | (e?) => Promise<void> | - |
submit() | () => Promise<void> | - |
reset() | () => void | - |
resetField(name) | (name) => void | - |