Function vs Class Components
React has two ways to write a component: as a plain function or as an ES2015 class. Both render UI, but since hooks landed in React 16.8 the function style has become the default for new code, and the official docs now teach functions exclusively. Understanding the difference matters because you will still meet class components in older codebases, error boundaries, and migration work.
The two styles at a glance
A class component extends React.Component, stores data in this.state, and exposes a render() method plus lifecycle hooks like componentDidMount. A function component is just a function that returns JSX; state and side effects come from hooks such as useState and useEffect. The function form has less ceremony, no this binding, and reuses logic through custom hooks instead of inheritance or wrapper components.
The same component, both ways
Here is a small counter that loads an initial value, tracks clicks, and updates the document title. First the legacy class version.
import { Component } from "react";
class Counter extends Component {
constructor(props) {
super(props);
this.state = { count: props.start };
this.increment = this.increment.bind(this);
}
componentDidMount() {
document.title = `Count: ${this.state.count}`;
}
componentDidUpdate() {
document.title = `Count: ${this.state.count}`;
}
increment() {
this.setState((prev) => ({ count: prev.count + 1 }));
}
render() {
return (
<button onClick={this.increment}>
Clicked {this.state.count} times
</button>
);
}
}
Note the moving parts: a constructor, a manual this.increment bind, setState with a merge, and the same title logic duplicated across two lifecycle methods.
Now the modern function version with hooks.
import { useState, useEffect } from "react";
function Counter({ start }) {
const [count, setCount] = useState(start);
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
const increment = () => setCount((prev) => prev + 1);
return <button onClick={increment}>Clicked {count} times</button>;
}
Output:
[ Clicked 0 times ] -> [ Clicked 1 times ] -> [ Clicked 2 times ]
(document title updates to "Count: 0", "Count: 1", "Count: 2")
The function version is roughly half the lines. There is no this, no constructor, and the mount and update logic collapse into a single useEffect driven by its dependency array.
Side-by-side comparison
| Concern | Class component | Function component |
|---|---|---|
| Definition | class X extends Component | function X(props) |
| Props | this.props | function argument |
| State | this.state + this.setState | useState hook |
| Side effects | componentDidMount / DidUpdate / WillUnmount | single useEffect |
this binding | required (constructor or arrow fields) | none |
| Logic reuse | HOCs, render props | custom hooks |
| Boilerplate | high (constructor, binds) | low |
| Recommended for new code | no | yes |
Why functions and hooks won
Three problems made classes painful. this was a constant source of bugs — forget a bind and your handler crashes. Related logic was scattered across lifecycle methods: a subscription’s setup lived in componentDidMount and its teardown in componentWillUnmount, far apart in the file. And stateful logic was hard to share — patterns like higher-order components and render props led to deeply nested “wrapper hell”.
Hooks fixed all three. State is a local variable, effects keep their setup and cleanup together, and a custom hook is an ordinary function you can extract and call anywhere.
function useDocumentTitle(title) {
useEffect(() => {
document.title = title;
}, [title]);
}
function Counter({ start }) {
const [count, setCount] = useState(start);
useDocumentTitle(`Count: ${count}`);
return (
<button onClick={() => setCount((c) => c + 1)}>
Clicked {count} times
</button>
);
}
That useDocumentTitle hook is reusable across any component with zero wrapper layers — the kind of clean composition classes never offered.
Tip:
useStatereplaces the whole value you give it; it does not shallow-merge likethis.setState. For object state, spread the previous value yourself:setUser((u) => ({ ...u, name })).
When you still meet classes
Function components cover almost everything, but a few cases remain. Error boundaries must currently be class components, because the getDerivedStateFromError and componentDidCatch lifecycles have no hook equivalent yet. You will also find classes throughout legacy codebases and many older libraries. Knowing how to read this.state, render, and lifecycle methods is essential for maintenance and migration.
class ErrorBoundary extends Component {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error, info) {
console.error(error, info);
}
render() {
if (this.state.hasError) return <h2>Something went wrong.</h2>;
return this.props.children;
}
}
Warning: Do not migrate working class components just to “modernize” them. Convert opportunistically when you are already editing the file, and write all new components as functions.
Best Practices
- Write every new component as a function component with hooks — that is the modern default.
- Reach for custom hooks to share stateful logic instead of HOCs or render props.
- Use a single
useEffect(or a few focused ones) rather than scattering logic across lifecycle methods. - Remember that
useStatereplaces state, whilethis.setStatemerges it — spread objects explicitly. - Keep class components only where required, chiefly error boundaries and untouched legacy code.
- Avoid mixing styles in one component; convert a whole component at once if you migrate it.