Typing Props & State
Once TypeScript is configured, the most frequent task is annotating component props and state. Getting this right unlocks autocomplete, inline documentation, and compile-time safety for every component interaction in your codebase. This page covers the full toolkit — from simple prop interfaces to complex generic state shapes.
Defining Props with an Interface
The standard pattern is to declare a named interface directly above the component that uses it. Colocation makes the contract immediately visible:
interface AlertProps {
message: string // required — no ?
severity: 'info' | 'warning' | 'error'
onClose?: () => void // optional — has ?
}
function Alert({ message, severity, onClose }: AlertProps) {
return (
<div className={`alert alert--${severity}`}>
<span>{message}</span>
{onClose && <button onClick={onClose}>×</button>}
</div>
)
}Props without ? are required — TypeScript will error if a caller omits them. Props with ? are optional — their type is automatically T | undefined, so you must guard before using them.
The children Prop
children is not automatically added in modern React + TypeScript. If your component accepts children, declare it explicitly with React.ReactNode:
import { ReactNode } from 'react'
interface CardProps {
title: string
children: ReactNode // anything React can render: string, element, array, null
}
function Card({ title, children }: CardProps) {
return (
<div className="card">
<h3>{title}</h3>
<div className="card__body">{children}</div>
</div>
)
}
// Usage — children passed between tags
<Card title="Profile">
<Avatar src="/me.jpg" />
<p>Software Engineer</p>
</Card>Style Props with CSSProperties
When a component accepts an inline style prop, use React.CSSProperties for full autocomplete on every CSS property:
import { CSSProperties } from 'react'
interface BoxProps {
style?: CSSProperties
children: ReactNode
}
function Box({ style, children }: BoxProps) {
return <div style={style}>{children}</div>
}
// TypeScript knows backgroundColor, padding, borderRadius, etc.
<Box style={{ backgroundColor: '#f5f5f5', padding: 16 }}>
Hello
</Box>
// ✗ TypeScript error: 'colour' does not exist in CSSProperties
<Box style={{ colour: 'red' }}>oops</Box>Union Types for Variants
Union types are the best way to restrict a prop to a fixed set of values. They produce excellent autocomplete and clear error messages:
interface ButtonProps {
label: string
variant: 'primary' | 'secondary' | 'ghost' | 'danger'
size?: 'sm' | 'md' | 'lg'
onClick?: () => void
}
function Button({ label, variant, size = 'md', onClick }: ButtonProps) {
return (
<button
className={`btn btn--${variant} btn--${size}`}
onClick={onClick}
>
{label}
</button>
)
}
<Button label="Save" variant="primary" />
<Button label="Cancel" variant="ghost" size="sm" />
// ✗ TypeScript error: '"danger-zone"' is not assignable to type '"primary" | "secondary" | ...'
<Button label="Oops" variant="danger-zone" />Extending HTML Element Props
A common pattern is building a wrapper around a native HTML element that passes through all standard attributes (className, disabled, aria-*, data attributes, etc.). Use React.ComponentProps to inherit them:
import { ComponentProps } from 'react'
// Inherit ALL props that a native <button> accepts
interface ButtonProps extends ComponentProps<'button'> {
variant?: 'primary' | 'secondary'
loading?: boolean
}
function Button({ variant = 'primary', loading, children, ...rest }: ButtonProps) {
return (
<button
className={`btn btn--${variant}`}
disabled={loading || rest.disabled}
{...rest}
>
{loading ? 'Loading...' : children}
</button>
)
}
// Now all native button props work automatically
<Button variant="primary" type="submit" aria-label="Save changes" onClick={save}>
Save
</Button>Typing useState
TypeScript can usually infer the type of useState from the initial value. When the initial value is ambiguous or null, provide the type explicitly:
import { useState } from 'react'
// TypeScript infers string — no annotation needed
const [name, setName] = useState('')
// TypeScript infers number
const [count, setCount] = useState(0)
// Must be explicit — initial null doesn't tell TS what User looks like
interface User {
id: number
name: string
email: string
}
const [user, setUser] = useState<User | null>(null)
// Array state — explicit type ensures setTags([]) works correctly
const [tags, setTags] = useState<string[]>([])
// After fetch:
setUser({ id: 1, name: 'Alice', email: 'alice@example.com' })
// ✗ TypeScript error: 'role' does not exist on type 'User'
setUser({ id: 2, name: 'Bob', email: 'b@b.com', role: 'admin' })A Fully Typed Stateful Form
Putting it all together: a registration form component with typed props, typed state, and typed event handlers:
import { useState, ChangeEvent, FormEvent } from 'react'
interface FormData {
username: string
email: string
password: string
}
interface RegisterFormProps {
onSuccess: (user: FormData) => void
initialEmail?: string
}
function RegisterForm({ onSuccess, initialEmail = '' }: RegisterFormProps) {
const [form, setForm] = useState<FormData>({
username: '',
email: initialEmail,
password: '',
})
const [error, setError] = useState<string | null>(null)
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.currentTarget
setForm(prev => ({ ...prev, [name]: value }))
}
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (form.password.length < 8) {
setError('Password must be at least 8 characters')
return
}
setError(null)
onSuccess(form)
}
return (
<form onSubmit={handleSubmit}>
{error && <p className="error">{error}</p>}
<input name="username" value={form.username} onChange={handleChange} />
<input name="email" value={form.email} onChange={handleChange} />
<input name="password" value={form.password} onChange={handleChange} type="password" />
<button type="submit">Register</button>
</form>
)
}Quick Reference
Pattern | Type |
|---|---|
Required string prop |
|
Optional number prop |
|
React children |
|
Inline style |
|
Click handler |
|
String union |
|
State — string |
|
State — nullable object |
|
State — array |
|