React and TypeScript were made for each other. Components are functions, props are object shapes, and state transitions are exactly the kind of “one of several cases” logic that types model well. The payoff is immediate: misspelled prop names, wrong event handlers, and impossible states all become compile errors instead of blank screens.
This lesson covers everyday React typing — props, events, hooks, context — and the two patterns that raise the most quality per line: useReducer with discriminated unions and forms typed from Zod schemas.
Setup
npm create vite@latest client -- --template react-tsThe template ships two tsconfigs: one for browser code, one for tooling — both strict. Everything from the previous lesson applies; you are just adding JSX to the type system.
Typing props
An interface or type alias describes the shape of a component's props, and TypeScript enforces it at every call site:
interface MovieCardProps {
movie: { id: string; title: string; year: number }
highlighted?: boolean
onSelect: (id: string) => void
children?: React.ReactNode
}
export function MovieCard({ movie, highlighted = false, onSelect, children }: MovieCardProps) {
return (
<article className={highlighted ? 'ring-2 ring-emerald-400' : ''}>
<h3>{movie.title}</h3>
<p>{movie.year}</p>
<button onClick={() => onSelect(movie.id)}>Select</button>
{children}
</article>
)
}<MovieCard movie={{ id: '1', title: 'Dune', year: 2021 }} onSelect={setSelectedId} />
<MovieCard movie={{ id: '1', title: 'Dune' }} onSelect={setSelectedId} />
// ~~~~~~~~~~~~~~~~ missing `year`Two rules that save teams real time:
- Prefer
typefor props (no meaningful difference) but stay consistent. - Never type
childrenasReact.ReactNodewhen it is the only prop and the component takes nothing else —{ children }: { children: React.ReactNode }is fine, but if the component also wraps native elements, extend their props instead:
type ButtonProps = React.ComponentProps<'button'> & {
variant?: 'primary' | 'ghost'
}
export function Button({ variant = 'primary', className, ...rest }: ButtonProps) {
return <button className={`btn ${variant} ${className ?? ''}`} {...rest} />
}Now onClick, disabled, type, and every other button attribute is typed — no hand-maintained list.
Events
Inline handlers infer the event type automatically:
<input onChange={(e) => setQuery(e.target.value)} /> // e: React.ChangeEvent<HTMLInputElement>
<form onSubmit={(e) => { e.preventDefault(); save() }} />You need explicit types only when extracting:
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value)
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
await save()
}The common ones: ChangeEvent, FormEvent, MouseEvent, KeyboardEvent, FocusEvent — all parameterized by the element type. If you find yourself fighting an event type, hovering the handler in your editor reveals what React actually expects.
State and hooks
useState infers from the initial value, which is usually what you want:
const [count, setCount] = useState(0) // number
const [query, setQuery] = useState('') // string
const [selectedId, setSelectedId] = useState<string | null>(null)Watch the classic pitfall:
const [user, setUser] = useState(null) // inferred: null — you can never set a user!
const [good, setGood] = useState<User | null>(null)When initial and future values differ, annotate the full union — or ideally, derive the type:
const [user, setUser] = useState<User | null>(null)
const [movies, setMovies] = useState<Movie[]>([])Refs get their element type; React 19 allows ref directly in function components (no forwardRef wrapper needed):
import { useRef } from 'react'
export function SearchBox({ ref }: { ref?: React.Ref<HTMLInputElement> }) {
const inner = useRef<HTMLInputElement>(null)
const inputRef = ref ?? inner
return <input ref={inputRef} type="search" />
}useRef<HTMLInputElement>(null) yields RefObject<HTMLInputElement | null> — inputRef.current?.focus() uses the optional chain because the element may not be mounted yet.
Context is where typing pays off, because consumers otherwise see any or undefined shapes. The standard pattern: a possibly-null context plus a hook that throws — the same shape as currentUser from the Express lesson:
import { createContext, useContext, useState } from 'react'
interface AuthContextValue {
user: User | null
token: string | null
login: (email: string, password: string) => Promise<void>
logout: () => void
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [token, setToken] = useState<string | null>(null)
// ...login/logout implementations
const value: AuthContextValue = { user, token, login, logout: () => setUser(null) }
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext)
if (!context) throw new Error('useAuth must be used inside AuthProvider')
return context // never null for consumers
}Discriminated unions in components
The three-state UI — loading, error, ready — is the canonical case. Model it as one union instead of three loose booleans:
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; error: string }
| { status: 'ready'; data: T }
function MovieList() {
const [state, setState] = useState<FetchState<Movie[]>>({ status: 'idle' })
useEffect(() => {
setState({ status: 'loading' })
fetchMovies()
.then((data) => setState({ status: 'ready', data }))
.catch((error: unknown) =>
setState({ status: 'error', error: error instanceof Error ? error.message : 'Unknown error' })
)
}, [])
switch (state.status) {
case 'idle':
case 'loading':
return <Skeleton />
case 'error':
return <p role="alert">{state.error}</p> // `error` exists only here
case 'ready':
return <ul>{state.data.map((m) => <li key={m.id}>{m.title}</li>)}</ul>
}
}Because the union has no overlap, state.data is unreachable in the error branch and state.error is unreachable in the ready branch — impossible states are unrepresentable. Compare that with isLoading: boolean, error: string | null, data: Movie[] | null, where isLoading && error && data is an expressible nonsense state you must remember to exclude.
The same pattern types useReducer actions:
type FilterState = { genre: string; year: number | null; sort: 'title' | 'year' }
type FilterAction =
| { type: 'genre/set'; genre: string }
| { type: 'year/set'; year: number | null }
| { type: 'sort/set'; sort: FilterState['sort'] }
| { type: 'reset' }
function filterReducer(state: FilterState, action: FilterAction): FilterState {
switch (action.type) {
case 'genre/set': return { ...state, genre: action.genre }
case 'year/set': return { ...state, year: action.year }
case 'sort/set': return { ...state, sort: action.sort }
case 'reset': return { genre: '', year: null, sort: 'title' }
default: {
const exhaustive: never = action
throw new Error(`Unhandled action: ${JSON.stringify(exhaustive)}`)
}
}
}Mistyped dispatches — dispatch({ type: 'year/set', year: '1999' }) — are caught at compile time, and adding an action type forces the reducer to handle it.
Forms with React Hook Form + Zod
The biggest React typing win is deriving form types from the same Zod schema the server uses. With React Hook Form's zodResolver, field names, values, and validation rules all come from one source:
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const movieFormSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
year: z.coerce.number().int().min(1888),
genres: z.array(z.string()).default([]),
})
type MovieFormInput = z.infer<typeof movieFormSchema>
export function MovieForm({ onSubmit }: { onSubmit: (input: MovieFormInput) => Promise<void> }) {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<MovieFormInput>({
resolver: zodResolver(movieFormSchema),
defaultValues: { title: '', year: 2024, genres: [] },
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('title')} />
{errors.title && <p role="alert">{errors.title.message}</p>}
<input type="number" {...register('year')} />
{errors.year && <p role="alert">{errors.year.message}</p>}
<button disabled={isSubmitting}>Save</button>
</form>
)
}register('titel') fails to compile, onSubmit receives exactly MovieFormInput, and errors.title is typed — no guessing about error shapes. Note z.coerce.number(): form inputs are strings, and coercion is how a text field satisfies a number schema honestly.
Try it
Drop into App.tsx of a Vite react-ts app, run npm run dev, and try changing the reducer action types to watch compile errors.
import { useReducer } from 'react'
type CounterState = { count: number; step: number }
type CounterAction =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'step/set'; step: number }
| { type: 'reset' }
function reducer(state: CounterState, action: CounterAction): CounterState {
switch (action.type) {
case 'increment': return { ...state, count: state.count + state.step }
case 'decrement': return { ...state, count: state.count - state.step }
case 'step/set': return { ...state, step: action.step }
case 'reset': return { count: 0, step: 1 }
default: {
const exhaustive: never = action
throw new Error(`Unhandled action: ${JSON.stringify(exhaustive)}`)
}
}
}
export default function App() {
const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 })
return (
<main style={{ padding: 24, fontFamily: 'system-ui' }}>
<h1>{state.count}</h1>
<button onClick={() => dispatch({ type: 'decrement' })}>−</button>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<label>
Step
<input
type="number"
value={state.step}
onChange={(e) => dispatch({ type: 'step/set', step: Number(e.target.value) })}
/>
</label>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</main>
)
}Check your understanding
What type should the change handler's parameter have when extracting it from an input's onChange?
A fetch state is typed as the union { status: 'loading' } | { status: 'error'; error: string } | { status: 'ready'; data: Movie[] }. Which bug does this prevent compared with separate isLoading/error/data fields?
Convert the movies client to TypeScript
Bring your React client under strict TypeScript while keeping behavior identical.
- Rename and convert: rename components to
.tsx, run the build, and work through compiler errors one file at a time rather than sprinklingany. - Shared type: define the
Movietype once — in atypes/movie.tsor, better, import it from the shared schemas you created in the Express lesson if your setup allows. One definition, both apps. - Components: give every component a props type; use
React.ComponentPropsfor components that wrap native elements (Button,Input); type every extracted event handler. - API client: type the fetch wrapper generically —
async function request<T>(path: string, options?: RequestInit): Promise<T>— and give each endpoint function a concrete return type (Promise<Movie[]>,Promise<Movie>). - Filters with a reducer: move genre/year/sort into a
useReducerwith a discriminated union of actions and an exhaustiveness check. - Forms: rebuild the create/edit form with React Hook Form + Zod, inferring
MovieFormInputfrom the schema; wire the same schema to the server if you have not already. - State unions: model the list screen state as
FetchState<Movie[]>and delete anyisLoading/hasErrorbooleans. - Cleanup enforced: enable
@typescript-eslint/no-explicit-anyas an error and runnpx tsc --noEmit— both must be clean. - Refactor drill: rename
yeartoreleaseYearacross the shared type; the compiler should list every site that needs updating. Fix them, then diff your change — that list is exactly what you would have found via runtime bugs.
Summary
- Props, events, refs, and context all get real types;
React.ComponentProps<'button'>is the fastest way to type wrapper components. useStateinfers, but mixed initial values need the full union annotated;useRef<HTMLInputElement>(null)types DOM refs, and React 19 takesrefas a plain prop.- Discriminated unions model fetch states and reducer actions so impossible states cannot be written;
neverchecks force updates when variants change. - A context-plus-throwing-hook pair makes consumers see a guaranteed type, never
null. - React Hook Form +
zodResolvertypes registration, values, and errors from the same schema the server validates with. - Client-side validation is UX; the server remains the source of truth.
Next: connect both sides of the stack with shared types — a typed API client, contract-first development, and an honest look at when tRPC or codegen is worth it.