Bonus: Patterns for Complex SPFx Forms
Form architecture in SPFx: validation, steps, async rules, and performance.
Form state model
Use a reducer to centralize updates, flags, and validation.
type Values = { title: string; owner: string; due?: string };
type Errors = Partial<Record<keyof Values, string>>;
type State = { values: Values; errors: Errors; touched: Partial<Record<keyof Values, boolean>>; dirty: boolean; submitting: boolean };
type Action =
| { type: 'change'; name: keyof Values; value: string }
| { type: 'blur'; name: keyof Values }
| { type: 'validate' }
| { type: 'submit_start' }
| { type: 'submit_success' }
| { type: 'submit_error'; message: string };
function validate(values: Values): Errors {
const errors: Errors = {};
if (!values.title) errors.title = 'Required';
if (!values.owner) errors.owner = 'Required';
return errors;
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'change': {
const values = { ...state.values, [action.name]: action.value };
return { ...state, values, dirty: true };
}
case 'blur': {
return { ...state, touched: { ...state.touched, [action.name]: true } };
}
case 'validate': {
return { ...state, errors: validate(state.values) };
}
case 'submit_start': return { ...state, submitting: true };
case 'submit_success': return { ...state, submitting: false, dirty: false };
case 'submit_error': return { ...state, submitting: false };
}
}
export function useFormState(initial: Values) {
const [state, dispatch] = React.useReducer(reducer, { values: initial, errors: {}, touched: {}, dirty: false, submitting: false });
// Debounced validation
const valuesJson = JSON.stringify(state.values);
React.useEffect(() => {
const t = setTimeout(() => dispatch({ type: 'validate' }), 200);
return () => clearTimeout(t);
}, [valuesJson]);
return { state, dispatch };
}
Form component
export function TaskForm({ onSubmit }: { onSubmit: (v: Values) => Promise<void> }) {
const { state, dispatch } = useFormState({ title: '', owner: '' });
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
dispatch({ type: 'validate' });
if (Object.keys(state.errors).length) return;
dispatch({ type: 'submit_start' });
try { await onSubmit(state.values); dispatch({ type: 'submit_success' }); }
catch (e) { dispatch({ type: 'submit_error', message: String(e) }); }
}
return (
<form onSubmit={handleSubmit} noValidate>
<label>
Title
<input
value={state.values.title}
onChange={e => dispatch({ type: 'change', name: 'title', value: e.target.value })}
onBlur={() => dispatch({ type: 'blur', name: 'title' })}
aria-invalid={!!state.errors.title}
aria-describedby={state.errors.title ? 'err-title' : undefined}
/>
</label>
{state.touched.title && state.errors.title && <div id="err-title" role="alert">{state.errors.title}</div>}
<label>
Owner
<input
value={state.values.owner}
onChange={e => dispatch({ type: 'change', name: 'owner', value: e.target.value })}
onBlur={() => dispatch({ type: 'blur', name: 'owner' })}
aria-invalid={!!state.errors.owner}
aria-describedby={state.errors.owner ? 'err-owner' : undefined}
/>
</label>
{state.touched.owner && state.errors.owner && <div id="err-owner" role="alert">{state.errors.owner}</div>}
<button type="submit" disabled={state.submitting}>Create</button>
</form>
);
}
Async rules and submission
- Debounce expensive validators; run async only when field is blurred or on submit.
- Abort in-flight validation when values change rapidly.
- Surface errors near the field; announce errors via
role="alert".
SPFx specifics
- Use Fluent UI components for accessible inputs; wire them as controlled components.
- For property pane forms, marshal values through a container and update properties in batches.
- Persist drafts in session/local storage for multi-step forms.
Anti‑Patterns
- Mixing uncontrolled and controlled inputs in the same form.
- Mutating
valuesdirectly instead of reducer updates. - Running async validation on every keypress without debouncing.