- React
- Type-Safe React: Components, Generics, and Hooks
- Typing Hooks & Refs: State, Reducers & DOM Elements
Typing Hooks & Refs: State, Reducers & DOM Elements
TypeScript can infer simple hook states, but union states require explicit generics like useState<User | null>(null). Type DOM refs with useRef<HTMLDivElement>(null) for read-only element access, while mutable container refs use explicit non-null initial values. Type useReducer with strict discriminated action unions.
Key Learning Objectives
Type useState with union types, null initializers, and lazy initializer functions.
Differentiate the two TypeScript overloads of useRef: DOM Ref (RefObject) vs Mutable Value Container (MutableRefObject).
Construct strictly typed reducers using discriminated unions for actions and exhaustive switch checks.
Infer custom hook return tuples using `as const` for strict array destructuring.
The Interview Problem & Context
Demonstrate how to type useState with nullable state, explain why useRef<HTMLInputElement>(null) creates a read-only ref, and build an exhaustively typed useReducer with discriminated unions.
1import React, { useState, useRef, useReducer } from 'react';23interface User {4 id: string;5 name: string;6 email: string;7}89// 1. Explicit generic for nullable union state10export function UserProfile() {11 const [user, setUser] = useState<User | null>(null);1213 // 2. DOM Ref vs Mutable Ref14 // DOM Ref: Initial value null, type RefObject<HTMLInputElement> (current is read-only for consumer)15 const inputRef = useRef<HTMLInputElement>(null);1617 // Mutable Container Ref: Initial value matches generic, type MutableRefObject<number> (current is writable)18 const renderCountRef = useRef<number>(0);19 renderCountRef.current += 1;2021 const focusInput = () => {22 inputRef.current?.focus();23 };2425 return <div>...</div>;26}2728// 3. Exhaustively typed useReducer with Discriminated Unions29interface State {30 count: number;31 step: number;32}3334type Action =35 | { type: 'INCREMENT' }36 | { type: 'DECREMENT' }37 | { type: 'SET_STEP'; payload: number }38 | { type: 'RESET' };3940function counterReducer(state: State, action: Action): State {41 switch (action.type) {42 case 'INCREMENT':43 return { ...state, count: state.count + state.step };44 case 'DECREMENT':45 return { ...state, count: state.count - state.step };46 case 'SET_STEP':47 return { ...state, step: action.payload };48 case 'RESET':49 return { count: 0, step: 1 };50 default: {51 // Exhaustiveness check: If a new Action type is added, TS flags a compile error here!52 const _exhaustiveCheck: never = action;53 return state;54 }55 }56}
Deep Architectural Explanation
Typing Hooks & Refs
1. useState Generics:
If initial state is empty or null, TypeScript infers null unless an explicit generic is provided:
const [user, setUser] = useState<User | null>(null);2. The Two Overloads of useRef:
TypeScript defines two distinct overloads for useRef:
- DOM Ref (
RefObject<T>):useRef<HTMLInputElement>(null)currentis typed asHTMLInputElement | null.currentis read-only because React attaches and manages the DOM node during the commit phase!
- Mutable Value Container (
MutableRefObject<T>):useRef<number>(0)currentis typed asnumberand is mutable. You can writeref.current = 5.
3. Discriminated Unions for Reducers:
Each action object has a common discriminant property type. Inside each case, TypeScript automatically narrows the type of action.payload.
Adding const _exhaustive: never = action in default ensures compile-time errors if a team member adds an action type without handling it!
Common Interview Traps & Pitfalls
Calling `useState(null)` without `<Type | null>`, locking state to type `null` forever.
Typing DOM refs without `null` as initial value (`useRef<HTMLInputElement>()`), which selects the wrong mutable overload.
Typing reducer actions as `{ type: string; payload?: any }` instead of strict discriminated unions.
Senior Interviewer Follow-Up Probes
How does TypeScript infer types for `useContext` when context is initialized with `null`?
How do you write a custom hook that returns a type-safe tuple vs an object?
What is the difference between `RefObject<T>` and `ForwardedRef<T>`?
Frequently Asked Interview Questions
Why is inputRef.current read-only when initialized with null?
TypeScript treats useRef<HTMLInputElement>(null) as a DOM RefObject. In React, DOM element references are assigned and managed by React during commit, so TypeScript marks current as readonly to prevent accidental consumer mutation.
How do you make a mutable ref in TypeScript that holds null initially?
Type it as useRef<string | null>(null) or use the union type where null is included in the generic parameter without selecting the DOM overload.
What is an exhaustiveness check in a TypeScript reducer?
Assigning the action to a variable of type never in the default case (const _check: never = action). If an unhandled action type exists in the union, TypeScript throws a compile-time type mismatch error.
Why should custom hooks returning arrays use "as const"?
Without "as const", TypeScript infers an array union type like (ValueType | SetterType)[]. Using "as const" asserts a fixed tuple [ValueType, SetterType], enabling accurate type inference when destructuring.
How do you type useMemo when type inference fails?
Provide an explicit generic: useMemo<CalculatedData>(() => computeExpensiveValue(data), [data]).
