Lesson 8 · Final

TypeScript with React

Home › Courses › TypeScript › Lesson 8
🔷 Lesson 8 · 22 minutes · Final

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.

~22 minutes Intermediate → Advanced React 19 · TypeScript 5
TypeScript path Typing props HTML attributes State & reducers Events Children Generic components Refs Challenge

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.

Button.tsx
// ─── The props interface ─────────────────────
interface ButtonProps {
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary' | 'danger'; // optional
disabled?: boolean;
}

// ─── The component ───────────────────────────
function Button({ label, onClick, variant = 'primary', disabled = false }: ButtonProps) {
return (

);
}

// ─── Callers get full checking ───────────────
function App() {
return (
); } export default App;
// Type annotations erased. Interface gone.
// Default values survive (JS feature).

function Button({ label, onClick, variant = 'primary', disabled = false }) {
return React.createElement(
'button',
{
className: btn btn-${variant}`,
onClick,
disabled
},
label
);
}

function App() {
return React.createElement(
'div',
null,
React.createElement(Button, { label: 'Save', onClick: () => console.log('saved') }),
React.createElement(Button, { label: 'Delete', onClick: () => {}, variant: 'danger' })
);
}

export default App;
[ Save ] [ Delete ]

Interface for props

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 (

// ─── Extend the native button props ──────────
interface ButtonProps
extends React.ButtonHTMLAttributes {
variant?: 'primary' | 'secondary' | 'danger';
loading?: boolean;
}

function Button({
variant = 'primary',
loading = false,
children,
disabled,
className,
...rest
}: ButtonProps) {
return (

);
}

// ─── Callers get everything for free ─────────
function App() {
return (
{/* Custom props */} {/* Native button attributes work too */} {/* ❌ Compile errors: /} {/
); } export default App;
// All TypeScript sugar erased. className/disabled handled.
// rest spread passes remaining native props through.

function Button({
variant = 'primary',
loading = false,
children,
disabled,
className,
...rest
}) {
return React.createElement(
'button',
{
className: btn btn-${variant} ${className ?? ''},
disabled: disabled || loading,
...rest
},
loading ? 'Loading…' : children
);
}

function App() {
return React.createElement(
'div',
null,
React.createElement(Button, { variant: 'danger', loading: true }, 'Delete'),
React.createElement(Button, {
type: 'submit',
onClick: (e) => console.log(e.currentTarget),
'aria-label': 'Submit form',
className: 'custom',
disabled: true
}, 'Submit')
);
}

export default App;

extends React.ButtonHTMLAttributes

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.

state.tsx
import { useState, useReducer } from 'react';

interface User { id: string; name: string }

function Profile() {
// ─── Inference — the common case ─────────────
const [count, setCount] = useState(0); // number
const [name, setName] = useState(''); // string
const [tags, setTags] = useState<string[]>([]); // empty array — annotate!

// ─── Annotated — when inference isn't enough ─
const [user, setUser] = useStatenull>(null);

function load() {
setUser({ id: '1', name: 'Alice' }); // ✅ setUser accepts User
// setUser({ id: 2 }); // ❌ type mismatch
}

return (

{count}

{user?.name ?? 'No user'}

); } // ─── useReducer with a discriminated union ─── interface State { count: number; history: number[] } type Action = | { type: 'increment' } | { type: 'decrement' } | { type: 'add'; by: number } | { type: 'reset' }; function reducer(state: State, action: Action): State { switch (action.type) { case 'increment': return { count: state.count + 1, history: [...state.history, state.count + 1] }; case 'decrement': return { count: state.count - 1, history: [...state.history, state.count - 1] }; case 'add': return { count: state.count + action.by, history: [...state.history, state.count + action.by] }; case 'reset': return { count: 0, history: [] }; } } function Counter() { const [state, dispatch] = useReducer(reducer, { count: 0, history: [] }); return (

{state.count}

{/* ❌ compile error: 'multiply' not in Action union /} {/ dispatch({ type: 'multiply' }); */}
); } export { Profile, Counter };
import { useState, useReducer } from 'react';

function Profile() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
const [tags, setTags] = useState([]);
const [user, setUser] = useState(null);

function load() {
setUser({ id: '1', name: 'Alice' });
}

return React.createElement('div', null,
React.createElement('p', null, count),
React.createElement('p', null, user?.name ?? 'No user'),
React.createElement('button', { onClick: load }, 'Load')
);
}

function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1, history: [...state.history, state.count + 1] };
case 'decrement':
return { count: state.count - 1, history: [...state.history, state.count - 1] };
case 'add':
return { count: state.count + action.by, history: [...state.history, state.count + action.by] };
case 'reset':
return { count: 0, history: [] };
}
}

function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0, history: [] });

return React.createElement('div', null,
React.createElement('p', null, state.count),
React.createElement('button', { onClick: () => dispatch({ type: 'increment' }) }, '+1'),
React.createElement('button', { onClick: () => dispatch({ type: 'add', by: 10 }) }, '+10'),
React.createElement('button', { onClick: () => dispatch({ type: 'reset' }) }, 'Reset')
);
}

export { Profile, Counter };

Inference when possible

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.

events.tsx
import { useState } from 'react';

function EventDemos() {
const [text, setText] = useState('');
const [checked, setChecked] = useState(false);

// ─── Change events (inputs, selects, textareas) ─
function handleInputChange(e: React.ChangeEvent) {
setText(e.target.value); // e.target is HTMLInputElement
}

function handleCheck(e: React.ChangeEvent) {
setChecked(e.target.checked); // .checked is boolean
}

// ─── Form submit ────────────────────────────
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
console.log('Submitted');
}

// ─── Mouse events ───────────────────────────
function handleClick(e: React.MouseEvent) {
console.log('clicked at', e.clientX, e.clientY);
console.log('on button', e.currentTarget.disabled);
}

// ─── Keyboard ───────────────────────────────
function handleKey(e: React.KeyboardEvent) {
if (e.key === 'Enter') console.log('submit');
}

// ─── Focus ──────────────────────────────────
function handleFocus(e: React.FocusEvent) {
e.target.select();
}

return (
); } // ─── The cheat sheet ────────────────────────── // onChange: React.ChangeEvent // onInput: React.FormEvent // onSubmit: React.FormEvent // onClick: React.MouseEvent // onKeyDown: React.KeyboardEvent // onFocus: React.FocusEvent // onBlur: React.FocusEvent // onScroll: React.UIEvent // onDrag: React.DragEvent // onWheel: React.WheelEvent // onPointer: React.PointerEvent export default EventDemos;
import { useState } from 'react';

function EventDemos() {
const [text, setText] = useState('');
const [checked, setChecked] = useState(false);

function handleInputChange(e) { setText(e.target.value); }
function handleCheck(e) { setChecked(e.target.checked); }
function handleSubmit(e) { e.preventDefault(); console.log('Submitted'); }
function handleClick(e) { console.log('clicked at', e.clientX, e.clientY); }
function handleKey(e) { if (e.key === 'Enter') console.log('submit'); }
function handleFocus(e) { e.target.select(); }

return React.createElement('form', { onSubmit: handleSubmit },
React.createElement('input', {
value: text,
onChange: handleInputChange,
onKeyDown: handleKey,
onFocus: handleFocus
}),
React.createElement('label', null,
React.createElement('input', {
type: 'checkbox', checked, onChange: handleCheck
}),
'Check me'
),
React.createElement('button', { onClick: handleClick }, 'Click')
);
}

export default EventDemos;

ChangeEvent

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.tsx
// ─── Card with children ──────────────────────
interface CardProps {
title: string;
children: React.ReactNode; // ← almost always this
}

function Card({ title, children }: CardProps) {
return (
"card">

{title}

"card-body">{children}
); } // ─── Flexible children accepted ────────────── function App() { return (
{/* String child */} "Plain text">Just a string {/* JSX child */} "With JSX">

A paragraph

{/* 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 } function OptionalCard({ 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 children type 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 props type C = React.ReactElement; // An element with known props type, or a generic element // → use in library types when you need to inspect children's props export { Card, OptionalCard };
function Card({ title, children }) {
return React.createElement('div', { className: 'card' },
React.createElement('h2', null, title),
React.createElement('div', { className: 'card-body' }, children)
);
}

function App() {
return React.createElement('div', null,
React.createElement(Card, { title: 'Plain text' }, 'Just a string'),
React.createElement(Card, { title: 'With JSX' },
React.createElement('p', null, 'A paragraph'),
React.createElement('button', null, 'A button')
),
React.createElement(Card, { title: 'With array' },
['a', 'b'].map(x => React.createElement('span', { key: x }, x))
),
React.createElement(Card, { title: 'Empty' }, null),
React.createElement(Card, { title: 'Conditional' },
true && React.createElement('p', null, 'Visible')
)
);
}

function OptionalCard({ title, children }) {
return React.createElement('div', { className: 'card' },
React.createElement('h2', null, title),
children && React.createElement('div', null, children)
);
}

export { Card, OptionalCard };

React.ReactNode — the default

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.

List.tsx
// ─── A generic List component ────────────────
interface ListProps {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor?: (item: T) => string | number;
}

function List({ items, renderItem, keyExtractor }: ListProps) {
return (
    {items.map((item, i) => (
  • keyExtractor(item) : i}> {renderItem(item, i)}
  • ))}
); } // ─── Usage: T is inferred per call ─────────── interface User { id: string; name: string } interface Product { sku: string; title: string; price: number } function App() { const users: User[] = [{ id: '1', name: 'Alice' }]; const products: Product[] = [{ sku: 'X', title: 'Book', price: 10 }]; return (
{/* 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 */}
); } export default App;
// Generics erased. Runtime is a plain component.

function List({ items, renderItem, keyExtractor }) {
return React.createElement('ul', null,
items.map((item, i) =>
React.createElement('li', {
key: keyExtractor ? keyExtractor(item) : i
}, renderItem(item, i))
)
);
}

function App() {
const users = [{ id: '1', name: 'Alice' }];
const products = [{ sku: 'X', title: 'Book', price: 10 }];

return React.createElement('div', null,
React.createElement(List, {
items: users,
keyExtractor: (u) => u.id,
renderItem: (u) => React.createElement('span', null, u.name)
}),
React.createElement(List, {
items: products,
keyExtractor: (p) => p.sku,
renderItem: (p) => React.createElement('span', null, p.title, ' — `$', p.price)
})
);
}

export default App;

Generic function components

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.tsx
import { useRef, forwardRef, useEffect } from 'react';

// ─── useRef — DOM element ref ────────────────
function FocusInput() {
const inputRef = useRef(null);

function focus() {
inputRef.current?.focus(); // current is HTMLInputElement | null
}

return (
); } // ─── useRef — mutable value ref ────────────── function Counter() { // A ref that holds a number, doesn't trigger re-render on change const 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; } const FancyInput = forwardRef( ({ label, placeholder }, ref) => { return ( ); } ); FancyInput.displayName = 'FancyInput'; // ─── Using the ref from the parent ─────────── function App() { const fancyRef = useRef(null); function focusFancy() { fancyRef.current?.focus(); } return (
{/* ref is passed through to the inner input */} "Name" placeholder="Enter…" />
); } // ─── React 19: ref as a regular prop ───────── // React 19 lets you pass ref directly without forwardRef: interface NewInputProps { label: string; ref?: React.Ref; } function NewInput({ 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;
import { useRef, forwardRef, useEffect } from 'react';

function FocusInput() {
const inputRef = useRef(null);

function focus() {
inputRef.current?.focus();
}

return React.createElement('div', null,
React.createElement('input', { ref: inputRef }),
React.createElement('button', { onClick: focus }, 'Focus')
);
}

function Counter() {
const countRef = useRef(0);
const intervalRef = useRef(null);

useEffect(() => {
intervalRef.current = setInterval(() => {
countRef.current += 1;
console.log(countRef.current);
}, 1000);

return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, []);

return React.createElement('p', null, 'Check console');
}

const FancyInput = forwardRef(({ label, placeholder }, ref) => {
return React.createElement('label', null,
label,
React.createElement('input', { ref, placeholder })
);
});
FancyInput.displayName = 'FancyInput';

function App() {
const fancyRef = useRef(null);

function focusFancy() {
fancyRef.current?.focus();
}

return React.createElement('div', null,
React.createElement(FancyInput, { ref: fancyRef, label: 'Name', placeholder: 'Enter…' }),
React.createElement('button', { onClick: focusFancy }, 'Focus input')
);
}

function NewInput({ label, ref }) {
return React.createElement('label', null, label, React.createElement('input', { ref }));
}

export default App;

useRef(initial)

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

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 ; }. 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.

🔗 What's next

🌐 External resources

🔧 Related problem solvers

← Back to TypeScript track
TypeScript track · Complete · 8/8 lessons