ReactClass Components (Legacy)

Class Components (Legacy)

Before React 16.8, class components were the only way to use state and lifecycle methods. They extend React.Component, define a render() method, and access props and state through this. Class components still work in React 18 — React has not deprecated them — but the React team recommends function components with hooks for all new code.

Understanding class components is useful for reading older codebases, working in large legacy applications, and understanding why hooks were designed the way they were.

Basic Syntax

TSX
import { Component } from 'react'

class Welcome extends Component {
  render() {
    return <h1>Hello, class component!</h1>
  }
}

Every class component must implement the render() method. React calls it to get the JSX description of the UI. The return value follows the same rules as a function component's return value.

Props in Class Components

Props are accessed through this.props. TypeScript users pass the props type as a generic argument to Component:

TSX
import { Component } from 'react'

interface GreetingProps {
  name: string
  age?: number
}

class Greeting extends Component<GreetingProps> {
  render() {
    const { name, age } = this.props
    return (
      <div>
        <h1>Hello, {name}!</h1>
        {age !== undefined && <p>Age: {age}</p>}
      </div>
    )
  }
}

// Usage — same as a function component
<Greeting name="Alice" age={30} />
State in Class Components

Class component state lives in this.state and is updated via this.setState(). You must not mutate this.state directly — React will not detect the change and the UI will not update.

TSX
import { Component } from 'react'

interface CounterState {
  count: number
}

class Counter extends Component<{}, CounterState> {
  // Initialize state — two styles:

  // Style 1: class field (modern, preferred)
  state: CounterState = { count: 0 }

  // Style 2: constructor (older, needed if you use props to derive initial state)
  constructor(props: {}) {
    super(props)  // MUST call super(props) before anything else
    this.state = { count: 0 }
  }

  increment = () => {
    // setState merges the object into existing state — no need to spread
    this.setState({ count: this.state.count + 1 })
  }

  render() {
    return (
      <div>
        <p>Count: {this.state.count}</p>
        <button onClick={this.increment}>Increment</button>
      </div>
    )
  }
}
Warning
Never do this.state.count++ or this.state = { ... }. Direct mutation does not trigger a re-render and causes subtle bugs where the displayed value and the internal value diverge. Always use this.setState().
setState Is Asynchronous

this.setState() does not immediately update this.state. React batches state updates and applies them before the next render. If you need to compute the next state based on the current state, always use the functional form:

TSX
// ✗ BUGGY — this.state.count may be stale
this.setState({ count: this.state.count + 1 })
this.setState({ count: this.state.count + 1 })
// Both reads see the OLD count — you end up with count + 1, not count + 2

// ✓ CORRECT — functional form receives the guaranteed latest state
this.setState(prevState => ({ count: prevState.count + 1 }))
this.setState(prevState => ({ count: prevState.count + 1 }))
// Now correctly results in count + 2
Lifecycle Methods

Class components have lifecycle methods that run at specific moments in a component's life. The three most important ones map directly to useEffect scenarios:

Lifecycle Method

When it runs

Equivalent hook pattern

componentDidMount

After first render (component is in the DOM)

useEffect(() => { … }, [])

componentDidUpdate

After every re-render (when props or state changed)

useEffect(() => { … }, [dep1, dep2])

componentWillUnmount

Just before component is removed from the DOM

useEffect(() => { return () => { … } }, [])

TSX
import { Component } from 'react'

interface TimerState {
  seconds: number
}

class Timer extends Component<{}, TimerState> {
  state: TimerState = { seconds: 0 }
  private intervalId: ReturnType<typeof setInterval> | null = null

  componentDidMount() {
    // Runs once — after the component appears in the DOM
    this.intervalId = setInterval(() => {
      this.setState(prev => ({ seconds: prev.seconds + 1 }))
    }, 1000)
  }

  componentDidUpdate(prevProps: {}, prevState: TimerState) {
    // Runs after every re-render — compare to avoid infinite loops
    if (prevState.seconds !== this.state.seconds && this.state.seconds === 10) {
      console.log('Ten seconds reached!')
    }
  }

  componentWillUnmount() {
    // Runs before removal — clean up to avoid memory leaks
    if (this.intervalId) {
      clearInterval(this.intervalId)
    }
  }

  render() {
    return <p>Elapsed: {this.state.seconds}s</p>
  }
}
The Equivalent Function Component

The Timer above written as a modern function component is significantly shorter and easier to follow — the setup and teardown logic live together in a single useEffect:

TSX
import { useState, useEffect } from 'react'

function Timer() {
  const [seconds, setSeconds] = useState(0)

  useEffect(() => {
    // Setup — equivalent to componentDidMount
    const id = setInterval(() => {
      setSeconds(prev => prev + 1)
    }, 1000)

    // Teardown — equivalent to componentWillUnmount
    return () => clearInterval(id)
  }, []) // Empty array = run only on mount/unmount

  useEffect(() => {
    if (seconds === 10) console.log('Ten seconds reached!')
  }, [seconds]) // Equivalent to componentDidUpdate with a guard

  return <p>Elapsed: {seconds}s</p>
}
Tip
One of the clearest wins of hooks over lifecycle methods is co-location: useEffect keeps the setup and cleanup of the same resource (the interval) in the same place. With class components, setup goes in componentDidMount and cleanup goes in componentWillUnmount — they can be far apart and easy to forget to keep in sync.
When You Still Need Class Components

There is currently one feature only available in class components: Error Boundaries. A component that catches JavaScript errors in its subtree must be a class component (at least for now — a hook-based alternative is being developed):

TSX
import { Component, ErrorInfo, ReactNode } from 'react'

interface Props { children: ReactNode }
interface State { hasError: boolean }

class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false }

  static getDerivedStateFromError(): State {
    return { hasError: true }
  }

  componentDidCatch(error: Error, info: ErrorInfo) {
    console.error('Caught error:', error, info.componentStack)
  }

  render() {
    if (this.state.hasError) {
      return <h2>Something went wrong.</h2>
    }
    return this.props.children
  }
}
Class Component Checklist
  • Extend React.Component (or React.PureComponent for shallow-prop-equality optimization)

  • Always call super(props) as the first line of the constructor

  • Never mutate this.state directly — always use this.setState()

  • Use the functional form of setState when next state depends on current state

  • Clean up subscriptions, timers, and event listeners in componentWillUnmount

  • Wrap legacy class subtrees in an Error Boundary — hooks cannot catch render errors

Note
If you are starting a new project or adding a new component to an existing project, write function components with hooks. Class components are fully supported but they are the past, not the future of React.