Context, Generics & Component Patterns

Expert · 20 min read · ▶ live playground · ✦ checkpoint

You now have typed props and typed hooks. The last React lesson connects those pieces into larger patterns: context for shared values, generic components for reusable inputs, event handlers for forms, and a quick look at how framework files make type boundaries visible.

This is a tool-shaped React lesson. The TSX examples are read-only reference code for a real React project; the live playground uses plain TypeScript to prove the generic option relationship without JSX.

Context without pretending a default exists

Context is a way to pass a value through a subtree without threading the same prop through every component. The type problem is the default value. If there is no meaningful default, do not invent one and do not silence TypeScript. Model the missing provider case, then hide that check inside a custom hook.

Read-only reference code:

import { createContext, useContext, useState, type ReactNode } from "react";
 
type Theme = "light" | "dark";
 
type ThemeContextValue = {
  theme: Theme;
  setTheme: (theme: Theme) => void;
};
 
const ThemeContext = createContext<ThemeContextValue | null>(null);
 
type ThemeProviderProps = {
  children: ReactNode;
};
 
export function ThemeProvider({ children }: ThemeProviderProps) {
  const [theme, setTheme] = useState<Theme>("light");
  const value: ThemeContextValue = { theme, setTheme };
 
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
 
export function useTheme() {
  const value = useContext(ThemeContext);
 
  if (value === null) {
    throw new Error("useTheme must be used inside ThemeProvider");
  }
 
  return value;
}
 
export function ThemeToggle() {
  const { theme, setTheme } = useTheme();
  const nextTheme: Theme = theme === "light" ? "dark" : "light";
 
  return (
    <button type="button" onClick={() => setTheme(nextTheme)}>
      Switch to {nextTheme}
    </button>
  );
}

The context itself admits the truth: before a provider exists, the value can be null. The custom hook turns that into a safe component boundary. Components that call useTheme() get a ThemeContextValue, not ThemeContextValue | null, because the hook either returns a real value or throws immediately with a useful message.

The shortcut to avoid is pretending the default exists. A non-null assertion on a null context default tells TypeScript to stop asking questions, but it does not create a provider at runtime. The provider/hook pattern keeps both sides honest: the type says the default may be missing, and the hook handles that missing case once.

Keep context values small and intentional. Theme, current session, feature flags, and a cart summary are reasonable candidates because many components may need the same value. A text input's draft value is usually not a context value; it belongs near the form that owns it. Context is a transport mechanism, not a replacement for thinking about ownership.

Also notice that setTheme is part of the context type. That is a design choice. Some contexts expose both state and actions; others expose read-only values and keep updates inside a provider component. The type should make that choice obvious to consumers.

Generic components preserve relationships

A generic component earns its type parameter when two props must agree. A select is the classic example: the current value, the list of options, and the onChange callback should all use the same value type.

You can see the relationship without React:

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

Now the same relationship becomes a read-only React component:

import { useState } from "react";
 
type SelectOption<T extends string> = {
  value: T;
  label: string;
};
 
type SelectProps<T extends string> = {
  label: string;
  value: T;
  options: readonly SelectOption<T>[];
  onChange: (value: T) => void;
};
 
function Select<T extends string>({ label, value, options, onChange }: SelectProps<T>) {
  return (
    <label>
      {label}
      <select
        value={value}
        onChange={(event) => {
          const selected = options.find((option) => option.value === event.currentTarget.value);
          if (selected !== undefined) {
            onChange(selected.value);
          }
        }}
      >
        {options.map((option) => (
          <option key={option.value} value={option.value}>
            {option.label}
          </option>
        ))}
      </select>
    </label>
  );
}
 
type Priority = "low" | "normal" | "urgent";
 
const priorityOptions = [
  { value: "low", label: "Low" },
  { value: "normal", label: "Normal" },
  { value: "urgent", label: "Urgent" },
] satisfies readonly SelectOption<Priority>[];
 
export function PriorityField() {
  const [priority, setPriority] = useState<Priority>("normal");
 
  return <Select label="Priority" value={priority} options={priorityOptions} onChange={setPriority} />;
}

The generic parameter is inferred from the props. The parent does not write <Select<Priority>>; it passes a Priority value, Priority options, and a setter that accepts Priority. The component keeps those pieces tied together.

Notice the event handler does not cast the browser string to T. A <select> event gives you a string at runtime. The component checks that string against the known options, then calls onChange with the matched option value. That keeps the generic promise connected to runtime evidence.

This is the difference between a generic component and a component with a few broad props. A broad select might accept value: string and onChange: (value: string) => void, which works until a parent accidentally mixes project ids, plan names, and priority values in the same code path. The generic version does not know what a priority is, but it preserves the relationship the parent brings to it.

Reach for this pattern when the component is reusable and the caller owns the domain type. Do not make every component generic just because you can. A PriorityBadge can be specific to Priority; a shared Select should stay generic because many domains can use the same rendering behavior.

Forms are event boundaries

React event handlers are another boundary between browser strings and your typed state. The handler type can usually be inferred inline, but named handlers should annotate the event they receive.

Read-only reference code:

import { useState, type ChangeEvent, type FormEvent } from "react";
 
type Plan = "free" | "team";
 
type SignupDraft = {
  email: string;
  plan: Plan;
};
 
function isPlan(value: string): value is Plan {
  return value === "free" || value === "team";
}
 
export function SignupForm() {
  const [draft, setDraft] = useState<SignupDraft>({
    email: "",
    plan: "free",
  });
 
  function updateEmail(event: ChangeEvent<HTMLInputElement>) {
    setDraft((current) => ({
      ...current,
      email: event.currentTarget.value,
    }));
  }
 
  function updatePlan(event: ChangeEvent<HTMLSelectElement>) {
    const value = event.currentTarget.value;
    if (!isPlan(value)) {
      return;
    }
 
    setDraft((current) => ({
      ...current,
      plan: value,
    }));
  }
 
  function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    console.log("signup", draft.email, draft.plan);
  }
 
  return (
    <form onSubmit={submit}>
      <label>
        Email
        <input value={draft.email} onChange={updateEmail} />
      </label>
      <label>
        Plan
        <select value={draft.plan} onChange={updatePlan}>
          <option value="free">Free</option>
          <option value="team">Team</option>
        </select>
      </label>
      <button type="submit">Create account</button>
    </form>
  );
}

The important property is currentTarget, not target. currentTarget is the element the handler is attached to, so a ChangeEvent<HTMLInputElement> gives you an input-shaped currentTarget. The browser still supplies strings, though. The plan select uses isPlan to turn a runtime string into the Plan union before it enters state.

This is the same boundary rule from DOM and forms earlier in Part V: controls produce strings; your app state should receive checked domain values.

Inline handlers can rely on inference when the JSX is simple. Named handlers are different: once you pull the function out of the JSX, annotate the event parameter so the function remains understandable away from the element that calls it. That annotation is not busywork. It documents which element the handler expects and gives currentTarget the right shape.

Submit handlers have their own job. They prevent the browser's default form navigation, then hand off already-modeled state. They should not be a pile of unchecked reads from random elements if the component has been maintaining typed state all along.

Framework files are type boundaries too

Frameworks such as Next.js add file conventions around React components. The TypeScript habit stays the same: type the boundary where the framework hands your code values, then pass checked data into components.

Read-only reference code:

type ProductPageProps = {
  params: Promise<{
    slug: string;
  }>;
  searchParams?: Promise<{
    ref?: string | string[];
  }>;
};
 
type ProductSummaryProps = {
  name: string;
  slug: string;
  referral?: string;
};
 
function ProductSummary({ name, slug, referral }: ProductSummaryProps) {
  return (
    <article>
      <h1>{name}</h1>
      <p>Slug: {slug}</p>
      {referral === undefined ? null : <p>Referral: {referral}</p>}
    </article>
  );
}
 
export default async function ProductPage({ params, searchParams }: ProductPageProps) {
  const { slug } = await params;
  const query = searchParams === undefined ? {} : await searchParams;
  const referral = typeof query.ref === "string" ? query.ref : undefined;
 
  const product = {
    name: "TypeScript Workshop",
    slug,
  };
 
  return <ProductSummary name={product.name} slug={product.slug} referral={referral} />;
}

Real Next projects may provide generated helpers such as PageProps<'/products/[slug]'> for route props. This reference code writes the promise shape directly so the boundary is visible without relying on generated globals. Other framework projects may provide helper types for routes, metadata, loaders, actions, server-only files, or client-only files. Use those helpers when the project has them, but do not let framework vocabulary blur the boundary. Route params are strings until parsed. Search params are optional until checked. Data loaded on the server should still match the props you hand to client components.

Server-oriented React files also make the erasure rule feel practical again. TypeScript can describe the props a framework gives you and the props you pass down, but it does not validate a URL segment, a cookie, or a database row by itself. The Section 13 boundary rule still applies: unknown or external data must be checked before you treat it as trusted app data.

That closes the React section. Next, the course moves to TypeScript on the server, where the same boundary discipline shows up in Node entry points, API handlers, environment variables, and database rows.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion