Skip to content

TypeScript in React

A parent renames a prop from word to currentWord. Three children keep reading word, and nothing flags it: the app builds, the page loads, and the first user to click the button that renders one of those children gets a blank screen. TypeScript adds a type layer on top of JavaScript: every value has a declared or inferred type, and the compiler flags code that mismatches before it ever runs.

In React that pays off exactly where bugs like that hide, at the seams where data moves between components. A typed prop is a contract: the parent must send the right shape, the child can trust what arrives, and your editor autocompletes both sides.

This chapter covers the React-specific parts: typing state, props, children, function props, and component return values. It assumes you know TypeScript's basics, unions, custom types, and generics; the course opens this section with refresher lessons on exactly those, and then puts everything to work rebuilding the Assembly: Endgame game in TypeScript.

Setting up: Vite's react-ts template

Vite ships a TypeScript flavor of its React template. One flag at scaffold time gives you a fully configured project:

bash
npm create vite@latest my-react-app -- --template react-ts

Component files use the .tsx extension instead of .jsx (TypeScript plus JSX), and plain modules with no JSX use .ts instead of .js. Everything else about the setup works the way it always has; Vite compiles the types away and ships plain JavaScript to the browser.

One gotcha comes with that: type errors surface in your editor and fail npm run build, which runs tsc -b before bundling, but the dev server strips the annotations without checking them. npm run dev will serve a project full of type errors, so an app that runs is not proof the types pass.

Typing state

useState usually types itself. The hook infers its type from the initial value you pass, so state created with a real starting value needs no annotation at all:

tsx
const [currentWord, setCurrentWord] = useState(getRandomWord())

If getRandomWord returns a string, currentWord is a string and setCurrentWord only accepts strings. Call setCurrentWord(true) and TypeScript flags it on the spot: a boolean is not assignable where a string is expected. That is the inference doing its job, and for most state you can leave it alone.

Inference breaks down when the initial value is empty or too loose to describe the state's future. An empty array says nothing about what it will hold, so useState([]) infers never[], an array whose elements can never be anything, which makes every push into it an error. This is where you reach for the explicit form: useState is a generic function, and you pass the type argument in angle brackets.

tsx
const [guessedLetters, setGuessedLetters] = useState<string[]>([])

Now the state is an array of strings even though the initial value is an empty array, and both the value and the setter enforce it. The same move handles nullable state, where the value starts as null and only later becomes something real:

tsx
type Word = { text: string; difficulty: number }

const [selectedWord, setSelectedWord] = useState<Word | null>(null)

The union Word | null tells TypeScript both shapes this state can legally hold. Every read of selectedWord now forces you to handle the null case before touching .text, which turns a classic runtime crash into a compile-time nudge. The mechanics of state itself are unchanged; TypeScript only pins down what the state is allowed to contain.

Typing component props

Props arrive as one object, so typing them means annotating that object. For a component with one or two props, an inline annotation works:

tsx
function ConfettiContainer({ isGameWon }: { isGameWon: boolean }) {
  // ...
}

Inline types get messy as props multiply. The standard pattern is a named type alias, conventionally called ComponentNameProps, declared above the component:

tsx
type GameStatusProps = {
  isGameWon: boolean
  wrongGuessCount: number
  message?: string
}

function GameStatus({ isGameWon, wrongGuessCount, message }: GameStatusProps) {
  // ...
}

The ? on message marks it optional: parents may omit it, and inside the component its type is string | undefined. An interface GameStatusProps { ... } declaration works in exactly the same position; for typing props the two are interchangeable, and the course sticks with type aliases for consistency with the custom types it builds elsewhere.

If a component accepts children, type it as ReactNode, the broad type covering everything React can render: elements, strings, numbers, fragments, arrays of all of these.

tsx
import { type ReactNode } from 'react'

type CardProps = {
  title: string
  children: ReactNode
}

How children flow through a component is covered in Children and composition; ReactNode is the type that describes them. Everything about how props behave at runtime stays the same. The type annotation adds the contract on top.

Typing function props

Components often receive functions as props: a click handler, a callback that reports a value upward. The type for a function prop uses arrow syntax: the parameter list with its types, an arrow, and the return type.

tsx
type NewGameButtonProps = {
  startNewGame: () => void
}

type LetterButtonProps = {
  letter: string
  onGuess: (value: string) => void
}

() => void describes a function taking no arguments and returning nothing, the shape of most event-style handlers. (value: string) => void says the child will call the function with a string, so the parent's implementation must accept one. Pass a handler whose parameter types don't match and the error appears at the JSX call site, in the parent, at compile time.

The check makes two allowances, though, and both look like the type checker letting something slip. A handler may declare fewer parameters than the type lists, so onGuess={() => setOpen(true)} satisfies (value: string) => void. And () => void accepts a function that returns a value, so startNewGame={async () => { await saveScore() }} type-checks even though it hands back a promise nothing awaits. The compiler catches wrong parameter types rather than every difference in shape.

Return types and derived values

Hover over a function component and TypeScript already knows it returns React.JSX.Element, inferred from the JSX in the return statement. (The JSX namespace lives inside the react module now instead of being a global, so writing the annotation by hand means importing it.)

You can leave that inference alone, and plenty of codebases do. Annotating the return type is a choice about strictness: it guarantees the component always returns a single element, which some teams value in larger codebases. That is a stricter promise than React makes, since a component may legally return a string, a number, an array, or null, and a bare JSX.Element annotation rejects all of those until you widen it.

tsx
import { type JSX } from 'react'

function Header(): JSX.Element {
  return <h1>Assembly: Endgame</h1>
}

function ConfettiContainer({ isGameWon }: { isGameWon: boolean }): JSX.Element | null {
  if (!isGameWon) return null
  return <Confetti />
}

ConfettiContainer sometimes renders nothing by returning null, the same move used in conditional rendering, so its annotated return type is the union JSX.Element | null. The same habit extends to values derived inside the component: an arrow function's return type tacks on after its parameter parentheses, and a map over data producing JSX yields a JSX.Element[]. Most of these annotations restate what inference already concluded, so treat them as optional documentation. The ones that consistently earn their keep sit at boundaries: props, empty or nullable state, and functions whose signatures other components depend on.

Importing shared types

Types are exported and imported like any other binding, which keeps one definition serving the whole app. Data modules commonly export their shape alongside the data:

tsx
// languages.ts
export type Language = {
  name: string
  backgroundColor: string
  color: string
}

// LanguageChips.tsx
import { type Language } from './languages'

type LanguageChipsProps = {
  languages: Language[]
}

The type keyword in the import marks it as type-only, so it disappears entirely from the compiled JavaScript. Once a type like Language lives in one place, every component that touches that data imports the same contract, and changing the shape in one file surfaces every call site that needs updating.

Event objects are the place typing bites first. An inline handler gets its event type for free, because React knows what an <input> passes to onChange, so event in onChange={event => setGuess(event.target.value)} is already typed. Extract that handler into a named function and the context disappears: the parameter becomes an implicit any, and the template's strict type checking turns that into an error. Annotate it with the React event type that matches the element.

tsx
function GuessInput() {
  const [guess, setGuess] = useState('')

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    setGuess(event.target.value)
  }

  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setGuess('')
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={guess} onChange={handleChange} />
    </form>
  )
}

The element type in the angle brackets is what makes event.target.value a string; put HTMLElement there instead and target carries no value at all. The same shape covers the rest: React.MouseEvent<HTMLButtonElement> for clicks, React.KeyboardEvent<HTMLInputElement> for key presses. Each is also importable by name, as in import { type ChangeEvent } from 'react'. What the handlers do at runtime is events and forms territory; the annotation only names what React hands them.

Older React + TypeScript code types components as const Header: React.FC = () => .... React.FC annotates the whole function rather than its return value, and it fell out of favor for concrete reasons: historically it silently added children to every component's props whether or not the component accepted any, and it complicates generic components. The current convention is the one this chapter uses, a plain function with typed props and an inferred or annotated JSX.Element return.

Prefer deriving types over re-declaring them. When a component needs part of an existing shape, utility types keep the single source of truth intact: a chip's inline style object that carries the color fields is Pick<Language, 'backgroundColor' | 'color'>. Pick is a whitelist, so a field added to Language later cannot land in the object you hand to style. Reach for Omit on the opposite job, stripping a known key from a props type, where inheriting whatever the source type grows is the behavior you want.

The same instinct applies to DOM-heavy wrappers: ComponentProps<'button'> from React pulls in every prop a native button accepts, so a design-system button can extend it instead of hand-listing onClick, disabled, and friends.

Each annotation policy fails in its own way, which is the part worth planning around. Annotate everything and every refactor drags a diff through files that only restate what inference already knew, so those annotations fall behind the code and start describing a shape it no longer has. Annotate only the seams and a bad inference, an any leaking out of an untyped dependency, spreads quietly through local values until it meets a boundary that rejects it.

The second failure is cheaper to catch, because the boundary is where the annotation already sits, so make annotate-the-seams the default and add a local annotation the moment a derived value's inferred type surprises you.

JunoType the seams, infer the rest TypeScript is like labeling the boxes you pass between components.

State created with a real starting value labels itself, and when the starting value is empty or null you write the label yourself, like useState<string[]>([]). Props get a small named type such as GameStatusProps listing each prop and its type.

Once the labels are on, your editor warns you the moment something of the wrong shape gets passed, before you even run the app.

JunoType the seams, infer the rest Let useState infer from real initial values and pass a generic for the empty and nullable cases, like useState<Word | null>(null).

Give each component a ComponentNameProps alias, mark optionals with ?, type children as ReactNode, and write function props as signatures like (value: string) => void. An extracted event handler needs its parameter annotated, as in React.ChangeEvent<HTMLInputElement>, because only inline handlers get that type for free.

Share types by exporting them from the module that owns the data and importing them with import { type Language }.

JunoType the seams, infer the rest Annotate the public surface, props and exported signatures, and let inference handle private derived values.

Skip React.FC in favor of plain functions with typed props and a JSX.Element or JSX.Element | null return where you want the guarantee. Derive instead of duplicating: Pick and ComponentProps<'button'> keep one source of truth, so shape changes surface as compile errors at every affected call site.

That's the handbook. What started as a function returning a bit of markup is now a full toolkit: components and state, effects and data, reusable component patterns, routing, a rendering model to reason about performance with, and a type layer that catches mistakes at the seams before anyone else meets them. Pick a project you actually want to exist, scaffold it with npm create vite@latest, and open these chapters back up as the project starts asking for them.