React Hooks in SPFx: useReducer

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.