Skip to content
React Course1.0.0 beta
CSEN
React Course1.0.0 beta
CSEN
🏠 Home🧭 Where to start?🔁 Review📄 Cheat sheets📰 What's newℹ️ About the course
0. JavaScript0/22

Basics

  • 1. A quick review of the basics0/3
  • 2. State and events (useState)0/3

Hooks in depth

  • 3. Effects (useEffect)0/3
  • 4. Refs (useRef)0/3
  • 5. More complex state (useReducer)0/3
  • 6. Context0/3
  • 7. Custom hooks0/3

Forms

  • 8. Forms and React 19 Actions0/3

Performance

  • 9. Performance and rendering0/3

Patterns

  • 10. Component design patterns0/3

Ecosystem

  • 11. Where the type goes (TypeScript syntax)0/3
  • 12. TypeScript with React0/3
  • 13. Routing (React Router)0/3
  • 14. Data fetching0/3
  • 15. Application state management0/3

Project

  • 16. Final project: Kanban0/2

Summary

  • 17. Summary: the principles of React

Your account

Privacy

Lesson 12 · Ecosystem

TypeScript with React

Typing props, events and hooks, generic components and discriminated unions.

Loading lesson…

💬 Found a mistake, something unclear, or have an idea? Let me know.
← Where the type goes (TypeScript syntax)Routing (React Router) →

Before we start: sockets and plugs

You can't push a British plug into a European socket. You don't need to plug anything in or test whether it blows a fuse – you can tell from the shape alone. The shape of the socket is really a contract: "this goes here, and nothing else".

A props type is exactly that kind of socket shape. The component says: "I need a name (text), a role (either admin or user) and a function I'll call on click". Whoever uses it with a missing name or with the role "admn" finds out in the editor, before running anything – with a red squiggle, not an error in a customer's browser.

In the previous lesson we learned where type labels are stuck. Now we'll stick them on things typical for React: props, events, hooks, context and components that work with any data.

💡 Documentation that doesn't lie
The editor suggests props, catches a typo in the name of a reducer action, warns about a null you didn't handle, and renaming a prop across fifty files is safe. This whole course is written in TypeScript – have a look at the code of any demo.
ℹ️ What's in this lesson
  1. Typing props and the types React offers.
  2. Extending native elements and "either–or" props.
  3. Events.
  4. Hooks and context.
  5. Generic components: a table for any data.
  6. Useful tricks.

1. Props: the shape of the socket

A user card that shows a name and a role and lets the parent know on click:

interface UserCardProps {
id: number
name: string
age?: number // optional
role: 'admin' | 'user' // only one of these two values
onSelect: (id: number) => void // a function the card calls
children?: ReactNode // content between the tags
}
function UserCard({ id, name, age = 18, role, onSelect, children }: UserCardProps) {
return (
<div onClick={() => onSelect(id)}>
<strong>{name}</strong> ({age}) – {role}
{children}
</div>
)
}

What TypeScript watches out for:

<UserCard id={1} name="Anna" role="admin" onSelect={select} /> // ✅
<UserCard id={1} role="admin" onSelect={select} />
// ❌ Property 'name' is missing – a required prop is missing
<UserCard id={1} name="Anna" role="admn" onSelect={select} />
// ❌ Type '"admn"' is not assignable to type '"admin" | "user"' – a typo
<UserCard id={1} name="Anna" role="user" onSelect={(id: string) => …} />
// ❌ onSelect gets a number, not a string
  • A union of text values ('admin' | 'user') instead of a general string. The editor offers both options as you type and won't let a typo through.
  • An optional prop with a default value: a question mark in the type, an equals sign in the destructuring. Inside the component age is always a number.
  • A callback is described as a function type: what it gets and what it returns (void = nothing useful).

React offers several types of its own that come in handy in props:

TypeWhen
ReactNodechildren and slots – text, a number, JSX, an array, null… anything renderable.
ReactElementWhen you need exactly one element (rare).
ComponentProps<'button'>All the props of a native element.
ComponentProps<typeof MyComp>The props of an existing component (even from a library that doesn’t export them).
CSSPropertiesAn object for the style prop.
ℹ️ interface or type?
Both work. The convention: interface for object shapes (props), type for unions and compositions (A & B, 'a' | 'b'). Above all be consistent.

2. Extending native elements and "either–or" props

Your own button with everything a button can do

You're writing your own Button with colour variants. But the user will want to pass disabled, type="submit", aria-label, title… too. Listing them all by hand makes no sense. So you take the shape of the native socket and add your own to it:

type ButtonProps = ComponentProps<'button'> & {
variant?: 'primary' | 'ghost' | 'danger'
}
function Button({ variant = 'primary', className = '', ...rest }: ButtonProps) {
return <button className={`btn btn-${variant} ${className}`} {...rest} />
}
  1. ComponentProps<'button'> = all the props a plain <button> has.
  2. & = "and at the same time". The result has the native props and our variant.
  3. ...rest collects everything we didn't take and passes it on to the <button>. Including onClick, disabled and ref.
❓ The three dots in ({ variant, ...rest }) and in {...rest} – is it the same thing?

The same syntax, two opposite directions:

  • In a parameter (rest): ({ variant, className, ...rest }) = "pull out variant and className, and pack everything else into a rest object". It collects.
  • In JSX or an object (spread): <button {...rest} /> = "unpack the rest object and pass each of its properties as a separate attribute". It scatters.

Together it means: what the component took, it handles itself; the rest it passes on unchanged. Mind the order: an attribute written after {...rest} overrides it, an attribute written before it can be overridden.

"Either–or" props: a discriminated union

At the post office you fill in a form. At the top you tick whether you're sending a letter or a parcel – and depending on that you fill in different boxes. For a parcel, the weight and dimensions; for a letter, none of that. Anyone who ticks "letter" and fills in a weight has got something wrong.

A component that is either a link or a button with an action has the same problem. With ordinary optional props you can write nonsense:

// ❌ Both optional → you can pass both, or neither
interface Props { href?: string; onClick?: () => void; children: ReactNode }
// ✅ The "tick box" kind decides what else is required
type LinkOrActionProps =
| { kind: 'link'; href: string; children: ReactNode }
| { kind: 'action'; onClick: () => void; children: ReactNode }
function LinkOrAction(props: LinkOrActionProps) {
if (props.kind === 'link') {
return <a href={props.href}>{props.children}</a> // TS knows: href is here
}
return <button onClick={props.onClick}>{props.children}</button> // and onClick here
}

Inside the if TypeScript knows which variant you have and lets you touch only its properties. And from the outside you can't write kind="link" without href.

Demo

A Button with native props + LinkOrAction

What you see: At the top, three buttons of our own Button component – with an icon, disabled with a native title, and with just an icon and an aria-label. At the bottom, two variants of LinkOrAction.

Try it:

  1. Hover over the disabled Disabled button. A tooltip from the title attribute appears – it got through via ...rest without us having to list it.
  2. Click Save and open the console (F12). The handler got a correctly typed button event.
  3. Click Link (it leads to this lesson) and Action (it shows a message). The same component, two variants.
  4. Open the code and find the commented-out line with kind="link" and onClick. If you uncommented it, TypeScript would report an error.

The takeaway: You inherit native props with one line. A discriminated union guarantees the component always gets a sensible combination.

Loading the interactive part…

3. Events

When you write a handler right in JSX, TypeScript knows which element and event it belongs to, and it infers the type of e by itself. When you pull the handler out into a separate variable, it loses that connection – and you have to write the type. It's the opaque box from the previous lesson.

// An inline handler: the type is inferred by itself – the simplest option
<input onChange={(e) => setName(e.target.value)} />
// A standalone handler: the event type + the element type in angle brackets
const handleChange = (e: ChangeEvent<HTMLInputElement>) => setName(e.target.value)
const handleSubmit = (e: FormEvent<HTMLFormElement>) => { e.preventDefault() }
const handleKey = (e: KeyboardEvent<HTMLInputElement>) => { if (e.key === 'Enter') … }
const handleClick = (e: MouseEvent<HTMLButtonElement>) => { e.currentTarget.disabled = true }
// Or the type of the whole handler at once
const onClick: MouseEventHandler<HTMLButtonElement> = (e) => { … }
✨ A trick: let the editor tell you the type
Don't know the type? Write the handler inline first, hover over e and the editor tells you. Then pull the handler out and copy the type.

4. Hooks and context

With hooks the see-through box rule applies: when TypeScript sees the initial value and everything is clear from it, don't write the type. When the box is empty or "nothing yet", pass the type in angle brackets.

useState(0) // number – a see-through box
useState<User | null>(null) // nothing now, a User later – you must say so
useState<Product[]>([]) // an empty array – you must say so (otherwise never[])
useRef<HTMLInputElement>(null) // a ref to an element on the page
useRef<number | null>(null) // a drawer for a timer ID
useRef(0) // a drawer with a number – inferred
useReducer(reducer, initial) // the types are inferred from the reducer
createContext<AuthValue | null>(null) // + a custom hook with a null check
// A custom hook returning a pair
function useToggle() {
…
return [on, toggle] as const // readonly [boolean, () => void]
}

With useReducer it's enough to type the reducer properly – the state and the actions as a discriminated union from the lesson on reducers. Everything else (the type of state, the allowed actions in dispatch) is inferred from it.

❓ How does the exhaustiveness check with never work (the TypedCart challenge)?

never is a type that nothing can be assigned to. That's used in a switch over actions:

switch (action.type) {
case 'added': …
case 'removed': …
case 'cleared': …
default: {
const unreachable: never = action // only "nothing" may get here
throw new Error('Unknown action')
}
}

In each case TypeScript ticks off one variant of the action. When all of them are handled, none is left in default, action has the type never and the assignment passes. When you add a new variant to Action and forget about it, it's left over in default and TypeScript reports that it can't be assigned to never. That way you find a forgotten action in the editor, not in the app.

5. Generic components

A vending machine doesn't know in advance what will be in it. Fill it with cans and it gives out cans; with biscuits, biscuits. And it won't give you a biscuit from a machine full of cans. A generic component works the same way: it doesn't know in advance what data it will work with, but once you give it the data, it "learns" its type and watches over it.

A typical example: a table that can show products, users, orders…

interface Column<T> {
key: keyof T & string // only a real property of T
header: string
render?: (row: T) => ReactNode // the row arrives correctly typed
}
interface TableProps<T> {
rows: T[]
columns: Column<T>[]
}
function Table<T>({ rows, columns }: TableProps<T>) { … }
  1. <T> after the function name says: "this component has a type parameter, I don't know it yet".
  2. When you write <Table rows={products} … />, TypeScript looks at rows and finds out that T is Product. The machine is filled.
  3. From then on every key in the columns must be a property of a product ('name', 'price'…) and the render function gets a Product.
<Table
rows={products}
columns={[
{ key: 'name', header: 'Name' },
{ key: 'price', header: 'Price', render: (p) => `$${p.price}` }, // p is a Product
{ key: 'color', header: 'Colour' }, // ❌ 'color' isn't a property of Product
]}
/>
Demo

A generic table with sorting

What you see: One Table component used twice: with products and with users. Columns with an arrow can be sorted.

Try it:

  1. Click the Price header for products, then again. It sorts ascending and descending.
  2. Click City for users. The same sorting code, different data.
  3. Open the code and find the commented-out color column. A product has no colour – if you uncommented the line, TypeScript would underline it.

The takeaway: The component is written once for any data. The data type is inferred from rows and watches over the columns and the render functions.

Loading the interactive part…

⚠️ A trap when inferring generics
TypeScript doesn't forbid <Select options={[1, 2]} value="a" /> – it infers T = number | string from both options and value. When you want T to be inferred only from options, use value: NoInfer<T> (TS 5.4+).

6. Useful tricks

SyntaxWhat for
const sizes = ['S', 'M'] as constA readonly pair of strings → typeof sizes[number] = "S" | "M".
obj satisfies Record<Tone, string>Checks the shape, but keeps the exact type of the value.
Omit<ButtonProps, "type">Props without some keys.
Partial<T>, Required<T>, Pick<T, K>Derived types without copying.
const x: never = actionAn exhaustiveness check in a switch.
z.infer<typeof schema>A type from a Zod schema – validation and types from one source.
❓ What's the difference between const x: Type = …, … as Type and … satisfies Type?
type Tone = 'info' | 'danger'
const a: Record<Tone, string> = { info: 'blue', danger: 'red' }
// "a IS of type Record" – it checks, and the variable's type is exactly Record<Tone, string>
const b = { info: 'blue', danger: 'red' } satisfies Record<Tone, string>
// "check that this SATISFIES Record" – but b's type stays exact: { info: string; danger: string }
const c = { info: 'blue' } as Record<Tone, string>
// "trust me, it's a Record" – it DOESN'T check that danger is missing. The lie gets through.

: Type and satisfies both check. They differ in what type the variable has afterwards: satisfies keeps the more precise, inferred one. as checks nothing, it just commands. Use it only where you really know more than TypeScript (for example e.target as Node).

Check yourself

Quiz What type does items have after const [items, setItems] = useState([])?
Quiz How do you type a prop into which you want to put <Icon />, or text, or nothing?
Quiz The Notice component has either a message prop (text) or an error prop (an Error object) – never both. How do you describe that?

Warm-up

Challenge

A badge with typed props

Warm-up

The props type is written – the component just has to use it.

Task (the same text is in the comment at the top of the file)
CHALLENGE: A badge with typed props

The props type (BadgeProps) is finished. The component doesn't use it yet.

1. Instead of (props: BadgeProps), destructure the props right in the parameter: function Badge({ label, tone = 'ok' }: BadgeProps)
2. Render <span className={tone}>{label}</span>
   (the ok and bad classes are ready-made course styles – green and red)
🧩 Where do the things not defined here come from
CSS classes like row, stack, card, list, btn, input or muted are the course's ready-made styles in src/styles.css (row = side by side, stack = stacked, card = bordered box, muted = grey text).

Loading the interactive part…

Challenges

The first challenge practises sections 1, 2 and 5: socket shapes for props, extending a native button and a generic Select. The second practises section 4 – a typed reducer and context in which you catch a typo in an action name before you save the file.

✨ An automatic check
These challenges have type tests. Run npm run typecheck:challenges in the terminal – as long as there's any in your code, the test fails. When the types are right, it passes without errors.
Challenge

Banish every any

Medium Bonus challenge

The UI looks the same – the change is only in the types. Only TypeScript can tell you've succeeded.

🔒 Bonus challenge

Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.

How we handle your data
Challenge

A type-safe cart

Hard Bonus challenge

A discriminated union for actions + a typed context = you catch a typo in an action name before you save the file.

🔒 Bonus challenge

Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.

How we handle your data

Summary

  • A props type is the shape of a socket: the wrong plug is caught in the editor, not at the customer's. Unions of strings instead of a general string.
  • ComponentProps<'button'> & {…} for extending native elements, ReactNode for children.
  • A discriminated union for "either–or" props (and for state and actions too) – a nonsensical combination can't be written.
  • Events: inline the type is inferred, a standalone handler needs ChangeEvent<…> and the like.
  • Hooks: useState<T>() for null, unions and empty arrays; otherwise let TS infer.
  • Generic components are vending machines: the data type is inferred from the props and watches over the rest.