React with Jotai
Overview
The Jotai showcase models form state as atoms and uses a derived atom for live populated-value state. The validation layer consumes ordinary model values.
Using @validation-rules-engine/react
Jotai works well when form state is naturally atomic. Keep atoms responsible for draft transitions, and run @validation-rules-engine/react hooks in components that read the current model.
Step 1 — Install Package
Install the React adapter, Core package, and Jotai.
npm install @validation-rules-engine/react @validation-rules-engine/core jotai
Jotai does not need any adapter-specific setup.
{
"dependencies": {
"jotai": "^2.0.0",
"@validation-rules-engine/react": "^1.0.0"
}
}
Step 2 — Configure Project
Wrap the route with ValidationRulesProvider. Add a Jotai Provider when you need route-scoped atom state.
import { Provider as JotaiProvider } from 'jotai';
import { ValidationRulesProvider } from '@validation-rules-engine/react';
export function ProfileRoute() {
return (
<JotaiProvider>
<ValidationRulesProvider>
<JotaiProfileForm />
</ValidationRulesProvider>
</JotaiProvider>
);
}
Step 3 — Create Validation Policy
Policies validate the full model atom value.
import type { ValidationPolicy, ValidationTarget } from '@validation-rules-engine/react';
export type ProfileModel = ValidationTarget & {
firstName: string;
lastName: string;
email: string;
country: string;
approved: 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('approved').isChecked('Approval is required')
]
};
Step 4 — Register Policy
useValidationRules registers policy objects and group definitions from the component lifecycle.
const policies = useMemo(() => [{ name: 'profile', policy: profilePolicy }], []);
const validation = useValidationRules({
model,
policies,
policyNames: ['profile'],
groups: [{ name: 'profileGroup', policies: ['profile'], formGroups: ['profile'], fields: ['firstName', 'lastName', 'email', 'country', 'approved'] }]
});
Step 5 — Connect State Management
Use one model atom plus write atoms for domain transitions.
import { atom } from 'jotai';
const initialModel: ProfileModel = { firstName: '', lastName: '', email: '', country: '', approved: false };
export const profileModelAtom = atom<ProfileModel>(initialModel);
export const profileErrorCountAtom = atom((get) => get(profileModelAtom).validationResults?.length ?? 0);
export const profileFieldChangedAtom = atom(null, (get, set, update: { path: keyof ProfileModel; value: unknown }) => {
set(profileModelAtom, { ...get(profileModelAtom), [update.path]: update.value });
});
export const profileResetAtom = atom(null, (_get, set) => set(profileModelAtom, initialModel));
Step 6 — Bind Controls
Read and write atoms in the form component, then use adapter message/summary components.
const [model, setModel] = useAtom(profileModelAtom);
const [, changeField] = useAtom(profileFieldChangedAtom);
const emailErrors = validation.getFieldErrors('email');
<input value={model.email} onChange={(event) => changeField({ path: 'email', value: event.target.value })} onBlur={() => void validation.validateField('email')} />
<ValidationMessage errors={emailErrors} />
<ValidationSummary errors={validation.errors} />
Step 7 — Validate
Validate the atom value and set the decorated model back into the atom.
async function submit() {
const snapshot = await validation.validate({ showAllErrors: true });
setModel(structuredClone(validation.model));
if (snapshot.isValid) await saveProfile(validation.model);
}
await validation.validateField('email');
await validation.validateGroup('profileGroup');
Step 8 — Reset
Clear Validation Rules Engine state, then dispatch the reset atom.
function reset() {
validation.clear();
resetProfile();
}
The hook unregisters policies when the component unmounts.
useEffect(() => () => resetProfile(), [resetProfile]);
Step 9 — Best Practices
Define atoms outside components and use write atoms for meaningful transitions.
export const profileCanSubmitAtom = atom((get) => {
const model = get(profileModelAtom);
return Boolean(model.email && model.approved);
});
Do not store the validation engine in atoms. Store plain draft data and let adapter hooks derive validation state.
Step 10 — Complete Working Example
import { FormEvent, useMemo } from 'react';
import { useAtom } from 'jotai';
import { ValidationMessage, ValidationSummary, useValidationRules } from '@validation-rules-engine/react';
import { ProfileModel, profilePolicy } from './profilePolicy';
import { profileFieldChangedAtom, profileModelAtom, profileResetAtom } from './profileAtoms';
export function JotaiProfileForm() {
const [model, setModel] = useAtom(profileModelAtom);
const [, changeField] = useAtom(profileFieldChangedAtom);
const [, resetProfile] = useAtom(profileResetAtom);
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 });
setModel(structuredClone(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) => changeField({ path: 'email', value: event.target.value })} onBlur={() => void validation.validateField('email')} />
<ValidationMessage errors={emailErrors} />
<label><input type="checkbox" checked={model.approved} onChange={(event) => changeField({ path: 'approved', value: event.target.checked })} /> Approved</label>
<button type="submit">Save</button>
<button type="button" onClick={() => { validation.clear(); resetProfile(); }}>Reset</button>
</form>
);
}
Installation
npm install @validation-rules-engine/react jotai
Package imports
import { atom, Provider, useAtom, useAtomValue } from 'jotai';
import { useValidationRules, useValidationField, ValidationSummary } from '@validation-rules-engine/react';
Provider setup
Use a route-scoped Jotai provider when the form should not share atoms globally.
<Provider>
<ProfileForm />
</Provider>
Policy registration
Read the model atom and register policies through useValidationRules.
const model = useAtomValue(profileAtom);
const validation = useValidationRules({ model, policies, policyNames: ['profile'], groups });
Policy unregistration
Policies unregister with the hook. Reset route atoms when leaving the page if the draft should not persist.
Validation lifecycle
Update primitive atoms or a model atom, then run field, group, or submit validation from the bridge.
Validation Groups
Use derived atoms for group counts and status readouts.
const errorCountAtom = atom((get) => get(profileAtom).validationResults?.length ?? 0);
Validation Summary
<ValidationSummary errors={validation.errors} />
Custom Inputs
Custom controls can write through atom setters while reading validation metadata from useValidationField.
Performance Considerations
Split large models into atoms only when it improves rendering. For policy validation, still provide a complete model snapshot.
Troubleshooting
If validation sees stale data, make sure the model passed to the hook is reconstructed after atom updates and not mutated in place.
Complete code example
const profileAtom = atom({ firstName: '', email: '' });
const setFieldAtom = atom(null, (get, set, { path, value }) => {
set(profileAtom, { ...get(profileAtom), [path]: value });
});
function ProfileForm() {
const model = useAtomValue(profileAtom);
const [, setField] = useAtom(setFieldAtom);
const form = useExternalValidationBridge({
model,
setFieldValue: (path, value) => setField({ path, value }),
policies,
policyNames: ['profile']
});
return <ProfileFields form={form} />;
}
Architecture
Primitive atoms hold the form model and revision. A derived atom reads the model and calculates display-only state. A scoped Jotai Provider prevents form drafts from leaking between routes.
Controls → model atom → derived atoms → validation hooks → core policies
Why use this state management library
Choose Jotai when state is naturally atomic, derived dependencies are important, or components need focused subscriptions composed from small units.
How Validation Rules Engine integrates
Atom writes publish immutable model transitions. The validation hooks register policies and evaluate the current atom value without putting the engine or policies into atoms.
Best Practices
- Scope atom stores for independent form instances.
- Derive display state instead of synchronizing duplicate atoms.
- Keep policy objects stable.
- Use write atoms for domain-specific transitions in larger forms.
Common Mistakes
- Creating atom definitions during every consumer render.
- Splitting tightly coupled form data into too many atoms.
- Storing validation engine instances in atoms.
- Duplicating derivable error counts.
Code Example
const modelAtom = atom(initialModel);
const populatedAtom = atom((get) => countPopulatedValues(get(modelAtom)));
const [model, setModel] = useAtom(modelAtom);
const populated = useAtomValue(populatedAtom);