DEV Community

Rezaul Karim
Rezaul Karim

Posted on

React Form State Management with Zustic

Form State Management with Zustic

Building forms in React doesn't have to be complicated. In this guide, I'll show you how to use Zustic with a simple, elegant validation pattern that works for simple contact forms and complex multi-step forms alike.

Why This Approach?

This pattern is:

  • Minimal: No external validation libraries needed (but can add them if you want)
  • Type-safe: Full TypeScript support
  • Reusable: Works for any form
  • Flexible: Easy to extend with additional validation rules

The Pattern

The core idea is to define a Field type with metadata about each field, then build actions to update and validate fields.

type Field = {
  value: string
  error: string | null
  required?: { value: boolean; message: string }
  pattern?: { value: RegExp; message: string }
  min?: { value: number; message: string }
  max?: { value: number; message: string }
}
Enter fullscreen mode Exit fullscreen mode

This gives us:

  • value: The field's current value
  • error: Any validation error message
  • required, pattern, min, max: Validation rules

Simple Login Form Example

Let's build a login form with email and password:

import { create } from 'zustic'
import React from 'react'

type Field = {
  value: string
  error: string | null
  required?: { value: boolean; message: string }
  pattern?: { value: RegExp; message: string }
  min?: { value: number; message: string }
  max?: { value: number; message: string }
}

type FormStore = {
  email: Field
  password: Field
  setFieldValue: (field: 'email' | 'password', value: string) => void
  validateField: (field: 'email' | 'password') => void
  handleSubmit: (cb: (data: { email: string; password: string }) => void) => (e: React.FormEvent<HTMLFormElement>) => void
}

const useForm = create<FormStore>((set, get) => ({
  email: {
    value: '',
    error: null,
    required: { value: true, message: 'Email is required' },
    pattern: {
      value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
      message: 'Invalid email format',
    },
    min: { value: 5, message: 'Email must be at least 5 characters' },
    max: { value: 255, message: 'Email must be less than 255 characters' },
  },
  password: {
    value: '',
    error: null,
    required: { value: true, message: 'Password is required' },
    pattern: {
      value: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/,
      message: 'Password must be at least 8 characters and contain letters and numbers',
    },
    min: { value: 8, message: 'Password must be at least 8 characters' },
    max: { value: 255, message: 'Password must be less than 255 characters' },
  },

  // Update field value
  setFieldValue: (field, value) => {
    set((state) => ({
      [field]: {
        ...state[field],
        value,
      },
    }));
  },

  // Validate a field and set error message
  validateField: (field) => {
    set((state) => {
      const fieldState = state[field]
      let error: string | null = null

      // Check required
      if (fieldState.required?.value && !fieldState.value) {
        error = fieldState.required.message
      } 
      // Check pattern
      else if (fieldState.pattern?.value && !fieldState.pattern.value.test(fieldState.value)) {
        error = fieldState.pattern.message
      } 
      // Check min length
      else if (fieldState.min && fieldState.value.length < fieldState.min.value) {
        error = fieldState.min.message
      } 
      // Check max length
      else if (fieldState.max && fieldState.value.length > fieldState.max.value) {
        error = fieldState.max.message
      } else {
        error = null
      }

      return {
        [field]: {
          ...fieldState,
          error,
        },
      }
    })
  },

  // Handle form submission
  handleSubmit: (cb)=> (e) => {
    e.preventDefault()
    get().validateField('email')
    get().validateField('password')

    const emailError = get().email.error
    const passwordError = get().password.error   

    if(!emailError && !passwordError) {
      cb({
        email: get().email.value,
        password: get().password.value,
      })
    }
  }
}))
Enter fullscreen mode Exit fullscreen mode

Building the Form Component

Now let's create a reusable Controller component for form fields:

interface ControllerProps {
  field: 'email' | 'password';
  render: (value: string, error: string | null, onChange: (value: string) => void) => React.ReactNode;
}

function Controller({ field, render }: ControllerProps) {
  const state = useForm()
  const value = state[field].value
  const error = state[field].error
  const setFieldValue = state.setFieldValue
  const validateField = state.validateField

  const element = render(value, error, (value) => {
    setFieldValue(field, value)
    validateField(field)
  })
  return element
}

export default function LoginForm() {
  const handleSubmit = useForm((s)=>s.handleSubmit)

  const onSubmit = (data: { email: string; password: string }) => {
    console.log('Form submitted:', data);
    // Send to API
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Controller 
        field='email'
        render={(value, error, onChange) => (
          <div className='form-group'>
            <label>Email</label>
            <input 
              type="text" 
              value={value} 
              onChange={(e) => onChange(e.target.value)} 
              placeholder="your@email.com"
            />
            {error && <span className='error'>{error}</span>}
          </div>
        )}
      />

      <Controller 
        field='password'
        render={(value, error, onChange) => (
          <div className='form-group'>
            <label>Password</label>
            <input 
              type="password" 
              value={value} 
              onChange={(e) => onChange(e.target.value)}
              placeholder="••••••••"
            />
            {error && <span className='error'>{error}</span>}
          </div>
        )}
      />

      <button type="submit">Login</button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Advanced: Adding More Fields

To add more fields, simply extend the pattern:

type FormStore = {
  email: Field
  password: Field
  name: Field           // Add new field
  phone: Field          // Add new field
  // ... other fields

  setFieldValue: (field: keyof FormStore, value: string) => void
  validateField: (field: keyof FormStore) => void
}

const useForm = create<FormStore>((set, get) => ({
  // ... existing fields

  name: {
    value: '',
    error: null,
    required: { value: true, message: 'Name is required' },
    min: { value: 2, message: 'Name must be at least 2 characters' },
    max: { value: 100, message: 'Name must be less than 100 characters' },
  },

  phone: {
    value: '',
    error: null,
    pattern: {
      value: /^\d{10,}$/,
      message: 'Phone must be at least 10 digits',
    },
  },

  // ... rest of store
}))
Enter fullscreen mode Exit fullscreen mode

With Validation Libraries

Using Zod

import { z } from 'zod';

const emailSchema = z.string().email('Invalid email');
const passwordSchema = z.string().min(8, 'Min 8 chars').regex(/[A-Za-z]/, 'Letters required').regex(/\d/, 'Numbers required');

validateField: (field) => {
  set((state) => {
    const fieldState = state[field];
    let error: string | null = null;

    try {
      if (field === 'email') {
        emailSchema.parse(fieldState.value);
      } else if (field === 'password') {
        passwordSchema.parse(fieldState.value);
      }
    } catch (err) {
      if (err instanceof z.ZodError) {
        error = err.errors[0].message;
      }
    }

    return {
      [field]: { ...fieldState, error },
    };
  });
}
Enter fullscreen mode Exit fullscreen mode

Using Yup

import * as yup from 'yup';

const emailSchema = yup.string().email('Invalid email').required();
const passwordSchema = yup.string().min(8).required();

validateField: async (field) => {
  const state = get();
  const fieldState = state[field];
  let error: string | null = null;

  try {
    if (field === 'email') {
      await emailSchema.validate(fieldState.value);
    } else if (field === 'password') {
      await passwordSchema.validate(fieldState.value);
    }
  } catch (err) {
    if (err instanceof yup.ValidationError) {
      error = err.message;
    }
  }

  set((state) => ({
    [field]: { ...state[field], error },
  }));
}
Enter fullscreen mode Exit fullscreen mode

Best Practices

1. Validate on Blur

Only show errors after the user has interacted with the field:

<input 
  onBlur={() => validateField('email')}
  onChange={(e) => {
    setFieldValue('email', e.target.value);
    // Don't validate on every keystroke
  }}
/>
Enter fullscreen mode Exit fullscreen mode

2. Clear Errors on Change

Clear field errors when the user starts typing:

setFieldValue: (field, value) => {
  set((state) => ({
    [field]: {
      ...state[field],
      value,
      error: null,  // Clear error on change
    },
  }));
}
Enter fullscreen mode Exit fullscreen mode

3. Disable Submit While Errors Exist

<button 
  type="submit"
  disabled={useForm((s) => !!(s.email.error || s.password.error))}
>
  Login
</button>
Enter fullscreen mode Exit fullscreen mode

4. Show Loading State

type FormStore = {
  // ... fields
  isSubmitting: boolean
  setIsSubmitting: (value: boolean) => void
}

// In handleSubmit
handleSubmit: (cb) => async (e) => {
  e.preventDefault()
  get().validateField('email')
  get().validateField('password')

  if(!get().email.error && !get().password.error) {
    set({ isSubmitting: true })
    try {
      await cb({
        email: get().email.value,
        password: get().password.value,
      })
    } finally {
      set({ isSubmitting: false })
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Why Zustic Works Great for Forms

Lightweight - Only ~500B, won't bloat your bundle

Simple - No complex middleware setup needed

Type-safe - Full TypeScript support

Flexible - Works with any validation approach

Performant - Efficient re-renders with selectors

Familiar - Similar to other state management libraries

Conclusion

This pattern gives you everything you need for form state management:

  • Simple validation rules
  • Type-safe updates
  • Clean component structure
  • Easy to extend and customize

Start building better forms with Zustic today!

Top comments (0)