Mixed lesson · examples marked Try it run here; the project files need Node.js, npm and a real project.
TypeScript adds types to JavaScript. In React it catches mistakes such as a missing prop, a wrong event type or a misspelt property while you type, before the app runs. This lesson shows the patterns you need. All project files below were compiled with TypeScript 6.0.3 (the version in the current Vite template) and built with Vite.
Create a TypeScript project
npm create vite@latest my-app -- --template react-ts
This gives you .tsx files (TypeScript with JSX), a tsconfig.json and a build script that runs tsc -b (the type check) before vite build. Important: Vite itself only strips the types; it does not check them. The check happens in npm run build (and in your editor). Run it often.
Types in the playground
The playground accepts tsx code so you can see the syntax. It removes the types and runs the JavaScript. It does not report type errors; a real project does.
type Props = { name: string };
function Greeting({ name }: Props) {
return <p>Hello, {name} ({name.length} letters)</p>;
}
function App() {
return <Greeting name="Ada" />;
}
Try changing name="Ada" to name={42}. It still runs here. In a TypeScript project the compiler would refuse it.
Typing props
Describe props with a type (or interface). Optional props get a ?; children is typed as ReactNode.
import type { ReactNode } from 'react'
type GreetingProps = {
name: string
excited?: boolean
children?: ReactNode
}
function Greeting({ name, excited = false, children }: GreetingProps) {
return (
<section>
<h2>
Hello, {name}
{excited ? '!' : '.'}
</h2>
{children}
</section>
)
}
export default Greeting
Now mistakes are reported at compile time. This is what the compiler really printed for three wrong uses:
src/bad.tsx(3,19): error TS2741: Property 'name' is missing in type '{}' but required in type 'GreetingProps'.
src/bad.tsx(4,28): error TS2322: Type 'number' is not assignable to type 'string'.
src/bad.tsx(5,39): error TS2322: Type '{ name: string; shout: true; }' is not assignable to type 'IntrinsicAttributes & GreetingProps'.
Property 'shout' does not exist on type 'IntrinsicAttributes & GreetingProps'.
A missing required prop, a wrong type and an unknown prop: three bugs caught without running anything. (In React 19 you do not need React.FC; a plain function with typed props is the usual style.)
Typing state and events
TypeScript infers state types from the initial value. Add the type yourself when the initial value cannot show it, such as null for “not loaded yet”. Events are typed by element:
import { useState, type ChangeEvent } from 'react'
function Counter() {
// The type is inferred from the initial value: number
const [count, setCount] = useState(0)
// Say the type when the initial value cannot show it
const [note, setNote] = useState<string | null>(null)
function handleChange(event: ChangeEvent<HTMLInputElement>) {
setNote(event.target.value || null)
}
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>Add one</button>
<input aria-label="Note" onChange={handleChange} />
<p>Note: {note ?? 'none'}</p>
</div>
)
}
export default Counter
Common event types: ChangeEvent<HTMLInputElement>, FormEvent<HTMLFormElement>, MouseEvent<HTMLButtonElement>, KeyboardEvent<HTMLInputElement>. If you write the handler inline (onChange={(e) => ...}), TypeScript infers e for you, which is why you often do not need to write these types at all.
Shared types and discriminated unions
Put shared shapes in one file. A discriminated union models “one of several states” and lets TypeScript narrow the type for you:
export type Product = {
id: number
name: string
price: number
tags?: string[]
}
// A discriminated union: "status" tells TypeScript which other fields exist
export type LoadState =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'success'; products: Product[] }
Inside a switch on state.status, TypeScript knows exactly which fields exist (you will see it in App.tsx below). It is the safest way to model loading, error and success (Loading States).
Generic components
A generic component works with any item type and keeps the types connected:
import type { ReactNode } from 'react'
type ListProps<T> = {
items: T[]
getKey: (item: T) => string | number
renderItem: (item: T) => ReactNode
}
// A generic component: it works with any item type, and keeps the types connected
function List<T>({ items, getKey, renderItem }: ListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={getKey(item)}>{renderItem(item)}</li>
))}
</ul>
)
}
export default List
List infers T from items, so renderItem receives a properly typed item, with autocomplete.
Typed custom hooks, refs and context
import { useState } from 'react'
export function useToggle(initial = false): [boolean, () => void] {
const [on, setOn] = useState(initial)
return [on, () => setOn((value) => !value)]
}
import { useRef } from 'react'
function FocusInput() {
// null at first; React fills it with the real element after rendering
const inputRef = useRef<HTMLInputElement>(null)
return (
<div>
<input ref={inputRef} aria-label="Name" />
<button onClick={() => inputRef.current?.focus()}>Focus</button>
</div>
)
}
export default FocusInput
import { createContext, useContext, useState, type ReactNode } from 'react'
type Theme = 'light' | 'dark'
type ThemeValue = { theme: Theme; toggle: () => void }
// null by default, so forgetting the provider is caught below
const ThemeContext = createContext<ThemeValue | null>(null)
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('light')
const toggle = () => setTheme((t) => (t === 'light' ? 'dark' : 'light'))
return <ThemeContext value={{ theme, toggle }}>{children}</ThemeContext>
}
export function useTheme(): ThemeValue {
const value = useContext(ThemeContext)
if (value === null) throw new Error('useTheme must be used inside <ThemeProvider>')
return value
}
The guard in useTheme narrows the type: after it, callers get a ThemeValue that is never null.
Putting it together
import Counter from './components/Counter.tsx'
import Greeting from './components/Greeting.tsx'
import List from './components/List.tsx'
import { useToggle } from './hooks/useToggle.ts'
import type { LoadState, Product } from './types.ts'
const products: Product[] = [
{ id: 1, name: 'Keyboard', price: 49 },
{ id: 2, name: 'Mouse', price: 25, tags: ['usb'] },
]
function describe(state: LoadState): string {
switch (state.status) {
case 'loading':
return 'Loading...'
case 'error':
return `Error: ${state.message}` // TypeScript knows "message" exists here
case 'success':
return `${state.products.length} products` // ...and "products" here
}
}
function App() {
const [open, toggle] = useToggle()
return (
<main>
<Greeting name="Ada" excited>
<p>{describe({ status: 'success', products })}</p>
</Greeting>
<List items={products} getKey={(p) => p.id} renderItem={(p) => `${p.name} ($${p.price})`} />
<button onClick={toggle}>{open ? 'Hide' : 'Show'} details</button>
{open && <p>Details are visible</p>}
<Counter />
</main>
)
}
export default App
This whole project passed tsc -b with no errors and built with Vite, and the behaviour was checked in a browser. Note that imports of .tsx and .ts files include the extension, because the template turns on allowImportingTsExtensions.
Settings worth knowing
The template’s tsconfig.app.json enables helpful options: noUnusedLocals and noUnusedParameters report dead code, verbatimModuleSyntax requires import type for type-only imports, erasableSyntaxOnly keeps your TypeScript to syntax that can be simply removed, and "jsx": "react-jsx" gives the modern JSX transform. Leave strictness on; it is what makes TypeScript useful.
Tips for beginners
- Let inference work. Do not annotate everything. Annotate props, shared data and function boundaries.
- Avoid
any. It switches the checker off. Useunknownwhen you truly do not know, then narrow. - Avoid type assertions (
value as Type) unless you really know more than the compiler. - Data from servers is untyped at run time. A type says what you expect, not what you receive. Validate important data (a schema library such as Zod is popular).
- Read the error from the bottom up. The last line usually names the real mismatch.
Key points
- Type props with a
type, let TypeScript infer state, and type events withChangeEvent<...>and friends. - Discriminated unions and generics model states and reusable components safely.
- Vite does not type-check; run
tsc -b(thebuildscript does) and use your editor’s checking.