DEV Community

Cover image for πŸš€ Introducing React-Z-Form: A Lightweight, Zero Re-render Form Library Powered by Zustand
Devanath
Devanath

Posted on

πŸš€ Introducing React-Z-Form: A Lightweight, Zero Re-render Form Library Powered by Zustand

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
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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...
};
Enter fullscreen mode Exit fullscreen mode

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={...}>
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

When the email field changes:

  1. Only forms.login.values.email updates in the store
  2. Only components subscribed to that specific path re-render
  3. 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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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:


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)