Skip to content

TypeScript React Hooks — Complete Guide

DodaTech Updated 2026-06-28 7 min read

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

  1. How does useState infer types? From the initial value argument. useState(0) infers number.

  2. What is the difference between useRef<HTMLDivElement>(null!) and useRef<HTMLDivElement | null>(null)? The first asserts non-null (you promise to assign it before use). The second includes null in the type.

  3. Why use discriminated unions with useReducer? They ensure only valid action shapes can be dispatched, catching missing or wrong payloads at compile time.

  4. 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

Do I need to type every useState call?

No. TypeScript infers from the initial value. Add explicit types only for null initial values or complex shapes.

What is `null!` in useRef?

The ! is a non-null assertion telling TypeScript "this will not be null even though it looks like it."

How do I type useReducer actions?

Use a discriminated union with a type field as a string literal.

Can I use generics in custom hooks?

Yes. Generic custom hooks are the most reusable: function useApi<T>(url: string): { data: T | null; loading: boolean }

What is the return type of useEffect?

void or a cleanup function (returning void).

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