Building high-performance forms in React without the bloat
Introduction
Forms are the backbone of web applicationsβfrom simple login screens to complex multi-step wizards. Yet, form management in React has always been a trade-off between simplicity and performance.
Libraries like Formik offer great developer experience but come with bundle bloat (~13KB). React Hook Form is faster but has a steeper learning curve. What if you could have the best of both worlds?
Enter React-Z-Formβa lightweight (~3KB gzipped), high-performance form library powered by Zustand that achieves zero unnecessary re-renders while maintaining a simple, intuitive API.
Why Another Form Library?
Before diving in, let's look at what makes React-Z-Form different:
| Feature | React-Z-Form | React Hook Form | Formik |
|---|---|---|---|
| Bundle Size | ~3KB | ~9KB | ~13KB |
| Re-renders | Zero (field-level) | Minimal | Full form |
| React 18/19 | β Native | β Supported | β οΈ Partial |
| TypeScript | β Built-in | β Built-in | β Built-in |
| Zod Integration | β Native adapters | Via resolver | Via adapter |
| Learning Curve | Low | Medium | Low |
The key differentiator? Fine-grained subscriptions. When you type in the email field, only the email field re-rendersβnot the password field, not the submit button, not the entire form.
Installation
npm install react-z-form zustand immer
Peer Dependencies:
- React >= 18.0.0
- Zustand >= 4.0.0
- Immer >= 9.0.0
Quick Start: Your First Form
Let's build a login form in under 30 lines:
import { FormProvider, Field, useForm } from 'react-z-form';
function LoginForm() {
const { submitWith, isSubmitting } = useForm('login');
const handleSubmit = (e) => {
e.preventDefault();
submitWith(async (values) => {
await api.login(values);
console.log('Logged in!', values);
});
};
return (
<FormProvider form="login" initialValues={{ email: '', password: '' }}>
<form onSubmit={handleSubmit}>
<Field
name="email"
validate={(v) => (!v ? 'Email is required' : undefined)}
>
{({ input, meta }) => (
<div>
<input {...input} type="email" placeholder="Email" />
{meta.touched && meta.error && (
<span className="error">{meta.error}</span>
)}
</div>
)}
</Field>
<Field
name="password"
validate={(v) => (!v ? 'Password is required' : undefined)}
>
{({ input, meta }) => (
<div>
<input {...input} type="password" placeholder="Password" />
{meta.touched && meta.error && (
<span className="error">{meta.error}</span>
)}
</div>
)}
</Field>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Logging in...' : 'Login'}
</button>
</form>
</FormProvider>
);
}
That's it! You have a fully functional form with:
- β Validation
- β Error display
- β Touch tracking
- β Submission handling
- β Loading states
Core Concepts
1. FormProvider
The FormProvider wraps your form and registers it in the Zustand store:
<FormProvider
form="myForm" // Unique form identifier
initialValues={{ name: '' }} // Initial field values
keepOnUnmount={false} // Persist state after unmount?
>
{/* Your form fields */}
</FormProvider>
Pro tip: Use keepOnUnmount={true} for multi-step forms to preserve state between steps.
2. Field Component (Render Props)
The Field component uses the render props pattern for maximum flexibility:
<Field
name="username"
validate={(value) => value.length < 3 ? 'Too short' : undefined}
validateOn="blur" // 'change' | 'blur' | 'submit' | 'all'
>
{({ input, meta }) => (
<>
<input {...input} />
{meta.touched && meta.error && <span>{meta.error}</span>}
</>
)}
</Field>
What's in input?
-
value- Current field value -
onChange- Change handler -
onBlur- Blur handler -
name- Field name
What's in meta?
-
touched- Has the field been blurred? -
dirty- Has the value changed? -
error- Validation error message
3. useForm Hook
For programmatic control, use the useForm hook:
const {
// State
values,
errors,
touched,
dirty,
isSubmitting,
submitCount,
// Computed (v1.1.0)
isValid, // No errors
isDirty, // Any field changed
isTouched, // Any field blurred
// Actions
reset,
setFieldValue,
setFieldError,
setValues,
submitWith,
} = useForm('myForm');
Validation: Inline or Zod
React-Z-Form supports both inline validation and Zod schemas out of the box.
Inline Validation
<Field
name="email"
validate={(value, allValues) => {
if (!value) return 'Required';
if (!/\S+@\S+\.\S+/.test(value)) return 'Invalid email';
return undefined; // No error
}}
>
{({ input, meta }) => /* ... */}
</Field>
Zod Validation (Recommended)
import { z } from 'zod';
import { zodField } from 'react-z-form';
const emailSchema = z.string()
.min(1, 'Email is required')
.email('Invalid email address');
<Field name="email" validate={zodField(emailSchema)}>
{({ input, meta }) => /* ... */}
</Field>
Form-Level Zod Validation
import { zodForm, zodValidate } from 'react-z-form';
const formSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string()
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});
// Validate entire form
const handleSubmit = () => {
const errors = zodForm(formSchema)(values);
if (Object.keys(errors).length > 0) {
setErrors(errors);
return;
}
// Submit...
};
Validation Modes
Control when validation runs with the validateOn prop:
// Validate on every keystroke
<Field name="email" validateOn="change" validate={...}>
// Validate only when field loses focus (default)
<Field name="email" validateOn="blur" validate={...}>
// Validate only on form submit
<Field name="email" validateOn="submit" validate={...}>
// Validate on change AND blur
<Field name="email" validateOn="all" validate={...}>
Programmatic Field Control
New in v1.1.0βfull programmatic control over form state:
function DynamicForm() {
const { setFieldValue, setValues, reset, values } = useForm('dynamic');
return (
<FormProvider form="dynamic" initialValues={{ status: 'pending' }}>
{/* Buttons to control form programmatically */}
<button onClick={() => setFieldValue('status', 'active')}>
Set Active
</button>
<button onClick={() => setValues({
status: 'complete',
completedAt: new Date().toISOString()
})}>
Mark Complete
</button>
<button onClick={() => reset()}>
Reset Form
</button>
<pre>{JSON.stringify(values, null, 2)}</pre>
</FormProvider>
);
}
UI Library Integration
React-Z-Form works seamlessly with any UI library. Here's an example with Material UI:
import { TextField, Button } from '@mui/material';
import { FormProvider, Field, useForm } from 'react-z-form';
import { zodField } from 'react-z-form';
import { z } from 'zod';
const schema = {
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Min 8 characters'),
};
function MaterialUIForm() {
const { submitWith, isSubmitting } = useForm('material');
return (
<FormProvider form="material" initialValues={{ email: '', password: '' }}>
<form onSubmit={(e) => {
e.preventDefault();
submitWith(async (values) => console.log(values));
}}>
<Field name="email" validate={zodField(schema.email)}>
{({ input, meta }) => (
<TextField
{...input}
label="Email"
error={meta.touched && !!meta.error}
helperText={meta.touched && meta.error}
fullWidth
margin="normal"
/>
)}
</Field>
<Field name="password" validate={zodField(schema.password)}>
{({ input, meta }) => (
<TextField
{...input}
type="password"
label="Password"
error={meta.touched && !!meta.error}
helperText={meta.touched && meta.error}
fullWidth
margin="normal"
/>
)}
</Field>
<Button
type="submit"
variant="contained"
disabled={isSubmitting}
fullWidth
>
Submit
</Button>
</form>
</FormProvider>
);
}
How It Achieves Zero Re-renders
The secret sauce is fine-grained Zustand subscriptions. Here's what happens under the hood:
// Each Field subscribes to ONLY its own data
const value = useFormStore((s) => s.forms[formName]?.values?.[name]);
const error = useFormStore((s) => s.forms[formName]?.errors?.[name]);
const touched = useFormStore((s) => s.forms[formName]?.touched?.[name]);
When the email field changes:
- Only
forms.login.values.emailupdates in the store - Only components subscribed to that specific path re-render
- The password field, submit button, and parent components don't re-render
Compare this to Formik, which re-renders the entire form on every keystroke!
FormSpy: When You Need the Full Picture
Sometimes you need to watch the entire form state (like for a debug panel or conditional logic). Use FormSpy:
import { FormSpy } from 'react-z-form';
<FormSpy>
{({ values, errors, touched, isSubmitting }) => (
<pre>
{JSON.stringify({ values, errors, touched, isSubmitting }, null, 2)}
</pre>
)}
</FormSpy>
Warning: FormSpy re-renders on every form change. Use it sparingly!
Complete API Reference
Components
| Component | Purpose |
|---|---|
FormProvider |
Wraps form, provides context |
Field |
Individual field with render props |
FormSpy |
Watch entire form state |
Hooks
| Hook | Purpose |
|---|---|
useForm(formName?) |
Form state + actions |
useField(fieldName, formName?) |
Field input + meta |
useFormState(formName?) |
Raw form state |
useFormName() |
Get form name from context |
Zod Adapters
| Function | Purpose |
|---|---|
zodField(schema) |
Field-level validator |
zodForm(schema) |
Form-level validator (returns error map) |
zodValidate(schema, values) |
Boolean validation check |
zodParse(schema, values) |
Parse with validation (throws on error) |
Migration from Other Libraries
From Formik
// Formik
<Formik initialValues={{ email: '' }} onSubmit={handleSubmit}>
<Form>
<Field name="email" />
<ErrorMessage name="email" />
</Form>
</Formik>
// React-Z-Form
<FormProvider form="myForm" initialValues={{ email: '' }}>
<form onSubmit={handleSubmit}>
<Field name="email">
{({ input, meta }) => (
<>
<input {...input} />
{meta.touched && meta.error && <span>{meta.error}</span>}
</>
)}
</Field>
</form>
</FormProvider>
From React Hook Form
// React Hook Form
const { register, handleSubmit, formState: { errors } } = useForm();
<input {...register('email', { required: true })} />
// React-Z-Form
const { submitWith } = useForm('myForm');
<Field name="email" validate={(v) => !v ? 'Required' : undefined}>
{({ input, meta }) => <input {...input} />}
</Field>
TypeScript Support
React-Z-Form includes full TypeScript definitions:
import { FormProvider, Field, useForm, UseFormResult } from 'react-z-form';
interface LoginValues {
email: string;
password: string;
}
function TypedForm() {
const form = useForm<LoginValues>('login');
// form.values is typed as LoginValues
console.log(form.values.email); // β
TypeScript knows this exists
return (
<FormProvider<LoginValues>
form="login"
initialValues={{ email: '', password: '' }}
>
{/* ... */}
</FormProvider>
);
}
When to Use React-Z-Form
Perfect for:
- β Applications already using Zustand
- β Performance-critical forms with many fields
- β Teams wanting a simple, lightweight solution
- β Projects needing React 18/19 concurrent features
- β Developers who prefer render props over HOCs
Consider alternatives if:
- β You need extensive community plugins/ecosystem
- β You have complex uncontrolled input requirements
- β Your team is already invested in Formik/RHF patterns
Conclusion
React-Z-Form proves that form management doesn't need to be complicated or heavy. By leveraging Zustand's fine-grained subscriptions and Immer's immutable updates, it delivers:
- 3KB bundle size (4x smaller than Formik)
- Zero unnecessary re-renders
- Simple render props API
- Native Zod validation support
- Full TypeScript support
The library is open source, MIT licensed, and ready for production.
Links:
- π¦ NPM: react-z-form
- π GitHub: devavp/react-z-form
Have you tried React-Z-Form? Share your experience in the comments! If you found this helpful, consider giving the repo a β on GitHub.
Tags: #react #javascript #typescript #webdev #forms #zustand
Top comments (0)