TypeScript React Hooks — Complete Guide
In this tutorial, you will learn about TypeScript React Hooks. We cover key concepts, practical examples, and best practices to help you master this topic.
React hooks with TypeScript provide compile-time safety for state, effects, refs, and reducers — ensuring your React state is always accessed and updated with the correct types throughout your component.
What You'll Learn
- Typed useState with inferred and explicit types
- useEffect with proper cleanup typing
- useRef for DOM and mutable values
- useReducer with discriminated unions
- Custom hooks with generic return types
Why It Matters
React hooks are functions — and like all functions, TypeScript makes them safer. Typed state prevents accidental type mismatches (setCount("hello") when count should be number). Typed refs prevent null access errors.
Real-World Use
DodaTech's Durga Antivirus Pro dashboard uses typed useReducer with a discriminated union for scan state transitions — the reducer cannot produce an invalid state because TypeScript enforces which actions are valid at each state.
Learning Path
flowchart LR A[React Components] --> B[React Hooks] B --> C[React Events] B --> D[You Are Here] C --> E[React Context] E --> F[State Management]
useState
TypeScript infers state types from initial values:
const [count, setCount] = useState(0); // type: number
const [name, setName] = useState(''); // type: string
const [isActive, setIsActive] = useState(false); // type: boolean
Explicit Type Parameter
For state that starts as null or has a union type:
interface User { id: string; name: string; }
const [user, setUser] = useState<User | null>(null);
// user: User | null
// Later
setUser({ id: '1', name: 'Alice' }); // OK
setUser(null); // OK — clearing state
setUser({ id: '1' }); // Error: missing name property
Complex State
interface ScanState {
status: 'idle' | 'scanning' | 'complete' | 'error';
progress: number;
threats: string[];
error?: string;
}
const [scan, setScan] = useState<ScanState>({
status: 'idle',
progress: 0,
threats: [],
});
// Update specific fields
setScan(prev => ({ ...prev, status: 'scanning', progress: 50 }));
useEffect
TypeScript infers the return type of the effect callback for cleanup:
useEffect(() => {
const subscription = someAPI.subscribe(data => {
setData(data);
});
// Return type must be a function (cleanup) or void
return () => {
subscription.unsubscribe();
};
}, []);
Async Effects
useEffect(() => {
const controller = new AbortController();
async function fetchData() {
try {
const response = await fetch('/api/data', { signal: controller.signal });
const data = await response.json();
setData(data);
} catch (err) {
if (!(err instanceof DOMException && err.name === 'AbortError')) {
setError(err);
}
}
}
fetchData();
return () => controller.abort();
}, []);
useRef
DOM Refs
function InputField() {
const inputRef = useRef<HTMLInputElement>(null!);
// null! is a non-null assertion — we know it will be assigned
useEffect(() => {
inputRef.current?.focus(); // OK — current is HTMLInputElement
}, []);
return <input ref={inputRef} />;
}
Mutable Value Refs
function Timer() {
const intervalRef = useRef<number | null>(null);
// intervalRef.current: number | null
const startTimer = () => {
intervalRef.current = window.setInterval(() => {
console.log('tick');
}, 1000);
};
const stopTimer = () => {
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
useEffect(() => () => stopTimer(), []);
return <button onClick={stopTimer}>Stop</button>;
}
useReducer
interface ScanState {
status: 'idle' | 'scanning' | 'complete' | 'error';
progress: number;
threats: string[];
error?: string;
}
type ScanAction =
| { type: 'START_SCAN' }
| { type: 'UPDATE_PROGRESS'; progress: number }
| { type: 'THREAT_FOUND'; threat: string }
| { type: 'SCAN_COMPLETE' }
| { type: 'SCAN_ERROR'; error: string };
function scanReducer(state: ScanState, action: ScanAction): ScanState {
switch (action.type) {
case 'START_SCAN':
return { ...state, status: 'scanning', progress: 0, threats: [] };
case 'UPDATE_PROGRESS':
return { ...state, progress: action.progress };
case 'THREAT_FOUND':
return { ...state, threats: [...state.threats, action.threat] };
case 'SCAN_COMPLETE':
return { ...state, status: 'complete', progress: 100 };
case 'SCAN_ERROR':
return { ...state, status: 'error', error: action.error };
default:
return state;
}
}
function ScanDashboard() {
const [state, dispatch] = useReducer(scanReducer, {
status: 'idle',
progress: 0,
threats: [],
});
// dispatch({ type: 'START_SCAN' }); // OK
// dispatch({ type: 'UPDATE_PROGRESS', progress: 50 }); // OK
// dispatch({ type: 'UPDATE_PROGRESS' }); // Error: missing progress
}
Custom Hooks
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setValue = (value: T | ((prev: T) => T)) => {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
};
return [storedValue, setValue];
}
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
fetch(url, { signal: controller.signal })
.then(res => res.json())
.then((data: T) => {
setData(data);
setLoading(false);
})
.catch(err => {
if (err.name !== 'AbortError') {
setError(err.message);
setLoading(false);
}
});
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
// Usage
interface ScanResult {
id: string;
threats: string[];
}
const { data, loading } = useFetch<ScanResult>('/api/scan/123');
Common Mistakes
1. Forgetting State Type Parameter for Null Initial Values
const [user, setUser] = useState(null); // inferred as null — can never change!
const [user, setUser] = useState<User | null>(null); // correct
2. Not Providing Generic Type for Custom Hooks
Always make custom hooks generic when the data type varies.
3. Using useRef Without Initial Value
const ref = useRef<HTMLDivElement>(); // ref.current: HTMLDivElement | undefined
const ref = useRef<HTMLDivElement>(null!); // ref.current: HTMLDivElement
4. Not Handling AbortController in useEffect Cleanup
Always abort pending requests to prevent memory leaks and state updates on unmounted components.
5. Not Using Discriminated Unions with useReducer
Without discriminated unions, you can dispatch actions with missing or wrong payloads.
Practice Questions
How does useState infer types? From the initial value argument.
useState(0)infersnumber.What is the difference between
useRef<HTMLDivElement>(null!)anduseRef<HTMLDivElement | null>(null)? The first asserts non-null (you promise to assign it before use). The second includes null in the type.Why use discriminated unions with useReducer? They ensure only valid action shapes can be dispatched, catching missing or wrong payloads at compile time.
How do you type an async fetch in a custom hook? Use a generic type parameter for the response data type:
useFetch<T>(url: string).
Challenge: Create a useDebounce<T> custom hook that debounces a value. It should accept a value and delay, and return the debounced value. Make it generic.
FAQ
Mini Project: useApi Hook
// hooks/useApi.ts
interface ApiState<T> {
data: T | null;
loading: boolean;
error: string | null;
}
function useApi<T>(url: string, options?: RequestInit): ApiState<T> & { refetch: () => void } {
const [state, setState] = useState<ApiState<T>>({
data: null,
loading: true,
error: null,
});
const fetchData = useCallback(async () => {
setState(prev => ({ ...prev, loading: true }));
try {
const response = await fetch(url, options);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
setState({ data, loading: false, error: null });
} catch (err) {
setState({ data: null, loading: false, error: (err as Error).message });
}
}, [url]);
useEffect(() => { fetchData(); }, [fetchData]);
return { ...state, refetch: fetchData };
}
// Usage
const { data, loading, error, refetch } = useApi<ScanResult[]>('/api/scans');
What's Next
Now explore event handling with TypeScript:
| Lesson | Description |
|---|---|
| {{< ref "/programming-languages/typescript/37-react-components" >}} | Review typed components |
| {{< ref "/programming-languages/typescript/39-react-events" >}} | Event types, forms, input handlers |
| {{< ref "/programming-languages/typescript/40-react-context" >}} | Typed context and providers |
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro