React Hooks in SPFx: useState

React Hooks in SPFx: useState

Intro and SPFx-focused examples for useState, initialization patterns, and avoiding unnecessary re-renders.

Why useState in SPFx

useState is perfect for web parts and extension UI where you need to track local UI state (form fields, toggles, small objects). Keep state minimal, derive when possible, and prefer immutable updates.

Best Practices

1) Prefer functional updates for derived next state

const [count, setCount] = useState(0);

function increment() {
	// Good: derived from previous value, safe under concurrent updates
	setCount(prev => prev + 1);
}

2) Keep state minimal and immutable

interface UiState {
	theme: 'light' | 'dark';
	busy: boolean;
}

const [ui, setUi] = useState<UiState>({ theme: 'light', busy: false });
const setTheme = (next: UiState['theme']) => setUi(prev => ({ ...prev, theme: next }));

3) Avoid duplicating derived data in state

Compute derived values in render or with useMemo instead of putting them in state.

const [items, setItems] = useState<string[]>([]);
// Avoid: const [count, setCount] = useState(items.length);
const count = items.length; // derive

4) Use useReducer when state shape gets complex

If you find yourself batching many useState calls or updating nested objects frequently, switch to useReducer for clarity and predictable transitions.

Anti‑Patterns (what to avoid)

❌ Mutating state objects

const [ui, setUi] = useState({ theme: 'light', busy: false });
// Bad: mutation — React won't detect changes reliably
ui.theme = 'dark';
setUi(ui);

Use immutable updates instead:

setUi(prev => ({ ...prev, theme: 'dark' }));

❌ Assuming synchronous updates

const [count, setCount] = useState(0);
setCount(count + 1);
console.log(count); // Still old value in the same tick

React batches updates. Read the current value in effects or handlers via functional updates:

setCount(prev => prev + 1);

❌ Storing large or frequently changing computed data

Large arrays or expensive computations should live outside state. Keep state to minimal inputs and compute outputs on demand.

Using useState with async

Track loading and error alongside your data, and guard against updates after unmount using AbortController or a mounted flag.

import { useEffect, useState } from 'react';

type Item = { id: number; title: string };
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
	const ac = new AbortController();
	const run = async () => {
		try {
			setLoading(true);
			setError(null);
			const res = await fetch(`${siteUrl}/_api/web/lists/getbytitle('Tasks')/items`, { signal: ac.signal });
			if (!res.ok) throw new Error(`HTTP ${res.status}`);
			const data = await res.json();
			setItems(data.value as Item[]);
		} catch (e: any) {
			if (e.name !== 'AbortError') setError(e.message ?? 'Unknown error');
		} finally {
			setLoading(false);
		}
	};
	run();
	return () => ac.abort(); // prevent setState after unmount
}, []);

UI rendering can read loading/error safely:

if (loading) return <Spinner />;
if (error) return <MessageBar intent="error">{error}</MessageBar>;
return <ItemsList items={items} />;

Cancellation token pattern (unmount-safe)

Wrap the fetch logic into a small hook that always attaches an AbortController and cancels on unmount. This ensures setState calls never run after the component is gone.

import { useEffect, useState } from 'react';

export function useCancelableFetch<T>(url: string, deps: any[] = []) {
	const [data, setData] = useState<T | null>(null);
	const [loading, setLoading] = useState(false);
	const [error, setError] = useState<string | null>(null);

	useEffect(() => {
		const ac = new AbortController();
		async function run() {
			try {
				setLoading(true);
				setError(null);
				const res = await fetch(url, { signal: ac.signal });
				if (!res.ok) throw new Error(`HTTP ${res.status}`);
				const json = await res.json();
				setData(json as T);
			} catch (e: any) {
				if (e.name !== 'AbortError') setError(e.message ?? 'Unknown error');
			} finally {
				setLoading(false);
			}
		}
		run();
		return () => ac.abort();
	}, deps);

	return { data, loading, error };
}

Usage in SPFx:

const { data, loading, error } = useCancelableFetch<{ value: { id: number; title: string }[] }>(
	`${siteUrl}/_api/web/lists/getbytitle('Tasks')/items`,
	[]
);

if (loading) return <Spinner />;
if (error) return <MessageBar intent="error">{error}</MessageBar>;
return <ItemsList items={data?.value ?? []} />;

Async property pane changes in SPFx

For property changes that trigger async work (e.g., loading options), keep a small busy flag and use functional updates:

const [busy, setBusy] = useState(false);
const [options, setOptions] = useState<string[]>([]);

async function onLoadOptions() {
	setBusy(true);
	try {
		const res = await fetch(`${siteUrl}/_api/...`);
		const data = await res.json();
		setOptions(prev => [...prev, ...data.value]);
	} finally {
		setBusy(false);
	}
}

Performance tips

  • Minimal state: store inputs, not large derived outputs.
  • Immutable updates to avoid accidental re-renders and bugs.
  • Functional updates under concurrent rendering ensure correctness.
  • Consider useCallback for hot handlers and useMemo for heavy derived values.