Bonus: Patterns for Complex SPFx Forms

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 values directly instead of reducer updates.
  • Running async validation on every keypress without debouncing.

See also