TypeScript63 min total · 21 parts
TypeScript Fundamentals: Types, Interfaces, Generics, and Why It Catches Bugs Before Runtime
Part 20 of 21 · ~3 min
TypeScript with React
Most production React today is written in TypeScript, not plain JavaScript. React Fundamentals covers how components and hooks actually behave, built around its own running program — a candidate review dashboard. What belongs here instead is narrower: how the type system specifically reaches into that world. A table that renders the queue is enough to show it.
interface QueueTableProps {
applicants: Applicant[];
onSelect?: (applicant: Applicant) => void;
emptyState: React.ReactNode; // covers strings, elements, fragments — anything React is willing to render
}
function QueueTable({ applicants, onSelect, emptyState }: QueueTableProps) {
if (applicants.length === 0) return <>{emptyState}</>;
return (
<table>
<tbody>
{applicants.map((a) => (
<tr key={a.id} onClick={() => onSelect?.(a)}>
<td>{a.name}</td>
<td>{a.stage}</td>
</tr>
))}
</tbody>
</table>
);
}
Hooks are usually where the actual friction lives, more than components themselves:
// TypeScript reads the type off the initial value where it can — but that only covers
// the shape the state starts in, not every shape it'll eventually hold
const [selected, setSelected] = useState<Applicant | null>(null);
// a ref aimed at a real DOM node needs its element type spelled out, starting from null
const noteInputRef = useRef<HTMLTextAreaElement>(null);
// typed this way instead of left as an untyped inline arrow
function handleNoteChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
console.log(e.target.value); // e.target: HTMLTextAreaElement, so .value is known to exist
}
useState<Applicant | null>(null) is the shape for state that starts empty and fills in once something happens — a reviewer clicking a row, here. Drop the explicit type argument, and TypeScript locks the state's type to null — just null, nothing else — reading only what the initial value tells it, and it will never afterward let an actual Applicant be assigned there. Same widening problem from the literal-types chapter, wearing a hook instead of a const.
Generics and hooks meet directly the moment shared logic gets pulled out of a component into a custom hook. Loading one record by id and tracking whether it's still in flight is the same shape regardless of whether the record is an Applicant, a Reviewer, or something else entirely — worth writing once, generically:
function useApplicant(id: number) {
const [applicant, setApplicant] = useState<Applicant | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchApplicant(id).then((result) => {
if (!cancelled) {
setApplicant(result);
setLoading(false);
}
});
return () => { cancelled = true; }; // guards against a stale response landing after id changes
}, [id]);
return { applicant, loading };
}
function ApplicantDetail({ id }: { id: number }) {
const { applicant, loading } = useApplicant(id);
if (loading) return <Spinner />;
if (!applicant) return <p>Not found.</p>;
return <h2>{applicant.name}</h2>; // narrowed to Applicant — only reachable past the null check above
}
useApplicant's return type is entirely inferred from its own body — { applicant: Applicant | null; loading: boolean } — so every component that calls it inherits the exact same two-step narrowing the raw useState<Applicant | null>(null) pattern required earlier, without re-declaring it each time. The cancelled flag guards against exactly the stale-closure problem covered in JavaScript Core Concepts: change id before the first fetch resolves, and without that flag, the effect's own closure over the old id would go on to overwrite fresh state with a stale answer. TypeScript has nothing to say about that timing bug directly — it's a runtime sequencing issue, not a type mismatch — but it does guarantee applicant is never silently undefined somewhere the code assumed null, which is the one part of this hook a type system can actually hold anyone to.