Module 2: JSX and Components
Class Components: Reading Legacy Code
The class syntax, lifecycle methods mapped to what hooks do today, and the one job only classes can still do.
Nobody should write new React components as classes. Function components with hooks are simpler, shorter, and get all the new features. So why spend time on this?
Because you will open a codebase older than three years and find classes. You'll also meet exactly one feature that still needs a class today: error boundaries. And understanding the class lifecycle makes hooks easier to reason about, since hooks replaced it.
The basic shape#
import { Component } from "react";
class Counter extends Component {
constructor(props) {
super(props);
this.state = { count: 0 };
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
this.setState({ count: this.state.count + 1 });
}
render() {
return (
<button onClick={this.handleClick}>
{this.props.label}: {this.state.count}
</button>
);
}
}
Everything lives on the instance. Props come from this.props, state from this.state, and render() returns the JSX. There's one state object for the whole component, not separate pieces like useState gives you.
Why the bind call exists#
JavaScript methods don't remember what this is. When React calls handleClick as an event handler, it calls it with no object attached, and this becomes undefined. Then this.setState blows up.
You fix it one of three ways:
// 1. bind in the constructor (what we did above)
this.handleClick = this.handleClick.bind(this);
// 2. class field with an arrow function (most common in older code)
handleClick = () => {
this.setState({ count: this.state.count + 1 });
};
// 3. arrow in JSX (works, creates a new function each render)
<button onClick={() => this.handleClick()}>
Arrow functions capture this from where they're written, which is why option 2 works. This binding problem is one of the main reasons the React team moved on to functions.
setState merges, and it's asynchronous-ish#
setState takes an object and merges it into the existing state. You only pass the keys that changed.
this.state = { name: "Asha", age: 30 };
this.setState({ age: 31 }); // name is untouched
That's different from useState, where setting replaces the value entirely.
Updates can be batched, so reading this.state right after calling setState can give you the old value. When the new value depends on the old one, pass a function:
this.setState((prev) => ({ count: prev.count + 1 }));
You'll see the same idea again with useState. It's the same problem in different clothes.
The lifecycle#
Class components have named methods that React calls at specific moments.
MOUNT UPDATE UNMOUNT
───── ────── ───────
constructor (new props / setState) componentWillUnmount
│ │
render render
│ │
componentDidMount componentDidUpdate
class UserProfile extends Component {
state = { user: null };
componentDidMount() {
this.load();
window.addEventListener("resize", this.onResize);
}
componentDidUpdate(prevProps) {
if (prevProps.userId !== this.props.userId) {
this.load(); // props changed, fetch again
}
}
componentWillUnmount() {
window.removeEventListener("resize", this.onResize);
}
async load() {
const res = await fetch(`/api/users/${this.props.userId}`);
this.setState({ user: await res.json() });
}
onResize = () => { /* ... */ };
render() {
return this.state.user ? <h2>{this.state.user.name}</h2> : <p>Loading</p>;
}
}
Notice how the setup logic (fetch + listener) is split across three methods, and the "refetch when the id changes" check is something you had to remember to write by hand. That scattering is the second major reason hooks exist.
How it maps to hooks#
The same component with hooks:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let ignore = false;
fetch(`/api/users/${userId}`)
.then((r) => r.json())
.then((data) => { if (!ignore) setUser(data); });
return () => { ignore = true; };
}, [userId]);
useEffect(() => {
const onResize = () => { /* ... */ };
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return user ? <h2>{user.name}</h2> : <p>Loading</p>;
}
componentDidMount plus componentDidUpdate collapse into one effect that re-runs when userId changes. The cleanup lives right next to the setup. Don't try to map hooks one-to-one onto lifecycle methods, though. Effects are about synchronizing with something, not about "mount" and "update" as moments, and thinking in lifecycle terms leads to bugs. We'll cover that properly in module 4.
The one thing classes still do: error boundaries#
If a component throws during rendering, React unmounts the whole tree by default, and the user sees a blank page. An error boundary catches the error and shows a fallback instead. There's no hook equivalent, so it needs a class.
class ErrorBoundary extends Component {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error, info) {
console.error("Caught:", error, info.componentStack);
}
render() {
if (this.state.hasError) return this.props.fallback ?? <p>Something broke.</p>;
return this.props.children;
}
}
<ErrorBoundary fallback={<p>Chart failed to load.</p>}>
<Chart />
</ErrorBoundary>
In practice, most teams use the small react-error-boundary package instead of writing this by hand, but under the hood it's this class. Error boundaries only catch errors in rendering and lifecycle code, not errors inside event handlers or async callbacks.
Where people get burned#
- Forgetting to bind a method, then getting
Cannot read properties of undefined (reading 'setState'). - Assigning to
this.state.xdirectly. It changes the object but doesn't re-render. - Reading
this.stateimmediately aftersetStateand expecting the new value. - Using
setStateinsiderender, which causes an infinite loop. - Forgetting
componentWillUnmountcleanup, which leaves listeners and timers running after the component is gone.
Practice#
- Convert the
Counterclass above into a function component withuseState. Count how many lines you removed. - Write a class component with a
setIntervalincomponentDidMountand clean it up properly. Then rewrite it withuseEffect. - Create an
ErrorBoundary, wrap a component that throws on purpose (throw new Error("boom")in the render body), and confirm the fallback appears instead of a blank page. - Call
setStatethree times in a row withthis.state.count + 1, then try again with the function form. Compare results.