React with Zustand
Overview
The Zustand showcase uses a route-scoped external store and focused selectors. Validation behavior is identical to the local and Redux examples.
Using @validation-rules-engine/react
With Zustand, keep the store focused on model transitions and let React components call Validation Rules Engine hooks with the selected model.
Step 1 — Install Package
Install the React adapter, Core package, and Zustand.
npm install @validation-rules-engine/react @validation-rules-engine/core zustand
Zustand is independent of the adapter and should be versioned with the application.
{
"dependencies": {
"zustand": "^5.0.0",
"@validation-rules-engine/react": "^1.0.0"
}
}
Step 2 — Configure Project
Wrap the route in ValidationRulesProvider. Provide a scoped Zustand store when independent draft instances are needed.
import { createContext, useContext, useMemo } from 'react';
import { createStore, useStore } from 'zustand';
import { ValidationRulesProvider } from '@validation-rules-engine/react';
const ProfileStoreContext = createContext<ReturnType<typeof createProfileStore> | null>(null);
export function ProfileRoute() {
const store = useMemo(() => createProfileStore(), []);
return (
<ValidationRulesProvider>
<ProfileStoreContext.Provider value={store}>
<ZustandProfileForm />
</ProfileStoreContext.Provider>
</ValidationRulesProvider>
);
}
Step 3 — Create Validation Policy
Keep policies separate from Zustand store creation.
import type { ValidationPolicy, ValidationTarget } from '@validation-rules-engine/react';
export type ProfileModel = ValidationTarget & {
firstName: string;
lastName: string;
email: string;
country: string;
subscribed: boolean;
};
export const profilePolicy: ValidationPolicy = {
addValidations: (v) => [
v.validateFor('firstName').isRequired('First name is required'),
v.validateFor('lastName').isRequired('Last name is required'),
v.validateFor('email').isRequired('Email is required').isEmail('Enter a valid email'),
v.validateFor('country').isRequired('Country is required'),
v.validateFor('subscribed').isChecked('Subscription confirmation is required')
]
};
Step 4 — Register Policy
Use useValidationRules for policy registration and automatic cleanup.
const policies = useMemo(() => [{ name: 'profile', policy: profilePolicy }], []);
const validation = useValidationRules({
model,
policies,
policyNames: ['profile'],
groups: [{ name: 'profileGroup', policies: ['profile'], formGroups: ['profile'], fields: profileFields }]
});
Step 5 — Connect State Management
Create a store with explicit field, commit, and reset actions.
const initialModel: ProfileModel = { firstName: '', lastName: '', email: '', country: '', subscribed: false };
const profileFields = ['firstName', 'lastName', 'email', 'country', 'subscribed'] as const;
function createProfileStore() {
return createStore<{
model: ProfileModel;
change(path: keyof ProfileModel, value: unknown): void;
commit(model: ProfileModel): void;
reset(): void;
}>((set) => ({
model: initialModel,
change: (path, value) => set((state) => ({ model: { ...state.model, [path]: value } })),
commit: (model) => set({ model: structuredClone(model) }),
reset: () => set({ model: initialModel })
}));
}
Step 6 — Bind Controls
Select only the model and actions that the component uses.
const store = useProfileStore();
const model = useStore(store, (state) => state.model);
const change = useStore(store, (state) => state.change);
const emailErrors = validation.getFieldErrors('email');
<input value={model.email} onChange={(event) => change('email', event.target.value)} onBlur={() => void validation.validateField('email')} />
<ValidationMessage errors={emailErrors} />
<ValidationSummary errors={validation.errors} />
Step 7 — Validate
Run validation against the selected model, then commit the decorated clone to Zustand.
async function submit() {
const snapshot = await validation.validate({ showAllErrors: true });
commit(structuredClone(validation.model));
if (snapshot.isValid) await saveProfile(validation.model);
}
await validation.validateGroup('profileGroup');
Step 8 — Reset
Clear validation state before resetting the store.
function reset() {
validation.clear();
resetStore();
}
Policy cleanup is owned by the hook; store cleanup is owned by the scoped provider.
useEffect(() => () => resetStore(), [resetStore]);
Step 9 — Best Practices
Use scoped stores for form drafts unless persistence is intentional.
const email = useStore(store, (state) => state.model.email);
const errorCount = validation.errors.length;
Avoid subscribing every field component to the entire store, keep actions immutable, and do not put the Validation Engine into Zustand.
Step 10 — Complete Working Example
import { FormEvent, useMemo } from 'react';
import { useStore } from 'zustand';
import { ValidationMessage, ValidationSummary, useValidationRules } from '@validation-rules-engine/react';
import { ProfileModel, profilePolicy } from './profilePolicy';
export function ZustandProfileForm() {
const store = useProfileStore();
const model = useStore(store, (state) => state.model);
const change = useStore(store, (state) => state.change);
const commit = useStore(store, (state) => state.commit);
const resetStore = useStore(store, (state) => state.reset);
const policies = useMemo(() => [{ name: 'profile', policy: profilePolicy }], []);
const validation = useValidationRules<ProfileModel>({ model, policies, policyNames: ['profile'] });
const emailErrors = validation.getFieldErrors('email');
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const snapshot = await validation.validate({ showAllErrors: true });
commit(validation.model);
if (snapshot.isValid) await saveProfile(validation.model);
}
return (
<form onSubmit={submit} noValidate>
<ValidationSummary errors={validation.errors} />
<input aria-label="Email" value={model.email} onChange={(event) => change('email', event.target.value)} onBlur={() => void validation.validateField('email')} />
<ValidationMessage errors={emailErrors} />
<label><input type="checkbox" checked={model.subscribed} onChange={(event) => change('subscribed', event.target.checked)} /> Subscribe</label>
<button type="submit">Save</button>
<button type="button" onClick={() => { validation.clear(); resetStore(); }}>Reset</button>
</form>
);
}
function useProfileStore() {
const store = useContext(ProfileStoreContext);
if (!store) throw new Error('ProfileStoreContext is missing');
return store;
}
Installation
npm install @validation-rules-engine/react zustand
Package imports
import { createStore } from 'zustand/vanilla';
import { useStore } from 'zustand';
import { useValidationRules, useValidationField, ValidationSummary } from '@validation-rules-engine/react';
Provider setup
Create a scoped vanilla store for the form route and expose it through React context.
const store = createStore(() => ({ model: initialModel, revision: 0 }));
Policy registration
Select the model from Zustand and pass it to the validation hook.
const model = useStore(store, (state) => state.model);
const form = useValidationRules({ model, policies, policyNames: ['profile'], groups });
Policy unregistration
Validation policies unregister when the hook unmounts. Dispose the route-scoped store by unmounting its provider.
Validation lifecycle
Use store actions for value changes and call validation helpers for field, group, and submit workflows.
Validation Groups
Zustand works well with derived selectors for group badges because selectors can subscribe to just the group status or error count.
Validation Summary
<ValidationSummary errors={form.errors} />
Custom Inputs
Bridge useValidationField metadata with a store action:
const field = useValidationField(formBridge, 'email');
store.getState().setField('email', nextValue);
Performance Considerations
Use selectors instead of subscribing every component to the full model. Keep large generated forms route-scoped.
Troubleshooting
If controls do not update, confirm the selector reads the same store instance as the provider and the action replaces the nested value immutably.
Complete code example
const profileStore = createStore((set) => ({
model: { firstName: '', email: '' },
setField: (path, value) => set((state) => ({ model: { ...state.model, [path]: value } })),
reset: () => set({ model: { firstName: '', email: '' } })
}));
function ProfileForm() {
const model = useStore(profileStore, (state) => state.model);
const setField = useStore(profileStore, (state) => state.setField);
const form = useExternalValidationBridge({ model, setField, policies, policyNames: ['profile'] });
return <ProfileFields form={form} onReset={() => profileStore.getState().reset()} />;
}
Architecture
A vanilla Zustand store exposes the model, a revision, and replace/reset actions. React's useStore hook subscribes to each selected value before the shared bridge passes the model to the validation hooks.
Controls → Zustand actions → vanilla store → focused selectors → validation
Why use this state management library
Choose Zustand for a small external store, concise actions, scoped or shared state, and selector-based rendering without Redux-style ceremony.
How Validation Rules Engine integrates
The store owns model transitions. Validation policies and groups are registered by React hooks and never imported by the Zustand store.
Best Practices
- Create a scoped store per form when drafts must be isolated.
- Select only the state a component renders.
- Keep actions explicit and immutable.
- Reset model and validation state as one user operation.
Common Mistakes
- Creating a store during every render.
- Subscribing every field to the whole store.
- Mutating nested data without returning a new model.
- Reusing singleton draft state between unrelated forms.
Code Example
const store = createStore((set) => ({
model: initialModel,
replaceModel: (model) => set((state) => ({ model, revision: state.revision + 1 })),
resetModel: (model) => set((state) => ({ model, revision: state.revision + 1 }))
}));
const model = useStore(store, (state) => state.model);