cheat sheet

React with TypeScript

Type-safe React patterns — function components, prop types, children, event handlers, refs, generic components, polymorphic `as` props, and Zod-powered forms.

React with TypeScript — Components, hooks, generics, and forms

What it is

React with TypeScript is the combination of Meta's UI library and Microsoft's typed JavaScript superset — together they give you compile-time guarantees about the shape of props, the type of hook state, the events your handlers receive, and the elements your refs point at. Modern React (18+) is "function components plus hooks", and the modern TS-React idiom is to annotate props with a plain type alias and let TypeScript infer everything else — no React.FC<>, no JSX.Element return annotation, no any. Alternatives in the same niche include Vue + Volar, Solid + TS, and Svelte 5 + TS, but the React + TS ecosystem (with libraries like react-hook-form, tanstack-query, zod, and radix-ui) is the broadest.

This article assumes the React fundamentals (JSX, hooks, components) covered in react-basics and focuses on the TS-specific patterns — typing props, event handlers, refs, and building reusable generic and polymorphic components.

Install

A new Vite + React + TypeScript project ships with @types/react and @types/react-dom already configured. For an existing JS-React project, install the same @types/* packages.

bash
# New project (Vite + React + TS)
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install

# Add TS types to an existing React project
npm install -D typescript @types/react @types/react-dom

Output: (none — exits 0 on success)

The Vite template ships a tsconfig.json with "jsx": "react-jsx" (the modern automatic runtime — no import React from "react" at the top of every file).

json
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noFallthroughCasesInSwitch": true
  }
}

Output: (none — exits 0 on success)

Why not React.FC<Props>?

Older TS-React tutorials used const Component: React.FC<Props> = (props) => …. The current React team docs and most style guides recommend dropping FC because: (1) it implicitly added a children prop that you may not want, (2) it broke defaultProps inference, and (3) it makes generic components much harder to write. The current idiom is to type the arguments instead.

tsx
// Old style (avoid)
import type { FC } from "react";
const Hello: FC<{ name: string }> = ({ name }) => <h1>Hello, {name}</h1>;

// Modern style (preferred)
type HelloProps = { name: string };
function Hello({ name }: HelloProps) {
  return <h1>Hello, {name}</h1>;
}

Output: (none — exits 0 on success)

You also do not need to annotate the return type. TypeScript infers JSX.Element (or ReactNode for fragments) automatically. Add : ReactNode explicitly only when the function may return null, a string, or an array.

Typing props

Props are typed with a plain type alias or interface. Prefer type for one-off prop bags and interface for component libraries (interfaces participate in declaration merging — see declaration-merging). Mark optional props with ?, give defaults via destructuring, and prefer string-literal unions over boolean flags for related variants.

tsx
type ButtonProps = {
  label: string;
  variant?: "primary" | "secondary" | "ghost";
  size?: "sm" | "md" | "lg";
  disabled?: boolean;
  onClick?: () => void;
};

export function Button({
  label,
  variant = "primary",
  size = "md",
  disabled = false,
  onClick,
}: ButtonProps) {
  return (
    <button
      className={`btn btn-${variant} btn-${size}`}
      disabled={disabled}
      onClick={onClick}
    >
      {label}
    </button>
  );
}

// Usage
<Button label="Save" variant="primary" size="lg" onClick={() => save()} />;

Output: (rendered)

text
<button class="btn btn-primary btn-lg">Save</button>

Discriminated-union props

When one prop's presence depends on another, model the choice as a discriminated union of prop shapes (see discriminated-unions). The compiler then enforces "either A and B, or C and D — never a mix".

tsx
type LinkButtonProps =
  | { as: "link"; href: string; onClick?: never }
  | { as: "button"; href?: never; onClick: () => void };

export function LinkButton(props: LinkButtonProps) {
  if (props.as === "link") {
    return <a href={props.href}>open</a>;
  }
  return <button onClick={props.onClick}>open</button>;
}

<LinkButton as="link" href="/docs" />;             // OK
<LinkButton as="button" onClick={() => {}} />;     // OK
<LinkButton as="link" onClick={() => {}} />;       // Error: onClick: never

Output: (none — exits 0 on success)

Children: ReactNode and friends

The children prop is anything renderable — text, numbers, JSX elements, arrays, false/null/undefined. The right type 99% of the time is ReactNode. Reserve ReactElement for "exactly one JSX element" and JSX.Element for legacy compatibility. There is a helper PropsWithChildren<T> that adds children: ReactNode to your props bag, but writing it out is usually clearer.

tsx
import type { ReactNode, ReactElement, PropsWithChildren } from "react";

// Explicit (clearer)
type CardProps = { title: string; children: ReactNode };
function Card({ title, children }: CardProps) {
  return (
    <section className="card">
      <h2>{title}</h2>
      <div>{children}</div>
    </section>
  );
}

// With the helper (equivalent)
type CardProps2 = PropsWithChildren<{ title: string }>;
function Card2({ title, children }: CardProps2) {
  return <section><h2>{title}</h2><div>{children}</div></section>;
}

// Exactly one child element
type WithIconProps = { icon: ReactElement; children: ReactNode };
function WithIcon({ icon, children }: WithIconProps) {
  return <span>{icon}{children}</span>;
}

Output: (none — exits 0 on success)

Render-prop and function-as-children

A render prop is just a prop typed as a function returning ReactNode. Use this pattern for "I provide state, you decide how to render it" components.

tsx
type DropdownProps = {
  options: string[];
  children: (state: { open: boolean; toggle: () => void }) => ReactNode;
};

function Dropdown({ options, children }: DropdownProps) {
  const [open, setOpen] = useState(false);
  return (
    <div className="dropdown">
      {children({ open, toggle: () => setOpen((o) => !o) })}
      {open && <ul>{options.map((o) => <li key={o}>{o}</li>)}</ul>}
    </div>
  );
}

// Caller controls the trigger UI
<Dropdown options={["one", "two"]}>
  {({ open, toggle }) => (
    <button onClick={toggle}>{open ? "Close" : "Open"}</button>
  )}
</Dropdown>;

Output: (none — exits 0 on success)

Event handlers

Every DOM event in React is a generic synthetic event — typed as React.SomethingEvent<TElement>. The two patterns you will use 95% of the time are the inline arrow function (where TS infers the event type from the JSX prop) and the named handler (where you must spell the type out).

tsx
import type { MouseEvent, ChangeEvent, FormEvent, KeyboardEvent } from "react";

function Form() {
  // Inline — TS infers e as ChangeEvent<HTMLInputElement>
  const [name, setName] = useState("");

  // Named handler — must declare the type
  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    console.log("submit", name);
  };

  const handleKey = (e: KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "Escape") setName("");
  };

  const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
    if (e.shiftKey) console.log("shift-click");
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
        onKeyDown={handleKey}
      />
      <button onClick={handleClick}>send</button>
    </form>
  );
}

Output: (none — exits 0 on success)

Event-handler types vs. event types

React exports two related families: the event types (MouseEvent<T>, ChangeEvent<T>) and the handler types (MouseEventHandler<T>, ChangeEventHandler<T>). Use the handler form when typing a prop; use the event form when typing the parameter of a function.

tsx
import type { MouseEventHandler, ChangeEventHandler } from "react";

type IconButtonProps = {
  icon: ReactNode;
  onClick: MouseEventHandler<HTMLButtonElement>;     // prop = handler
};

function onChangeAge(e: ChangeEvent<HTMLInputElement>) {  // arg = event
  console.log(e.target.value);
}

Output: (none — exits 0 on success)

Common event-target table

JSX attributeEvent typeNotes
onClickMouseEvent<T>T = target element (e.g. HTMLButtonElement)
onChange (input/textarea)ChangeEvent<HTMLInputElement>e.target.value is string
onChange (select)ChangeEvent<HTMLSelectElement>
onSubmitFormEvent<HTMLFormElement>Always call e.preventDefault()
onKeyDown / onKeyUpKeyboardEvent<T>e.key, e.code, e.shiftKey
onFocus / onBlurFocusEvent<T>
onDragStartDragEvent<T>e.dataTransfer
onWheelWheelEvent<T>e.deltaY

Typing useState

useState's type is usually inferred from the initial value — useState(0) is number, useState("hi") is string. When the type cannot be inferred (initial is null, undefined, or []), pass the type explicitly with useState<T>(...).

tsx
import { useState } from "react";

// Inferred
const [count, setCount] = useState(0);              // number
const [name, setName]  = useState("Alice Dev");    // string

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

// Explicit — initial value is an empty array
const [todos, setTodos] = useState<string[]>([]);

// Discriminated state machine
type Status =
  | { kind: "idle" }
  | { kind: "loading" }
  | { kind: "ok"; data: User }
  | { kind: "err"; message: string };

const [status, setStatus] = useState<Status>({ kind: "idle" });

Output: (none — exits 0 on success)

A discriminated-union state type prevents impossible combinations like { loading: true, data: someUser } — see discriminated-unions.

Typing useRef

useRef has two distinct shapes depending on what you pass. Get this wrong and TypeScript will reject your JSX ref={...} attribute.

Initial valueTypeUse case
useRef<HTMLInputElement>(null)RefObject<HTMLInputElement> (read-only, attaches to JSX)DOM element refs
useRef<number>(0)MutableRefObject<number> (writable)Mutable values across renders
useRef<number | null>(null)MutableRefObject<number | null>Mutable, starts unset
tsx
import { useRef, useEffect } from "react";

// DOM ref — null start, RefObject shape
function AutoFocusInput() {
  const ref = useRef<HTMLInputElement>(null);
  useEffect(() => { ref.current?.focus(); }, []);
  return <input ref={ref} />;
}

// Mutable value ref — writable, MutableRefObject shape
function Counter() {
  const renders = useRef(0);
  renders.current++;             // OK — writable
  return <p>Rendered {renders.current} times</p>;
}

// Mutable + nullable — for timer IDs
function Tick() {
  const id = useRef<ReturnType<typeof setInterval> | null>(null);
  const start = () => {
    id.current = setInterval(() => console.log("tick"), 1000);
  };
  const stop = () => {
    if (id.current) clearInterval(id.current);
    id.current = null;
  };
  return <><button onClick={start}>start</button><button onClick={stop}>stop</button></>;
}

Output: (none — exits 0 on success)

Forwarding refs

A wrapper component that exposes a child element's ref must use forwardRef. With TS, forwardRef<RefType, PropsType> declares both — the element being ref-attached and the component's own props.

tsx
import { forwardRef } from "react";
import type { ComponentPropsWithoutRef } from "react";

type InputProps = ComponentPropsWithoutRef<"input"> & {
  label: string;
};

export const LabeledInput = forwardRef<HTMLInputElement, InputProps>(
  ({ label, ...rest }, ref) => (
    <label>
      <span>{label}</span>
      <input ref={ref} {...rest} />
    </label>
  ),
);

LabeledInput.displayName = "LabeledInput";

// Usage — ref is now typed as RefObject<HTMLInputElement>
function Form() {
  const inputRef = useRef<HTMLInputElement>(null);
  return <LabeledInput ref={inputRef} label="Name" placeholder="Alice Dev" />;
}

Output: (none — exits 0 on success)

React 19 makes ref a regular prop — you can drop forwardRef and just declare ref?: Ref<HTMLInputElement> in your props. For codebases on 17–18, keep forwardRef.

Generic components

A component can accept its own type parameter — the same mechanism as a generic function (see generics). The classic example is a <List<T>> that takes an array of T and a render function that receives a T. With function components this works seamlessly; with forwardRef it requires a small cast.

tsx
type ListProps<T> = {
  items: T[];
  keyFn: (item: T) => string | number;
  render: (item: T) => ReactNode;
};

export function List<T>({ items, keyFn, render }: ListProps<T>) {
  return (
    <ul>
      {items.map((item) => (
        <li key={keyFn(item)}>{render(item)}</li>
      ))}
    </ul>
  );
}

// Usage — T is inferred from items
type User = { id: number; name: string };
const users: User[] = [
  { id: 1, name: "Alice Dev" },
  { id: 2, name: "Bob Coder" },
];

<List
  items={users}
  keyFn={(u) => u.id}
  render={(u) => <strong>{u.name}</strong>}     // u: User
/>;

Output: (rendered)

text
<ul>
  <li><strong>Alice Dev</strong></li>
  <li><strong>Bob Coder</strong></li>
</ul>

Generic select

A real-world <Select<T>> pairs a generic value type with a render-fn for each option's label and an onChange that returns the typed value — not just the string ID.

tsx
type SelectProps<T> = {
  options: T[];
  value: T;
  labelFn: (opt: T) => string;
  keyFn: (opt: T) => string;
  onChange: (next: T) => void;
};

export function Select<T>({ options, value, labelFn, keyFn, onChange }: SelectProps<T>) {
  const handle = (e: ChangeEvent<HTMLSelectElement>) => {
    const next = options.find((o) => keyFn(o) === e.target.value);
    if (next) onChange(next);
  };

  return (
    <select value={keyFn(value)} onChange={handle}>
      {options.map((o) => (
        <option key={keyFn(o)} value={keyFn(o)}>{labelFn(o)}</option>
      ))}
    </select>
  );
}

type Role = { id: string; label: string; level: number };
const roles: Role[] = [
  { id: "admin", label: "Admin", level: 3 },
  { id: "user",  label: "User",  level: 1 },
];

function RolePicker() {
  const [role, setRole] = useState<Role>(roles[0]);
  return (
    <Select
      options={roles}
      value={role}
      keyFn={(r) => r.id}
      labelFn={(r) => `${r.label} (level ${r.level})`}
      onChange={setRole}        // setRole receives a Role, not a string
    />
  );
}

Output: (rendered)

text
<select>
  <option value="admin">Admin (level 3)</option>
  <option value="user">User (level 1)</option>
</select>

Polymorphic as prop

A polymorphic component renders any HTML element (or another component) selected via an as prop, while keeping the right typed attributes for that element. <Box as="a" href="/x"> accepts href; <Box as="button" onClick={fn}> accepts onClick. This is the trick behind chakra-ui's Box and radix-ui's asChild cousins.

tsx
import type { ElementType, ComponentPropsWithoutRef } from "react";

type BoxOwnProps<E extends ElementType> = {
  as?: E;
  padding?: number;
};

type BoxProps<E extends ElementType> =
  BoxOwnProps<E> & Omit<ComponentPropsWithoutRef<E>, keyof BoxOwnProps<E>>;

export function Box<E extends ElementType = "div">({
  as,
  padding = 0,
  style,
  ...rest
}: BoxProps<E>) {
  const Component = as ?? "div";
  return <Component style={{ padding, ...style }} {...rest} />;
}

// All of these type-check correctly:
<Box padding={8}>plain div</Box>;
<Box as="a" href="/docs">link</Box>;             // href is required for "a"
<Box as="button" onClick={() => alert("hi")}>btn</Box>;
<Box as="img" src="/x.png" alt="x" />;

Output: (none — exits 0 on success)

Polymorphic components quickly get harder when you add refs — see Matt Pocock's react-polymorphic-types write-up or the react-aria source. For most apps, a non-generic Button as prop covers the 80% use case.

Component prop helpers

React's type exports include several utilities for "give me the props of element X". Pick the right one for your wrapper or extender.

HelperWhat it returns
ComponentProps<"button">All props of <button> (including ref)
ComponentPropsWithoutRef<"button">Same, minus ref (most common for spread)
ComponentPropsWithRef<"button">Same, with the right ref type
ComponentProps<typeof Button>The Props type of a custom component
tsx
import type { ComponentPropsWithoutRef, ComponentProps } from "react";

// Extend a native element's props
type IconButtonProps = ComponentPropsWithoutRef<"button"> & {
  icon: ReactNode;
};

function IconButton({ icon, children, ...rest }: IconButtonProps) {
  return <button {...rest}>{icon}{children}</button>;
}

// Get the props of *your own* component (e.g. for a wrapper)
type WrappedButtonProps = ComponentProps<typeof IconButton>;

Output: (none — exits 0 on success)

Forms with Zod + React Hook Form

The endgame for type-safe forms in React is a single source of truth — a zod schema — that produces both runtime validation and the TS type of the form values. Pair it with react-hook-form's zodResolver and the input registrations are checked against the schema's keys at compile time.

bash
npm install react-hook-form @hookform/resolvers zod

Output: (none — exits 0 on success)

tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

// 1. Schema (source of truth)
const SignupSchema = z.object({
  email: z.string().email("Invalid email"),
  password: z.string().min(8, "At least 8 chars"),
  age: z.coerce.number().int().min(13, "Must be 13+"),
  role: z.enum(["user", "admin"]),
});

// 2. Inferred TS type
type SignupValues = z.infer<typeof SignupSchema>;

// 3. Component
export function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<SignupValues>({
    resolver: zodResolver(SignupSchema),
  });

  const onSubmit = async (values: SignupValues) => {
    // values is fully typed — { email: string; password: string; age: number; role: "user" | "admin" }
    await fetch("/api/signup", { method: "POST", body: JSON.stringify(values) });
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input type="email" placeholder="Email" {...register("email")} />
      {errors.email && <p>{errors.email.message}</p>}

      <input type="password" placeholder="Password" {...register("password")} />
      {errors.password && <p>{errors.password.message}</p>}

      <input type="number" placeholder="Age" {...register("age")} />
      {errors.age && <p>{errors.age.message}</p>}

      <select {...register("role")}>
        <option value="user">User</option>
        <option value="admin">Admin</option>
      </select>

      <button disabled={isSubmitting}>Sign up</button>
    </form>
  );
}

Output: (rendered, on submit with invalid email)

text
Invalid email

Try register("emial") and TypeScript will flag the typo immediately — register is constrained to keyof SignupValues.

Context with TypeScript

createContext<T>(default) types the value held by a provider. The most common pitfall is forgetting to type the default and ending up with a null shape that every consumer has to narrow. Two clean patterns are: (1) a non-null default for primitive contexts, and (2) a tiny useXContext hook that throws when used outside a provider.

tsx
import { createContext, useContext } from "react";
import type { ReactNode } from "react";

type Theme = "light" | "dark";
type ThemeCtx = { theme: Theme; toggle: () => void };

const ThemeContext = createContext<ThemeCtx | null>(null);

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>("light");
  const value: ThemeCtx = {
    theme,
    toggle: () => setTheme((t) => (t === "light" ? "dark" : "light")),
  };
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

export function useTheme() {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error("useTheme must be used inside <ThemeProvider>");
  return ctx;
}

// Inside a component:
function Toolbar() {
  const { theme, toggle } = useTheme();           // ctx is never null here
  return <button onClick={toggle}>{theme}</button>;
}

Output: (none — exits 0 on success)

useReducer with discriminated actions

useReducer<Reducer<State, Action>> types both the state shape and the action union. Combined with a discriminated-union Action type, the reducer body gets full narrowing inside each case, and the dispatcher rejects malformed actions at the call site.

tsx
import { useReducer } from "react";

type State = { count: number; lastOp: string };

type Action =
  | { type: "inc"; by: number }
  | { type: "dec"; by: number }
  | { type: "reset" }
  | { type: "set"; value: number };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "inc":   return { count: state.count + action.by, lastOp: `+${action.by}` };
    case "dec":   return { count: state.count - action.by, lastOp: `-${action.by}` };
    case "reset": return { count: 0, lastOp: "reset" };
    case "set":   return { count: action.value, lastOp: `=${action.value}` };
    default: {
      const _exhaustive: never = action;          // compile-time exhaustiveness
      return state;
    }
  }
}

function Counter() {
  const [s, dispatch] = useReducer(reducer, { count: 0, lastOp: "" });
  return (
    <>
      <p>{s.count} ({s.lastOp})</p>
      <button onClick={() => dispatch({ type: "inc", by: 1 })}>+1</button>
      <button onClick={() => dispatch({ type: "reset" })}>reset</button>
      <button onClick={() => dispatch({ type: "set", value: 100 })}>set 100</button>
    </>
  );
}

Output: (rendered, after +1 +1 set-100)

text
100 (=100)

Custom hooks

Custom hooks are just functions whose name starts with use. Type their parameters and return value as you would any other function. If a hook returns a tuple, use as const (or annotate the return) so TS infers [T, (next: T) => void] rather than (T | (T => void))[].

tsx
import { useState, useEffect } from "react";

export function useLocalStorage<T>(key: string, initial: T) {
  const [value, setValue] = useState<T>(() => {
    const raw = localStorage.getItem(key);
    return raw ? (JSON.parse(raw) as T) : initial;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue] as const;            // [T, Dispatch<SetStateAction<T>>]
}

// Usage — TS knows count is number and setCount is (n: number) => void
const [count, setCount] = useLocalStorage("count", 0);

// And it works for any T
const [user, setUser] = useLocalStorage<{ name: string } | null>("user", null);

Output: (none — exits 0 on success)

Common pitfalls

  1. React.FC<Props> — implicit children, no generic support. Use function Foo({...}: Props) instead.
  2. useState<T>() with no argument — gives T | undefined. Pass null or a real initial value: useState<User | null>(null).
  3. useRef mutability confusionuseRef<HTMLInputElement>(null) is read-only (RefObject); useRef(0) is writable (MutableRefObject). The compiler chooses based on the initial value.
  4. Stringifying event valuese.target.value is always string. Coerce to number explicitly: Number(e.target.value) or use e.currentTarget.valueAsNumber for <input type="number">.
  5. onChange vs onInput — React's onChange fires on every keystroke (unlike DOM's onChange). Don't reach for onInput.
  6. as any for the as prop — defeats the purpose. Use the polymorphic recipe above with ElementType + ComponentPropsWithoutRef.
  7. Spreading ...rest of unknown shape — type the rest explicitly with Omit<ComponentPropsWithoutRef<"button">, keyof MyProps> to avoid any.
  8. children: JSX.Element — too narrow; rejects strings, numbers, arrays, null. Use ReactNode.
  9. Mutating state directlystate.count++ does not re-render and TypeScript will not catch it unless you mark state readonly. Always return a new object.
  10. Forgetting displayName on forwardRef — React DevTools shows <ForwardRef> instead of your component name. Set Comp.displayName = "Comp".

Real-world recipes

Typed wrapper around fetch

A common pattern is wrapping fetch with a Zod-validated response — runtime safety plus full inference on the consumer side.

tsx
import { z, ZodTypeAny } from "zod";

async function fetchJson<S extends ZodTypeAny>(
  url: string,
  schema: S,
  init?: RequestInit,
): Promise<z.infer<S>> {
  const res = await fetch(url, init);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return schema.parse(await res.json());
}

const User = z.object({ id: z.number(), name: z.string() });

// Usage — return type is inferred from the schema
const user = await fetchJson("/api/users/1", User);   // { id: number; name: string }

Output: (none — exits 0 on success)

useAsync hook with discriminated state

A small async hook that returns a discriminated-union state — narrow on state.status in JSX for type-safe rendering.

tsx
import { useEffect, useState } from "react";

type Async<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "ok"; data: T }
  | { status: "err"; error: Error };

export function useAsync<T>(fn: () => Promise<T>, deps: unknown[] = []) {
  const [state, setState] = useState<Async<T>>({ status: "idle" });

  useEffect(() => {
    let alive = true;
    setState({ status: "loading" });
    fn().then(
      (data) => alive && setState({ status: "ok", data }),
      (error) => alive && setState({ status: "err", error: error as Error }),
    );
    return () => { alive = false; };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps);

  return state;
}

// Consumer
function Profile({ id }: { id: number }) {
  const s = useAsync(() => fetchUser(id), [id]);

  if (s.status === "loading") return <p>loading…</p>;
  if (s.status === "err")     return <p>error: {s.error.message}</p>;
  if (s.status === "ok")      return <p>hello {s.data.name}</p>;
  return null;                                       // idle
}

Output: (rendered)

text
hello Alice Dev

Polymorphic <Heading> for design systems

A common design-system pattern: render <h1><h6> based on a level prop while keeping all native heading attributes.

tsx
import type { ComponentPropsWithoutRef } from "react";

type Level = 1 | 2 | 3 | 4 | 5 | 6;
type HeadingProps = ComponentPropsWithoutRef<"h1"> & { level?: Level };

export function Heading({ level = 1, ...rest }: HeadingProps) {
  const Tag = `h${level}` as const;
  return <Tag {...rest} />;
}

<Heading level={2} className="hero">Welcome, Alice Dev</Heading>;

Output: (rendered)

text
<h2 class="hero">Welcome, Alice Dev</h2>

Typed cva (class-variance-authority) variants

The cva library lets you describe a component's class variants as data; TS then enforces only-valid variant combinations at the call site.

bash
npm install class-variance-authority

Output: (none — exits 0 on success)

tsx
import { cva, type VariantProps } from "class-variance-authority";

const button = cva("btn", {
  variants: {
    intent: { primary: "btn-primary", danger: "btn-danger", ghost: "btn-ghost" },
    size:   { sm: "btn-sm", md: "btn-md", lg: "btn-lg" },
  },
  defaultVariants: { intent: "primary", size: "md" },
});

type ButtonProps = VariantProps<typeof button> &
  ComponentPropsWithoutRef<"button">;

export function Button({ intent, size, className, ...rest }: ButtonProps) {
  return <button className={button({ intent, size, className })} {...rest} />;
}

// All variants are autocompleted and type-checked
<Button intent="danger" size="lg">delete</Button>;
<Button intent="primary" size="xl"></Button>;        // Error: "xl" is not in size

Output: (none — exits 0 on success)

Discriminated-union event handlers in a <Select>

Pair the generic-<Select<T>> recipe with a discriminated-union of options so each variant gets the right typed metadata on selection.

tsx
type Option =
  | { kind: "user";  id: string; name: string }
  | { kind: "group"; id: string; members: number };

const options: Option[] = [
  { kind: "user",  id: "u1", name: "Alice Dev" },
  { kind: "group", id: "g1", members: 12 },
];

function Picker() {
  const [value, setValue] = useState<Option>(options[0]);

  return (
    <>
      <Select
        options={options}
        value={value}
        keyFn={(o) => o.id}
        labelFn={(o) => o.kind === "user" ? o.name : `Group of ${o.members}`}
        onChange={setValue}
      />
      {value.kind === "user"
        ? <p>Selected user: {value.name}</p>
        : <p>Selected group of {value.members}</p>}
    </>
  );
}

Output: (rendered, after selecting the group)

text
Selected group of 12

The value.kind === "user" check narrows value to the user variant — TypeScript knows value.name exists; in the else branch it knows value.members exists. No casts required.