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
useStateinfers from initial value; specify type explicitly for unions and nulluseReffor DOM elements needs the element type andnullinitial valueuseReducerpairs 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.