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:
npm create vite@latest my-react-app -- --template react-tsComponent 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:
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.
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:
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:
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:
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.
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.
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.
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:
// 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.
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.
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.

