learn.colinkim.dev

Hooks, events, and state

Learn how TypeScript works with React hooks, event handlers, and state management.

React hooks interact with TypeScript’s type system in predictable ways. Each hook has generic parameters that control its types.

useState

useState infers the type from the initial value:

const [count, setCount] = useState(0);
// count is number, setCount accepts number

const [name, setName] = useState("Colin");
// name is string

For nullable or union state, specify the type explicitly:

const [user, setUser] = useState<User | null>(null);
// user is User | null

const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
// status is the literal union

useRef

useRef has two forms. With an initial value, it infers the type:

const count = useRef(0);
// count.current is number

With null as the initial value (for DOM refs), specify the type:

const inputRef = useRef<HTMLInputElement>(null);
// inputRef.current is HTMLInputElement | null

function focusInput() {
  inputRef.current?.focus();  // optional chaining — current may be null
}

useReducer

useReducer is where discriminated unions shine. The reducer’s action type drives state transitions:

type State = 
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: User[] }
  | { status: "error"; message: string };

type Action =
  | { type: "fetch" }
  | { type: "success"; data: User[] }
  | { type: "error"; message: string };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "fetch":
      return { status: "loading" };
    case "success":
      return { status: "success", data: action.data };
    case "error":
      return { status: "error", message: action.message };
    default:
      return state;
  }
}

const [state, dispatch] = useReducer(reducer, { status: "idle" });

TypeScript narrows action in each case block and narrows state based on state.status.

Event handlers

React provides typed event handler interfaces:

function Form() {
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    // ...
  };

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    console.log(e.target.value);
  };

  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    console.log(e.clientX, e.clientY);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input onChange={handleChange} />
      <button onClick={handleClick}>Submit</button>
    </form>
  );
}

The generic parameter (HTMLFormElement, HTMLInputElement, etc.) specifies which element the event comes from.

Context typing

React Context requires the type of the value it provides:

interface AuthContextValue {
  user: User | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
}

const AuthContext = createContext<AuthContextValue | null>(null);

function useAuth(): AuthContextValue {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error("useAuth must be used within AuthProvider");
  }
  return context;
}

The createContext<AuthContextValue | null>(null) pattern sets the initial value to null and uses the hook to throw if the context is not provided.

API state

Fetching data with typed state:

type ApiState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; message: string };

function useApi<T>(url: string): ApiState<T> {
  const [state, setState] = useState<ApiState<T>>({ status: "idle" });

  const fetch = async () => {
    setState({ status: "loading" });
    try {
      const response = await fetch(url);
      const data: T = await response.json();  // should validate here
      setState({ status: "success", data });
    } catch (error) {
      setState({
        status: "error",
        message: error instanceof Error ? error.message : "Unknown error",
      });
    }
  };

  return state;
}

The generic ApiState<T> works for any data type. The state is a discriminated union — consumers can switch on status and get the right data in each branch.

What to carry forward

  • useState infers from initial value; specify type explicitly for unions and null
  • useRef for DOM elements needs the element type and null initial value
  • useReducer pairs naturally with discriminated unions for state machines
  • React event types are generic: React.FormEvent<T>, React.ChangeEvent<T>
  • context needs both the value type and a null default
  • generic API state with discriminated unions is a powerful reusable pattern

The next lesson covers the backend track — Node.js, request/response types, and typed errors.

Progress

Quick checks

No quick checks in this lesson.

Mark lesson manually or answer quick checks to track progress.