React with Context API
Overview
The Context API showcase uses only React's built-in provider, consumer hook, and reducer to share a form model across a subtree.
Using @validation-rules-engine/react
Context API is useful when a form draft must be shared by a bounded subtree. Keep the reducer pure and run Validation Rules Engine in components that consume the context model.
Step 1 — Install Package
Install the React adapter and Core package.
npm install @validation-rules-engine/react @validation-rules-engine/core
No additional state library is required.
{
"dependencies": {
"react": "^19.0.0",
"@validation-rules-engine/react": "^1.0.0"
}
}
Step 2 — Configure Project
Compose your form context provider with ValidationRulesProvider.
import { ValidationRulesProvider } from '@validation-rules-engine/react';
import { ProfileStateProvider } from './ProfileStateContext';
export function ProfileRoute() {
return (
<ValidationRulesProvider>
<ProfileStateProvider>
<ContextProfileForm />
</ProfileStateProvider>
</ValidationRulesProvider>
);
}
Step 3 — Create Validation Policy
The policy targets the context model value.
import type { ValidationPolicy, ValidationTarget } from '@validation-rules-engine/react';
export type ProfileModel = ValidationTarget & {
firstName: string;
lastName: string;
email: string;
role: string;
attested: 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('role').isRequired('Role is required'),
v.validateFor('attested').isChecked('Attestation is required')
]
};
Step 4 — Register Policy
Register policies in the consuming form component.
const policies = useMemo(() => [{ name: 'profile', policy: profilePolicy }], []);
const groups = useMemo(() => [
{ name: 'profileGroup', policies: ['profile'], formGroups: ['profile'], fields: ['firstName', 'lastName', 'email', 'role', 'attested'] }
], []);
const validation = useValidationRules({ model: state.model, policies, policyNames: ['profile'], groups });
Step 5 — Connect State Management
Use a reducer with explicit actions. Do not dispatch validation side effects from the reducer.
import { createContext, PropsWithChildren, useContext, useMemo, useReducer } from 'react';
import type { ProfileModel } from './profilePolicy';
const initialModel: ProfileModel = { firstName: '', lastName: '', email: '', role: '', attested: false };
type Action =
| { type: 'fieldChanged'; path: keyof ProfileModel; value: unknown }
| { type: 'validated'; model: ProfileModel }
| { type: 'reset' };
function reducer(state: { model: ProfileModel }, action: Action) {
if (action.type === 'fieldChanged') return { model: { ...state.model, [action.path]: action.value } };
if (action.type === 'validated') return { model: structuredClone(action.model) };
return { model: initialModel };
}
const ProfileStateContext = createContext<{ state: { model: ProfileModel }; dispatch: React.Dispatch<Action> } | null>(null);
Step 6 — Bind Controls
Consume context state, dispatch changes, and show adapter messages.
const { state, dispatch } = useProfileState();
const emailErrors = validation.getFieldErrors('email');
<input value={state.model.email} onChange={(event) => dispatch({ type: 'fieldChanged', path: 'email', value: event.target.value })} onBlur={() => void validation.validateField('email')} />
<ValidationMessage errors={emailErrors} />
<ValidationSummary errors={validation.errors} />
Step 7 — Validate
Validate from the consumer component and dispatch a validated model action.
async function submit() {
const snapshot = await validation.validate({ showAllErrors: true });
dispatch({ type: 'validated', model: validation.model });
if (snapshot.isValid) await saveProfile(validation.model);
}
await validation.validateField('email');
await validation.validateGroup('profileGroup');
Step 8 — Reset
Clear adapter metadata and dispatch reset.
function reset() {
validation.clear();
dispatch({ type: 'reset' });
}
Policy cleanup is automatic on unmount.
useEffect(() => () => dispatch({ type: 'reset' }), [dispatch]);
Step 9 — Best Practices
Memoize provider values so consumers do not rerender unnecessarily.
const value = useMemo(() => ({ state, dispatch }), [state, dispatch]);
return <ProfileStateContext.Provider value={value}>{children}</ProfileStateContext.Provider>;
Keep the provider close to the form subtree, split read/write contexts for very large forms, and avoid treating one global context as an application-wide store.
Step 10 — Complete Working Example
import { FormEvent, useMemo } from 'react';
import { ValidationMessage, ValidationSummary, useValidationRules } from '@validation-rules-engine/react';
import { ProfileModel, profilePolicy } from './profilePolicy';
export function ContextProfileForm() {
const { state, dispatch } = useProfileState();
const policies = useMemo(() => [{ name: 'profile', policy: profilePolicy }], []);
const validation = useValidationRules<ProfileModel>({ model: state.model, policies, policyNames: ['profile'] });
const emailErrors = validation.getFieldErrors('email');
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const snapshot = await validation.validate({ showAllErrors: true });
dispatch({ type: 'validated', model: validation.model });
if (snapshot.isValid) await saveProfile(validation.model);
}
return (
<form onSubmit={submit} noValidate>
<ValidationSummary errors={validation.errors} />
<input aria-label="Email" value={state.model.email} onChange={(event) => dispatch({ type: 'fieldChanged', path: 'email', value: event.target.value })} onBlur={() => void validation.validateField('email')} />
<ValidationMessage errors={emailErrors} />
<label><input type="checkbox" checked={state.model.attested} onChange={(event) => dispatch({ type: 'fieldChanged', path: 'attested', value: event.target.checked })} /> Attested</label>
<button type="submit">Save</button>
<button type="button" onClick={() => { validation.clear(); dispatch({ type: 'reset' }); }}>Reset</button>
</form>
);
}
function ProfileStateProvider({ children }: PropsWithChildren) {
const [state, dispatch] = useReducer(reducer, { model: initialModel });
const value = useMemo(() => ({ state, dispatch }), [state]);
return <ProfileStateContext.Provider value={value}>{children}</ProfileStateContext.Provider>;
}
Installation
npm install @validation-rules-engine/react @validation-rules-engine/core
Package imports
import { createContext, useContext, useReducer } from 'react';
import { useValidationRules, useValidationField, ValidationSummary } from '@validation-rules-engine/react';
Provider setup
Create a dedicated form context instead of placing every form in a global application context.
const ProfileContext = createContext(null);
function ProfileProvider({ children }) {
const [model, dispatch] = useReducer(profileReducer, initialModel);
return <ProfileContext.Provider value={{ model, dispatch }}>{children}</ProfileContext.Provider>;
}
Policy registration
Read the context model and pass it to validation hooks.
const { model, dispatch } = useContext(ProfileContext);
const form = useValidationRules({ model, policies, policyNames: ['profile'], groups });
Policy unregistration
Policies unregister when the route unmounts. Reset context state when users cancel or leave a draft.
Validation lifecycle
Dispatch model updates, then validate fields, groups, or the full form through the bridge.
Validation Groups
Reducers work well when group state and form state should be reset through one action.
Validation Summary
<ValidationSummary errors={form.errors} />
Custom Inputs
Custom inputs can dispatch field changes and read validation metadata from useValidationField.
Performance Considerations
Split contexts or memoize provider values for large forms. Context updates all consumers that read the provider value.
Troubleshooting
If unrelated components rerender, move the form context closer to the route or split model/actions into separate contexts.
Complete code example
function profileReducer(model, action) {
switch (action.type) {
case 'field':
return { ...model, [action.path]: action.value };
case 'reset':
return { firstName: '', email: '' };
default:
return model;
}
}
function ProfileForm() {
const { model, dispatch } = useProfileContext();
const form = useExternalValidationBridge({
model,
setFieldValue: (path, value) => dispatch({ type: 'field', path, value }),
policies,
policyNames: ['profile']
});
return <ProfileFields form={form} onReset={() => dispatch({ type: 'reset' })} />;
}
Architecture
A dedicated Context Provider owns a reducer. A consumer bridge maps reducer state and dispatch into the same contract consumed by the shared validation pages.
Controls → reducer dispatch → Context Provider → consumers → validation hooks
Why use this state management library
Choose Context when a form must be shared through a bounded component subtree and adding a state library would not provide enough value.
How Validation Rules Engine integrates
The provider owns model updates; validation hooks consume the current context model. Neither the context nor reducer depends on the validation engine.
Best Practices
- Keep the provider close to its consumers.
- Memoize provider values and callbacks.
- Use a reducer for explicit complex transitions.
- Split contexts when consumers need unrelated update frequencies.
Common Mistakes
- Using one application-wide context for every form.
- Recreating provider values unnecessarily.
- Treating Context itself as a complete state-management architecture.
- Dispatching validation side effects from the reducer.
Code Example
const FormContext = createContext(null);
const [state, dispatch] = useReducer(reducer, { model: initialModel, revision: 0 });
<FormContext.Provider value={{ state, dispatch }}>
<Form />
</FormContext.Provider>