TypeScript with React. Where types pay off the most.
React is the place TypeScript earns its keep. Every component is a contract — props in, JSX out. TypeScript enforces that contract at every call site: passing the wrong props fails to compile, autocomplete knows every field, and refactoring a prop type updates every usage automatically. This lesson covers the entire surface: typing props (including extending HTML attributes), typing state and reducers, typing every event, children, generic components that work with any data, and forwardRef. Every pattern here is what production React teams use daily.
Props: an interface for the component's parameter. State: infer from initial value; annotate with a union for nullable (useState(null)). Events:React.ChangeEvent, React.FormEvent, React.MouseEvent. Children:React.ReactNode. Generic components:function List(props: { items: T[] }). HTML props:extends React.ButtonHTMLAttributes. This is the complete vocabulary of React + TS.
Before this lesson: complete the React track — you need to understand components, JSX, state, and hooks. This lesson assumes you can write React in plain JavaScript.
🎯 Typing props — the foundation
Every component is a contract. Props are the contract's parameters.
A React component is a function that takes a props object and returns JSX. TypeScript's job: describe the props object so callers know what to pass, and the component knows what it receives. The pattern is universal — an interface for the props, annotate the parameter, destructure. Every component you'll ever write follows this shape.
Define ButtonProps once. The function signature reuses it. Optional props use ?. Union props restrict to valid values.
Destructure in signature
function Button({ label, onClick }: ButtonProps) — destructure for cleaner code and full inference. No props.label everywhere.
Defaults in destructuring
variant = 'primary' — defaults in the destructure give the parameter its non-optional type inside the function. The caller can still omit it.
Compile-time errors at callers
Wrong props → red squiggle in the caller's file. Not a runtime crash. This is the primary value of React + TS — errors caught before the browser runs.
Name your props ComponentNameProps — always.ButtonProps, UserCardProps, ModalProps. This makes grepping trivial and gives IDEs better indexing. Every React + TS codebase follows this convention. The one exception: a props type used by multiple components (e.g. BaseProps) or an anonymous inline type for a one-off. But 95% of your components should have a XxxProps interface — even small ones. It's free documentation.
🧬 Extending HTML attributes — wrap native elements
Your button should accept onClick, disabled, type — for free.
When you wrap an HTML element (, , ), you want your component to accept all the native attributes plus your custom props. React ships a set of interfaces for exactly this: React.ButtonHTMLAttributes, React.InputHTMLAttributes, React.AnchorHTMLAttributes, etc. Extending them gives your component the full native API.
Inherits all native button props: onClick, disabled, type, aria-*, className, children, etc. Add your own on top.
Generic
The event target type. onClick receives React.MouseEvent — e.currentTarget is typed as the actual element.
...rest forwards native props
Destructure custom props, spread the rest onto the native element. This is the standard pattern — every UI library uses it.
Element variants
InputHTMLAttributes, AnchorHTMLAttributes, TextareaHTMLAttributes, SelectHTMLAttributes, FormHTMLAttributes — one per element type.
Don't redeclare native attributes manually. Writing onClick?: () => void in your props loses everything: event type narrowing, all the other native props, and future React updates. extends React.ButtonHTMLAttributes gives you all of it, correctly typed, forever. This is the single biggest pattern to internalize for UI libraries — it's why Radix UI, shadcn/ui, and every serious component library work the way they do.
🎛️ useState and useReducer — state with types
Inference handles most cases. Annotate when the type isn't obvious from the initial value.
For useState(0), TypeScript infers number — no annotation needed. The annotation matters when the initial value is a subset of the actual type: useState(null) infers null, not User | null. That's when you provide the generic. Same story for useReducer — discriminated unions are the standard pattern for actions.
useState(0) → number. useState('') → string. useState(false) → boolean. Don't annotate these — inference is correct.
Annotate for unions
useState(null) — the generic tells TS the state is nullable, so setUser(user) works. Without it, useState(null) is just null.
Empty arrays need annotation
useState([]) infers never[]. Fix: useState([]) or useState([]). This is the most common "inference isn't enough" case.
useReducer with discriminated union
Actions as a discriminated union — { type: 'add'; by: number } | { type: 'reset' }. Each dispatch call is type-checked. dispatch({ type: 'typo' }) fails to compile.
Use useReducer + discriminated union for anything more than one related piece of state. Two useState calls can drift out of sync (loading = true, error = 'x', impossible). A single useReducer with a discriminated union makes impossible states unrepresentable — the same pattern from Lesson 4 applies directly to React state. If your component has three or more state variables that relate, consolidate them into one reducer. Your future self will thank you when debugging.
🖱️ Events — the type for every interaction
React's event types give you e.target, e.currentTarget, and typed values.
React wraps DOM events in its own synthetic event system. TypeScript gives you types for every one — the trick is knowing which to use. They're all named React.XxxEvent. Once you see the pattern (change, form, mouse, keyboard, focus, submit), you'll type every event correctly without thinking.
For onChange on inputs, textareas, selects. e.target.value is string, e.target.checked is boolean. The element generic determines the target type.
FormEvent
For onSubmit. Has preventDefault(). The form element generic enables form-specific properties.
MouseEvent
For onClick. Has clientX/Y, currentTarget (the element the handler is attached to), button (which mouse button).
KeyboardEvent
For onKeyDown/Up/Press. Has key, code, ctrlKey/shiftKey/altKey/metaKey.
Inline handlers — inferred
onClick={(e) => ...} in JSX — e is inferred from the attribute. Only annotate when extracting to a named function.
Don't use e.target when you mean e.currentTarget.e.target is the element that fired the event — could be a child. e.currentTarget is the element the handler is attached to. For type safety and correctness, use e.currentTarget when you need the element. This is a subtle bug that types can't always catch — the target/currentTarget distinction is a DOM concept, not a React concept. Use currentTarget by default.
🧸 Children and ReactNode — composing components
React.ReactNode is almost always the right type for children.
Any JSX element can contain children — text, other elements, arrays, null, fragments. React.ReactNode is the broadest type that covers all of it. It's the correct type for children in 95% of cases. The narrow types (ReactElement, JSX.Element) are for specific cases — usually when you need to inspect the element.
Card.tsxCopy
TypeScriptCompiled JS
// ─── Card with children ──────────────────────interface CardProps {
title: string;
children: React.ReactNode; // ← almost always this
}
functionCard({ title, children }: CardProps) {
return (
A button{/* Array of elements */}"With array">
{['a', 'b'].map(x => {x})}
{/* Null — allowed by ReactNode */}"Empty">{null}{/* Conditional rendering */}"Conditional">
{true &&
Visible
}
);
}
// ─── Optional children ───────────────────────interface OptionalCardProps {
title: string;
children?: React.ReactNode; // optional — component may render empty
}
functionOptionalCard({ title, children }: OptionalCardProps) {
return (
"card">
{title}
{children &&
{children}
}
);
}
// ─── Comparison ──────────────────────────────type A = React.ReactNode;
// string | number | bigint | boolean | null | undefined |
// ReactElement | Iterable | Promise>
// → use this for childrentype B = JSX.Element;
// The narrow type for a single JSX element (e.g. )
// → use when you know it's exactly one element and need to inspect propstype C = React.ReactElement;
// An element with known props type, or a generic element
// → use in library types when you need to inspect children's propsexport { Card, OptionalCard };
Broadest type: strings, numbers, JSX, arrays, null, undefined, fragments. Use for children. Accepts everything React can render.
JSX.Element — narrow
A single JSX element. Use only when you need to inspect props of the child, or when you're sure the return is exactly one element.
ReactElement — element with props
A generic element with known props. Used in libraries that manipulate children (e.g. a that reads its children's props). Rare in app code.
Optional children
children?: React.ReactNode — the ? makes it optional. Combine with {children && ...} for conditional rendering.
Never use React.FC in new code.React.FC was the pattern in 2018-2021 code. It's now discouraged for real reasons: (1) it implicitly added children (removed in React 18), which caused confusion; (2) it doesn't work well with generics; (3) it hides the props parameter. Modern React code: type props directly (function Button(props: ButtonProps)) and add children?: ReactNode explicitly when you need it. If you see React.FC in a codebase, treat it as a legacy marker — not a bug, but not the pattern to follow.
🧬 Generic components — components that work with any type
One List for User[], Post[], Product[] — full type safety.
Generic components are React components with a type parameter — the same generics from Lesson 3, applied to functions that return JSX. They let you write a component once and reuse it with any data type, preserving type information at the call site. Every library that deals with data uses this: React Hook Form, TanStack Table, react-select, react-dropdown.
{/* T = User — item is User inside the callback */} u.id}
renderItem={(u) => {u.name}}
/>
{/* T = Product — item is Product */} p.sku}
renderItem={(p) => (
{p.title} — $`{p.price}
)}
/>
{/* ❌ Errors: /}
{/ renderItem={(u) => u.nonexistent} — not on User /}
{/ items={[{ id: 1 }]} — id must be string */}
function List(props: ListProps) — the same generics from Lesson 3. T flows from the items prop into the renderItem callback.
Inference at the call site
Passing items={users} infers T = User. Inside renderItem, the item is typed as User — full autocomplete on u.name, u.id.
Generic props interface
interface ListProps — props can be generic too. Use (with trailing comma) in .tsx files when needed to disambiguate from JSX.
Real-world generics
TanStack Table's useReactTable. React Hook Form's useForm. react-select's . All rely on this pattern.
Prefer function declarations for generic components.function List(props: ListProps) parses correctly always. Arrow-function form const List = (...) => can be confused with JSX in .tsx files — you'd need with a trailing comma to disambiguate. The function declaration form is the standard: it's clearer, always parses, and gives the component a real name in stack traces and devtools.
🔗 Refs and forwardRef — imperatively accessing DOM
When you need to focus an input, scroll a div, or measure an element.
Refs give you direct access to a DOM element or a mutable value across renders. Typing them is straightforward: useRef(initial) for values, React.createRef() for element refs. The complexity comes from forwardRef — components that forward their ref to an inner element — which has a specific typing pattern.
refs.tsxCopy
TypeScriptCompiled JS
import { useRef, forwardRef, useEffect } from'react';
// ─── useRef — DOM element ref ────────────────functionFocusInput() {
const inputRef = useRef(null);
functionfocus() {
inputRef.current?.focus(); // current is HTMLInputElement | null
}
return (
Focus
);
}
// ─── useRef — mutable value ref ──────────────functionCounter() {
// A ref that holds a number, doesn't trigger re-render on changeconst countRef = useRef(0);
const intervalRef = useReftypeof setInterval> | null>(null);
useEffect(() => {
intervalRef.current = setInterval(() => {
countRef.current += 1;
console.log(countRef.current);
}, 1000);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, []);
return
Check console
;
}
// ─── forwardRef — pass a ref through a component ─interface FancyInputProps {
label: string;
placeholder?: string;
}
constFancyInput = forwardRef(
({ label, placeholder }, ref) => {
return (
);
}
);
FancyInput.displayName = 'FancyInput';
// ─── Using the ref from the parent ───────────functionApp() {
const fancyRef = useRef(null);
functionfocusFancy() {
fancyRef.current?.focus();
}
return (
{/* ref is passed through to the inner input */}"Name" placeholder="Enter…" />
Focus input
);
}
// ─── React 19: ref as a regular prop ─────────// React 19 lets you pass ref directly without forwardRef:interface NewInputProps {
label: string;
ref?: React.Ref;
}
functionNewInput({ label, ref }: NewInputProps) {
return ;
}
// Both patterns work. Use forwardRef for React 18 compatibility;// use ref-as-prop for new React 19 codebases.export default App;
For DOM elements: useRef(null). The generic is the element type. .current is T | null.
Mutable value refs
useRef(0) for a number, useRef(null) for intervals. Changing .current doesn't re-render — it's a mutable box.
forwardRef
Two generics: the ref target type and the props type. Wrap the component function. Set displayName for devtools. The parent can pass a ref that reaches the inner element.
React 19: ref as prop
React 19 allows ref as a regular prop — no forwardRef wrapper needed. Both patterns work; use forwardRef for React 18 compatibility.
Refs are for things React can't do declaratively. Focus, scroll, measurement, third-party DOM integration, imperative animation. If you find yourself reaching for a ref to read state, mutate state, or trigger a re-render, you probably want useState or useEffect instead. The React mental model is declarative — refs are the escape hatch, not the default. Typed correctly, refs are safe. But use them sparingly: a component that reaches for refs in five places usually has a state design problem.
🏆 Challenge
Three tasks. Each builds a real React + TS pattern from scratch.
Task 1 — Build a typed Button component
Build a button that extends the native element:
1. Interface ButtonProps extends React.ButtonHTMLAttributes
2. Add custom props: variant: 'primary' | 'secondary' | 'danger', size?: 'sm' | 'md' | 'lg', loading?: boolean
3. Destructure custom props, spread ...rest onto the
4. When loading, render "Loading…" and disable the button
5. Use it in an App with: custom props, native props (type, aria-label), and the disabled state
6. Verify compile errors for: missing variant, wrong variant value, wrong native prop
The point: this is exactly how shadcn/ui, Radix, and every production component library types its primitives. Master this pattern and you can wrap any HTML element.
Task 2 — Typed form with useReducer
Build a login form with full type safety:
1. State as discriminated union: idle | submitting (with startedAt) | success (with user) | error (with message)
2. Action union for transitions: submit, resolve, reject, reset
3. Typed reducer with exhaustive check (assertNever)
4. Form with typed handlers: React.FormEvent, React.ChangeEvent
5. Render different UI per state (idle = button, submitting = spinner, success = welcome, error = red message)
6. Dispatch should only accept valid actions — try a typo, confirm it fails
The point: this is the canonical React + TS state machine. Every real form should be structured this way. If you can build this, you can build any interactive form.
Task 3 — Generic DataTable component
Build a reusable table that works with any row type:
1. interface Column { key: keyof T; header: string; render?: (value: T[keyof T], row: T) => React.ReactNode }
2. interface DataTableProps { data: T[]; columns: Column[]; keyExtractor: (row: T) => string }
3. function DataTable(props: DataTableProps) — render the table
4. If render is provided, use it; otherwise display String(row[column.key])
5. Use it with User[] and Product[] — verify the columns prop validates that key is a real key of T
6. Try key: 'nonexistent' — confirm the compile error
The point: this is exactly how TanStack Table, AG Grid, and every data grid library types its column definitions. The combination of generics + keyof + conditional rendering is the foundation of every typed data table in the ecosystem.
⚠️ Common mistakes
The React + TS bugs that catch every beginner.
1. Using React.FC
Legacy pattern. Adds implicit children, doesn't compose with generics. Fix: type props directly — function Button(props: ButtonProps). Add children?: ReactNode explicitly if needed.
2. Not annotating nullable state
useState(null) infers null — setUser(user) fails. Fix: useState(null). The generic tells TS the full type range.
3. Using React.ReactNode vs JSX.Element wrongly
JSX.Element rejects strings, null, arrays. For children, use ReactNode. Use JSX.Element only when you know it's a single element.
4. Forgetting e.currentTarget
e.target can be a child element. e.currentTarget is the element the handler is attached to. Fix: use e.currentTarget when you need the element.
5. Hardcoding event types
(e: Event) => ... — loses all React-specific properties. Fix: use React.ChangeEvent, React.MouseEvent, etc. Import from 'react'.
6. Over-annotating inline handlers
onClick={(e: React.MouseEvent) => ...} when inference would work. Fix: let inference handle inline handlers. Annotate only when extracting to a named function.
7. Forgetting displayName on forwardRef
DevTools shows "ForwardRef" instead of the component name. Fix: FancyInput.displayName = 'FancyInput'. Small but worth it.
8. Using useRef() without initial value
useRef() — .current is undefined, not null. Fix: always pass null as the initial value. useRef(null).
❓ Frequently asked questions
How do I type React props in TypeScript?
Define an interface or type for the component's props, then annotate the function parameter. interface ButtonProps { label: string; onClick: () => void; } function Button({ label, onClick }: ButtonProps) { return {label}; }. Optional props use ?. Children use React.ReactNode.
How do I type useState in TypeScript?
Usually you don't — TypeScript infers from the initial value. const [count, setCount] = useState(0) infers number. For values that start null or undefined, annotate explicitly: useState(null). The generic annotation tells TypeScript what the state can hold.
How do I type events in React?
Use React's built-in event types. For input changes: React.ChangeEvent. For form submit: React.FormEvent. For button clicks: React.MouseEvent. For generic handlers: React.SyntheticEvent. Each provides the correct target type.
What is React.FC and should I use it?
React.FC is a type for function components that accepts props of type T. It was popular in older React code but is now discouraged. The reason: React.FC adds an implicit children prop and doesn't compose well with generics. Modern practice: type the props parameter directly.
How do I type a generic component?
A generic component is a function with a type parameter: function List({ items, render }: { items: T[]; render: (item: T) => React.ReactNode }). Callers get full inference: u.name} /> infers T = User, and u.name is type checked.
What is the difference between ReactNode and JSX.Element?
ReactNode is the broadest type for anything React can render: JSX, strings, numbers, null, undefined, arrays, fragments. JSX.Element is narrower — specifically a single JSX element. Use ReactNode for children and return types that might be conditional.
How do I type a forwardRef component?
forwardRef takes two type parameters: the ref type and the props type. const Input = forwardRef((props, ref) => { return ; }). The first parameter is the element the ref attaches to. The second is the props.
How do I extend HTML element props in a custom component?
Use React's built-in attribute interfaces. interface ButtonProps extends React.ButtonHTMLAttributes { variant: 'primary' | 'secondary'; } — this gives your component all the native button props plus your custom variant. This is the standard pattern for wrapping HTML elements.
🎉 You've completed the TypeScript track
Eight lessons. From annotations to generics to narrowing to classes to utility types to tooling to React. You now know every TypeScript feature used in production codebases — and more importantly, you know when to reach for each one.
Next steps: build something real. Pick a project from the project list — the React + TS app is the natural next step. Or deepen your TypeScript with advanced patterns: conditional types, infer, brand types, the satisfies operator. Or apply what you know to a real codebase — the fastest way to learn TypeScript at a deeper level is to use it daily.
You've done the work. The compiler is now on your side.