ReactTyping Props & State

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:

TSX
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:

TSX
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>
Tip
`ReactNode` is the widest children type — it accepts JSX, strings, numbers, arrays, fragments, portals, and null/undefined. Use `ReactElement` when you need exactly one JSX element (no strings or null).
Style Props with CSSProperties

When a component accepts an inline style prop, use React.CSSProperties for full autocomplete on every CSS property:

TSX
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:

TSX
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:

TSX
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:

TSX
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:

TSX
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>
  )
}
Note
Notice the `[name]: value` computed property key pattern — this lets one `handleChange` function update any field by name. TypeScript is happy here because `name` comes from `e.currentTarget.name` which is a string, and `FormData` has string values.
Quick Reference

Pattern

Type

Required string prop

name: string

Optional number prop

count?: number

React children

children: React.ReactNode

Inline style

style?: React.CSSProperties

Click handler

onClick?: () => void

String union

variant: 'primary' | 'secondary'

State — string

useState<string>('')

State — nullable object

useState<User | null>(null)

State — array

useState<string[]>([])

Warning
Avoid using `any` as a prop or state type. It silently disables all TypeScript checking for that value and all values derived from it. If you genuinely do not know the type, start with `unknown` — it is safe because TypeScript forces you to narrow it before use.