TypeScriptTyping Props, State & Hooks

Typing Props, State & Hooks

React's hooks are generic functions — they accept type parameters that tell the compiler exactly what shape of data to expect. Typing them correctly eliminates a whole class of runtime errors and gives you precise autocompletion everywhere state flows through your component tree.

This page covers useState, useReducer, useContext, useCallback, useMemo, and the patterns for building fully-typed custom hooks.

useState

useState infers the state type from the initial value when it is unambiguous. Provide an explicit type parameter when the initial value is null, undefined, or a union:

TSX
import { useState } from 'react';

// Inference works — initial value is a string
const [name, setName] = useState('');      // string

// Inference works — initial value is a number
const [count, setCount] = useState(0);     // number

// Must provide type — initial null means TypeScript would infer null
const [user, setUser] = useState<User | null>(null);

// Explicit type avoids narrowing issues later
const [items, setItems] = useState<string[]>([]);  // not never[]

interface User {
  id: number;
  name: string;
  email: string;
}

The setter accepts either a new value or an updater function that receives the previous state:

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

// Direct value
setCount(5);
setCount('five');  // Error: Argument of type 'string' is not assignable to parameter of type 'number'

// Updater function — receives previous state
setCount(prev => prev + 1);
setCount(prev => prev * 2);

// With objects — always spread to avoid mutation
const [user, setUser] = useState<User>({ id: 1, name: 'Alice', email: 'a@b.com' });

setUser(prev => ({ ...prev, name: 'Bob' }));  // OK — partial update pattern
Discriminated Union State

Async operations have three states: loading, success, and error. A discriminated union models this perfectly:

TSX
type AsyncState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error';   error: Error };

function UserProfile({ userId }: { userId: string }) {
  const [state, setState] = useState<AsyncState<User>>({ status: 'idle' });

  async function loadUser() {
    setState({ status: 'loading' });
    try {
      const user = await fetchUser(userId);
      setState({ status: 'success', data: user });
    } catch (err) {
      setState({ status: 'error', error: err instanceof Error ? err : new Error(String(err)) });
    }
  }

  if (state.status === 'idle')    return <button onClick={loadUser}>Load</button>;
  if (state.status === 'loading') return <div>Loading...</div>;
  if (state.status === 'error')   return <div>Error: {state.error.message}</div>;

  // TypeScript narrows to { status: 'success'; data: User }
  return <div>{state.data.name}</div>;
}
Success
Discriminated unions eliminate the boolean flag anti-pattern (isLoading, isError). TypeScript enforces that you handle every case.
useReducer

useReducer is ideal for complex state with multiple related transitions. Type the state and action union explicitly:

TSX
interface CartItem {
  id:       string;
  name:     string;
  price:    number;
  quantity: number;
}

interface CartState {
  items:    CartItem[];
  coupon:   string | null;
  isOpen:   boolean;
}

// Discriminated union of all possible actions
type CartAction =
  | { type: 'ADD_ITEM';    payload: CartItem }
  | { type: 'REMOVE_ITEM'; payload: { id: string } }
  | { type: 'UPDATE_QTY';  payload: { id: string; quantity: number } }
  | { type: 'APPLY_COUPON'; payload: { code: string } }
  | { type: 'TOGGLE_CART' }
  | { type: 'CLEAR' };

const initialState: CartState = { items: [], coupon: null, isOpen: false };

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM':
      return { ...state, items: [...state.items, action.payload] };

    case 'REMOVE_ITEM':
      return { ...state, items: state.items.filter(i => i.id !== action.payload.id) };

    case 'UPDATE_QTY':
      return {
        ...state,
        items: state.items.map(i =>
          i.id === action.payload.id ? { ...i, quantity: action.payload.quantity } : i
        ),
      };

    case 'APPLY_COUPON':
      return { ...state, coupon: action.payload.code };

    case 'TOGGLE_CART':
      return { ...state, isOpen: !state.isOpen };

    case 'CLEAR':
      return initialState;
  }
}

function Cart() {
  const [state, dispatch] = useReducer(cartReducer, initialState);

  return (
    <div>
      <button onClick={() => dispatch({ type: 'TOGGLE_CART' })}>
        Cart ({state.items.length})
      </button>
      {/* TypeScript errors if payload is wrong shape */}
      <button onClick={() => dispatch({ type: 'ADD_ITEM', payload: { id: '1', name: 'Widget', price: 9.99, quantity: 1 } })}>
        Add Item
      </button>
    </div>
  );
}
Tip
TypeScript narrows the action parameter in each case branch, so action.payload has exactly the right type in every case — no manual casting needed.
useContext

A common mistake is creating a context with null as the default, which makes the type T | null everywhere. A safer pattern uses a custom hook that throws when used outside the provider:

TSX
import { createContext, useContext, useState, ReactNode } from 'react';

interface ThemeContextValue {
  theme:       'light' | 'dark';
  toggleTheme: () => void;
}

// null as default — the context has no value until the Provider renders
const ThemeContext = createContext<ThemeContextValue | null>(null);

// Custom hook — throws a helpful error if used outside Provider
function useTheme(): ThemeContextValue {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error('useTheme must be used inside <ThemeProvider>');
  return ctx;
}

function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');

  const value: ThemeContextValue = {
    theme,
    toggleTheme: () => setTheme(t => t === 'light' ? 'dark' : 'light'),
  };

  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

// Consumer — no null checks needed
function ThemeToggle() {
  const { theme, toggleTheme } = useTheme();  // type is ThemeContextValue, never null

  return (
    <button onClick={toggleTheme}>
      Current: {theme}
    </button>
  );
}
useCallback and useMemo

TSX
import { useCallback, useMemo } from 'react';

interface Product { id: number; name: string; price: number; category: string; }

function ProductList({ products }: { products: Product[] }) {
  const [filter, setFilter] = useState('');

  // useMemo — return type is inferred from the callback
  const filtered = useMemo(
    () => products.filter(p => p.name.toLowerCase().includes(filter.toLowerCase())),
    [products, filter]
  );
  // filtered: Product[]

  // useCallback — parameter and return types are inferred
  const handleSearch = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      setFilter(e.target.value);
    },
    []
  );
  // handleSearch: (e: React.ChangeEvent<HTMLInputElement>) => void

  return (
    <div>
      <input onChange={handleSearch} placeholder="Search..." />
      {filtered.map(p => <div key={p.id}>{p.name} — ${p.price}</div>)}
    </div>
  );
}
Custom Hooks

Custom hooks are regular functions that start with use. They should explicitly type their return values for clarity:

TSX
// Generic fetch hook
interface UseFetchResult<T> {
  data:    T | null;
  loading: boolean;
  error:   Error | null;
  refetch: () => void;
}

function useFetch<T>(url: string): UseFetchResult<T> {
  const [data,    setData]    = useState<T | null>(null);
  const [loading, setLoading] = useState(false);
  const [error,   setError]   = useState<Error | null>(null);

  const fetchData = useCallback(async () => {
    setLoading(true);
    setError(null);
    try {
      const res  = await fetch(url);
      if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
      const json = await res.json() as T;
      setData(json);
    } catch (e) {
      setError(e instanceof Error ? e : new Error(String(e)));
    } finally {
      setLoading(false);
    }
  }, [url]);

  useEffect(() => { fetchData(); }, [fetchData]);

  return { data, loading, error, refetch: fetchData };
}

// Usage — T is provided at the call site
function UserPage({ userId }: { userId: string }) {
  const { data: user, loading, error } = useFetch<User>(`/api/users/${userId}`);

  if (loading) return <div>Loading...</div>;
  if (error)   return <div>Error: {error.message}</div>;
  if (!user)   return null;

  return <div>{user.name}</div>;
}
useLocalStorage — Persisted State

TSX
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? (JSON.parse(item) as T) : initialValue;
    } catch {
      return initialValue;
    }
  });

  const setValue = useCallback((value: T) => {
    try {
      setStoredValue(value);
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch (err) {
      console.error('useLocalStorage error:', err);
    }
  }, [key]);

  return [storedValue, setValue];
}

// Usage
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
const [user,  setUser]  = useLocalStorage<User | null>('user', null);
Typing Component Props Patterns

Some common prop patterns and how to type them:

TSX
// Render prop pattern
interface DataLoaderProps<T> {
  url:    string;
  render: (data: T) => ReactNode;
}

function DataLoader<T>({ url, render }: DataLoaderProps<T>) {
  const { data } = useFetch<T>(url);
  if (!data) return null;
  return <>{render(data)}</>;
}

<DataLoader<User[]>
  url="/api/users"
  render={(users) => users.map(u => <span key={u.id}>{u.name}</span>)}
/>

// Controlled vs uncontrolled — use type union
interface InputProps {
  // Controlled
  value?:    string;
  onChange?: (value: string) => void;
  // Uncontrolled
  defaultValue?: string;
}

// Spread native props — extend the HTML element's type
interface TextFieldProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
  error?: string;
}

function TextField({ label, error, ...inputProps }: TextFieldProps) {
  return (
    <div>
      <label>{label}</label>
      <input {...inputProps} />
      {error && <span className="error">{error}</span>}
    </div>
  );
}
Avoiding Common Typing Mistakes

Mistake

Problem

Fix

useState([])

Inferred as never[]

useState<string[]>([])

useState(null)

Inferred as null, not T | null

useState<User | null>(null)

React.FC<Props>

Implicit children, historical baggage

function Comp({ }: Props) { }

any in event handlers

Loses type safety

React.ChangeEvent<HTMLInputElement>

useContext without null guard

Type is T | null everywhere

Custom hook that throws outside provider

Mutating state directly

React does not re-render

Always return new object/array

Key Takeaways
  1. useState<T> — provide explicit type when initial value is null, undefined, or a union

  2. Discriminated unions model async state far better than boolean flags

  3. useReducer — type the state interface and a discriminated union for all actions

  4. useContext — pair with a custom hook that throws when used outside the provider

  5. Custom hooks should explicitly declare their return type interface

  6. Extend HTMLAttributes<T> to pass through native element props safely

  7. useCallback and useMemo infer types from the callback — rarely need explicit params

  8. Render props and generic components let you build reusable, fully-typed abstractions