React Hooks in SPFx: useReducer
When and how to use useReducer in SPFx, with examples like form validation.
Why useReducer in SPFx
Use useReducer when state has multiple fields with coordinated updates, or when transitions depend on action semantics. It improves clarity and testability over many useState calls.
Reducer Basics
type FormState = { title: string; busy: boolean; error: string | null };
type Action =
| { type: 'setTitle'; value: string }
| { type: 'submitStart' }
| { type: 'submitSuccess' }
| { type: 'submitError'; message: string };
function reducer(state: FormState, action: Action): FormState {
switch (action.type) {
case 'setTitle': return { ...state, title: action.value };
case 'submitStart': return { ...state, busy: true, error: null };
case 'submitSuccess': return { ...state, busy: false };
case 'submitError': return { ...state, busy: false, error: action.message };
default: return state;
}
}
const [state, dispatch] = useReducer(reducer, { title: '', busy: false, error: null });
Best Practices
1) Keep reducer pure (no side-effects)
Reducers must be synchronous and pure: compute next state from (state, action) without I/O.
2) Define clear action types
Use descriptive types with payloads. Prefer a small, expressive set over many ambiguous actions.
3) Immutable updates
Always return new objects/arrays; never mutate the existing state.
Anti‑Patterns
❌ Side-effects inside reducer
Avoid fetch/logging/timers in reducers. Do side-effects in event handlers or effects and dispatch results.
❌ Mutating nested objects
function reducer(state: any, action: any) {
state.profile.name = 'X'; // mutation — wrong
return state;
}
Fix with immutable copies:
return { ...state, profile: { ...state.profile, name: 'X' } };
Async patterns with useReducer
Dispatch lifecycle actions around async calls.
async function handleSubmit() {
dispatch({ type: 'submitStart' });
try {
const res = await fetch(`${siteUrl}/_api/...`, { method: 'POST', body: JSON.stringify({ title: state.title }) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
dispatch({ type: 'submitSuccess' });
} catch (e: any) {
dispatch({ type: 'submitError', message: e.message ?? 'Unknown error' });
}
}
With cancellation:
async function handleSubmit() {
const ac = new AbortController();
dispatch({ type: 'submitStart' });
try {
const res = await fetch(`${siteUrl}/_api/...`, { method: 'POST', signal: ac.signal });
dispatch({ type: 'submitSuccess' });
} catch (e: any) {
if (e.name !== 'AbortError') dispatch({ type: 'submitError', message: e.message });
}
return () => ac.abort();
}
SPFx form example
const [state, dispatch] = useReducer(reducer, { title: '', busy: false, error: null });
function onTitleChange(e: React.ChangeEvent<HTMLInputElement>) {
dispatch({ type: 'setTitle', value: e.target.value });
}
async function onSave() {
dispatch({ type: 'submitStart' });
try {
const res = await fetch(`${siteUrl}/_api/web/lists/getbytitle('Tasks')/items`, { method: 'POST', body: JSON.stringify({ Title: state.title }) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
dispatch({ type: 'submitSuccess' });
} catch (e: any) {
dispatch({ type: 'submitError', message: e.message ?? 'Unknown error' });
}
}
return (
<form>
<input value={state.title} onChange={onTitleChange} />
<button disabled={state.busy} onClick={onSave}>Save</button>
{state.error && <MessageBar intent="error">{state.error}</MessageBar>}
</form>
);
When to prefer Context + Reducer
For shared global state (theme/user/preferences), wrap useReducer in a context provider and consume via useContext across components.