Form State Management with Zustand: Complete Guide to Inputs, Validation, and Performance
Zustand handles form state management by creating a lightweight store with createStore from src/vanilla.ts and subscribing to field-specific slices via the useStore hook in src/react.ts, enabling selective re-rendering for each input component without boilerplate.
Form state management with Zustand leverages the library's unopinionated, vanilla JavaScript core to handle everything from simple login inputs to complex validated forms. The pmndrs/zustand repository provides a minimal API surface that scales from UI-wide globals to isolated form components without requiring reducers, actions, or provider wrappers.
Why Zustand Fits Form Handling
Zustand eliminates the boilerplate typically associated with form state. Unlike reducer-based solutions, a Zustand store is a plain JavaScript object mutated via setState, allowing direct field updates with minimal code.
The architecture provides specific advantages for forms:
- No Boilerplate Reducers: Create a store with a single
createStorecall fromsrc/vanilla.ts. Actions are plain functions that callset(state => ...). - Direct Mutable Updates: The
setStatecallback receives the current state, enabling concise field updates likeset(state => { state.email = e.target.value }). - Selective Re-rendering: The
useStorehook insrc/react.tsaccepts a selector function, letting each input subscribe only to its specific slice (e.g.,state.email). Components re-render only when their subscribed value changes. - Framework Agnostic Core: Stores work in React, React Native, Solid, or vanilla JS, making forms portable across environments.
- Optional Middleware: Add
immerfor immutable nested updates orpersistfor saving drafts without modifying form logic.
Core Implementation Architecture
At its core, Zustand's form capability relies on two key files. The createStore factory in src/vanilla.ts returns an API containing setState, getState, and subscribe:
export const createStore = ((createState) =>
createState ? createStoreImpl(createState) : createStoreImpl) as Create
React components consume this API via the useStore hook defined in src/react.ts, which uses useSyncExternalStore for concurrent-safe subscriptions:
export function useStore<S extends ReadonlyStoreApi<unknown>, U>(
api: S,
selector: (state: ExtractState<S>) => U,
) {
const slice = React.useSyncExternalStore(
api.subscribe,
React.useCallback(() => selector(api.getState()), [api, selector]),
React.useCallback(() => selector(api.getInitialState()), [api, selector]),
)
React.useDebugValue(slice)
return slice
}
This selector pattern is critical for form performance, as it prevents unrelated inputs from re-rendering when a single field updates.
Structuring a Form Store Schema
A robust Zustand form store typically tracks three data domains:
type FormState = {
values: { [field: string]: string }
errors: { [field: string]?: string }
touched: { [field: string]?: boolean }
setField: (field: string, value: string) => void
setError: (field: string, error?: string) => void
setTouched: (field: string, touched?: boolean) => void
reset: () => void
}
values: Stores current input strings.errors: Holds validation messages per field.touched: Tracks whether a user has visited a field, controlling when validation errors display.
The store is instantiated once at module scope and consumed via useStore in components that need specific slices.
Practical Implementation Examples
Simple Login Form
For basic forms, define individual setters per field:
/* store.ts */
import { createStore } from 'zustand'
type LoginForm = {
email: string
password: string
setEmail: (email: string) => void
setPassword: (pw: string) => void
reset: () => void
}
export const useLoginForm = createStore<LoginForm>()(set => ({
email: '',
password: '',
setEmail: email => set({ email }),
setPassword: password => set({ password }),
reset: () => set({ email: '', password: '' }),
}))
/* LoginForm.tsx */
import { useLoginForm } from './store'
export function LoginForm() {
const email = useLoginForm(state => state.email)
const password = useLoginForm(state => state.password)
const setEmail = useLoginForm(state => state.setEmail)
const setPassword = useLoginForm(state => state.setPassword)
const reset = useLoginForm(state => state.reset)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
console.log('login', { email, password })
reset()
}
return (
<form onSubmit={handleSubmit}>
<label>
Email
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
</label>
<button type="submit">Sign In</button>
</form>
)
}
Each input re-renders only when its specific slice changes.
Dynamic Forms with Validation and Touch Tracking
For dynamic field sets, use records and derive field props:
/* formStore.ts */
import { createStore } from 'zustand'
type FormState = {
values: Record<string, string>
errors: Record<string, string>
touched: Record<string, boolean>
setField: (name: string, value: string) => void
setError: (name: string, error?: string) => void
setTouched: (name: string, touched?: boolean) => void
reset: () => void
}
export const useForm = createStore<FormState>()(set => ({
values: {},
errors: {},
touched: {},
setField: (name, value) =>
set(state => {
state.values[name] = value
state.errors[name] = value.trim() ? '' : 'Required'
}),
setError: (name, error = '') =>
set(state => {
state.errors[name] = error
}),
setTouched: (name, touched = true) =>
set(state => {
state.touched[name] = touched
}),
reset: () =>
set({
values: {},
errors: {},
touched: {},
}),
}))
/* MyForm.tsx */
import { useForm } from './formStore'
export function MyForm() {
const { values, errors, touched, setField, setTouched, reset } = useForm(
state => ({
values: state.values,
errors: state.errors,
touched: state.touched,
setField: state.setField,
setTouched: state.setTouched,
reset: state.reset,
}),
)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
console.log('submitted', values)
reset()
}
const field = (name: string) => ({
value: values[name] ?? '',
error: touched[name] && errors[name] ? errors[name] : '',
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
setField(name, e.target.value),
onBlur: () => setTouched(name, true),
})
return (
<form onSubmit={handleSubmit}>
<label>
First name
<input type="text" {...field('firstName')} />
{field('firstName').error && <span>{field('firstName').error}</span>}
</label>
<label>
Last name
<input type="text" {...field('lastName')} />
{field('lastName').error && <span>{field('lastName').error}</span>}
</label>
<button type="submit">Submit</button>
</form>
)
}
The memoized selector ensures typing in one field does not cause unrelated inputs to re-render.
Immutable Updates with Immer Middleware
For complex nested forms, the immer middleware allows mutating syntax while preserving immutability:
/* immerForm.ts */
import { createStore } from 'zustand'
import { immer } from 'zustand/middleware/immer'
type DraftForm = {
email: string
password: string
setEmail: (email: string) => void
setPassword: (pw: string) => void
}
export const useImmerForm = createStore<DraftForm>()(
immer(set => ({
email: '',
password: '',
setEmail: email => set(state => { state.email = email }),
setPassword: pw => set(state => { state.password = pw }),
}))
)
Install the middleware via npm install immer and import from zustand/middleware/immer. This pattern simplifies updates for deeply nested form structures like address hierarchies or dynamic arrays.
Summary
- Create stores using
createStorefromsrc/vanilla.tsto hold form values, errors, and touched states. - Subscribe selectively via
useStorefromsrc/react.tswith selector functions to prevent unnecessary re-renders when individual fields change. - Structure state with
values,errors, andtouchedrecords to support both simple and dynamic form layouts. - Leverage middleware like
immerfor immutable updates on complex nested data without verbose spread syntax. - Scale efficiently by keeping stores at module scope and consuming only required slices in input components.
Frequently Asked Questions
How do I prevent unnecessary re-renders in large Zustand forms?
Use the selector pattern in useStore(store, selector) to subscribe each input component only to its specific field value. According to the implementation in src/react.ts, useStore utilizes useSyncExternalStore with memoized callbacks, ensuring components re-render exclusively when their selected slice changes, even in forms with hundreds of inputs.
Can I use Zustand for form validation without external libraries?
Yes. Implement validation logic directly inside your store actions. For example, in the setField action, validate the new value immediately and update the errors record in the same set call. This keeps validation co-located with state mutations and accessible to any component subscribing to the error slice.
Should I use Immer middleware for form state updates?
Use Immer when your form contains deeply nested objects or arrays where immutable updates require verbose spread syntax. The middleware, available in zustand/middleware/immer, allows you to write mutating code like state.address.zip = value while maintaining immutable state semantics under the hood. For flat forms, standard set calls are sufficient.
How does Zustand compare to React Hook Form for form state management?
Zustand provides manual control over every aspect of form state, making it ideal for complex cross-form dependencies or when form data must interact with global UI state. React Hook Form optimizes for uncontrolled components and built-in validation patterns. Choose Zustand when you need fine-grained reactive control via selectors or when integrating form state with other global stores according to the pmndrs/zustand architecture.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →