Back to React
React
easy
junior

What is the practical difference between functional and class components in React today?

Functional components are the modern default — hooks replaced class lifecycle methods, with cleaner composition and smaller bundles. Classes still appear in legacy code and ONE place hooks can't reach: error boundaries (componentDidCatch).

6 min read·~10 min to think through

Both render UI from props and state. Class components were the original API; functional components became fully equivalent (and then strictly better) when hooks shipped in React 16.8. Modern code uses functional everywhere; the question tests whether you can articulate why and the one corner case where classes are still required.

Side-by-side mapping.

ClassFunctional
this.state + this.setStateuseState
componentDidMountuseEffect(() => { … }, [])
componentDidUpdateuseEffect(() => { … }, [deps])
componentWillUnmountuseEffect(() => () => { … }, [])
shouldComponentUpdateReact.memo(Component, compareFn)
getDerivedStateFromPropsderive in render or useState(() => fn(props)) lazy init
getSnapshotBeforeUpdateuseLayoutEffect
componentDidCatch / getDerivedStateFromErrorno hook equivalent — class only

Why functional won.

  1. Composition. Custom hooks let you extract and share stateful logic across components without HOCs or render props. Class components had no clean way to do this — mixins failed and HOC stacks created wrapper hell.
  2. Less boilerplate. No this, no constructor, no bind. The "method bind in constructor" pattern was a constant footgun for juniors.
  3. Co-located lifecycle by concern. Class components scatter related logic across componentDidMount / componentDidUpdate / componentWillUnmount. With useEffect, the subscription, dependency tracking, and cleanup live together.
  4. Better tree-shaking. Functional components are plain functions; bundlers compress them more aggressively than ES classes.
  5. Concurrent React. New features (useTransition, useDeferredValue, useId, useSyncExternalStore) ship as hooks; classes don't get them.

The one place classes are still required: error boundaries.

tsx
class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { error: Error | null }> {
  state = { error: null as Error | null };
  static getDerivedStateFromError(error: Error) { return { error }; }
  componentDidCatch(error: Error, info: React.ErrorInfo) { logToSentry(error, info); }
  render() { return this.state.error ? <FallbackUI /> : this.props.children; }
}

There is no hook for catching errors in descendants. Every codebase has one or two of these, often imported from react-error-boundary (which still wraps a class internally).

Differences that trip people up.

  • this.state is merged on setState; useState is replaced. setCount(prev => ({ ...prev, x: 1 })) is the functional equivalent of this.setState({ x: 1 }).
  • this capture in handlers — class methods passed as callbacks need .bind(this) (constructor) or arrow methods. Functional has no this so closures handle this naturally — but closures bring their own stale-closure footgun.
  • useEffect vs lifecycle timinguseEffect runs after paint; componentDidMount ran after commit but before paint. Use useLayoutEffect if you need the synchronous timing.
  • StrictMode double-invocation in dev applies to both, but functional/effects show it more visibly because effects run twice while constructors do too — both surface lifecycle bugs.

Migration posture. No, you don't need to migrate working class components. They aren't deprecated. New code: functional. Touched code: convert if it pays off (heavy refactor with shared logic to extract); leave alone if it works.

One-liner answers for fast questions:

  • "Difference?" → Functional + hooks is the modern default; classes are legacy except for error boundaries.
  • "Performance?" → Equivalent. React.memo is the equivalent of PureComponent / shouldComponentUpdate.
  • "When use a class?" → Error boundaries.
  • "Can hooks fully replace Redux?" → Different question; useReducer + Context works for small stores; Redux still wins for cross-cutting middleware (sagas, devtools, persistence).

Code

tsx
// Class
class Counter extends React.Component<{ start: number }, { count: number }> {
  state = { count: this.props.start };
  increment = () => this.setState(s => ({ count: s.count + 1 }));
  componentDidMount() { document.title = "Counter mounted"; }
  componentWillUnmount() { document.title = "Gone"; }
  render() { return <button onClick={this.increment}>{this.state.count}</button>; }
}

// Functional
function Counter({ start }: { start: number }) {
  const [count, setCount] = useState(start);
  useEffect(() => {
    document.title = "Counter mounted";
    return () => { document.title = "Gone"; };
  }, []);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
Same component — class vs functional

Follow-up questions

  • Why are error boundaries still class-only?
  • What's the functional equivalent of shouldComponentUpdate?
  • How does useState differ from this.setState in merging behavior?
  • Are there cases where classes still perform better?

Common mistakes

  • Forgetting to bind class methods → `this` is undefined in handlers.
  • Calling this.setState({...}) expecting a deep merge — only top-level keys merge.
  • Treating useEffect like componentDidMount when component re-mounts (StrictMode dev double-mount).
  • Trying to write an error boundary as a hook.

Performance considerations

  • React.memo + useMemo/useCallback approximate PureComponent + class memoization.
  • Functional components have no inherent perf cost vs classes; differences are about code shape.

Edge cases

  • getSnapshotBeforeUpdate has no exact hook equivalent — useLayoutEffect is the closest.
  • ref forwarding from class differs (works on instance) vs functional (need forwardRef).
  • ContextType (class) vs useContext (functional) — same behavior, different ergonomics.

Real-world examples

  • react-error-boundary, Sentry's withErrorBoundary, react-redux connect (legacy class internals) — still classes under the hood.

Senior engineer discussion

Senior signal: knowing the lifecycle ↔ hook map cold, the error boundary exception, and the merge-vs-replace setState distinction.

Related questions