Skip to main content

Form State Management with Zustic

· 6 min read
Zustic Team
Lightweight State Management Library

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 }
}

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,
})
}
}
}))

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>
)
}

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
}))

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 },
};
});
}

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 },
}));
}

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
}}
/>

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
},
}));
}

3. Disable Submit While Errors Exist

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

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 })
}
}
}

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!