Skip to main content
CodeOath
← All posts

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.