Episode 2 — React Frontend Architecture NextJS / 2.4 — React Lifecycle Methods
2.4.a — Class Components Lifecycle
In one sentence: Class components are ES6 classes that extend
React.Component, manage state throughthis.state, and expose explicit lifecycle methods — and while modern React favours functions, understanding classes is essential for maintaining legacy code and using error boundaries.
Navigation: ← Overview · Next → React's Lifecycle Methods
Table of Contents
- What Are Class Components?
- ES6 Class Syntax Refresher
- Your First Class Component
- The Constructor Method
- State in Class Components
- The
thisBinding Problem - setState — Updating State in Classes
- The Render Method
- Class Fields Syntax (Modern Shorthand)
- Class vs Functional Components — Side by Side
- The Three Lifecycle Phases
- Component Instance vs Function Call
- When You Still Encounter Class Components
- Converting a Class Component to a Function
- Common Mistakes with Class Components
- Key Takeaways
1. What Are Class Components?
Before React 16.8 (February 2019), if you needed state or lifecycle methods, you had to use a class component. Functional components were "stateless" — they received props and returned JSX, nothing more.
Class components changed that by providing:
| Feature | How |
|---|---|
| Local state | this.state + this.setState() |
| Lifecycle hooks | componentDidMount, componentDidUpdate, etc. |
| Instance methods | Regular methods on the class |
| Refs | this.myRef = React.createRef() |
| Error boundaries | componentDidCatch + getDerivedStateFromError |
Timeline
2013 ─── React launched ─── Class components only for state
│
2015 ─── Stateless functional components introduced (just for UI)
│
2018 ─── React 16.6: React.memo, React.lazy
│
2019 ─── React 16.8: HOOKS released ─── Functional components get state + lifecycle
│
2020+ ── Community shifts to functional-first
│
2024+ ── Class components still used for Error Boundaries
(no hook equivalent for componentDidCatch yet)
Bottom line: You write new code with functions + hooks. But you read and maintain class code in every company with a codebase older than 2019.
2. ES6 Class Syntax Refresher
React class components use JavaScript ES6 classes. Let's review the syntax before applying it to React.
Basic class
class Animal {
// Constructor — called when you do `new Animal(...)`
constructor(name, sound) {
this.name = name; // instance property
this.sound = sound;
}
// Method — shared across all instances via prototype
speak() {
return `${this.name} says ${this.sound}`;
}
}
const dog = new Animal('Rex', 'Woof');
console.log(dog.speak()); // "Rex says Woof"
Inheritance with extends
class Dog extends Animal {
constructor(name) {
super(name, 'Woof'); // MUST call super() before using `this`
this.tricks = [];
}
learn(trick) {
this.tricks.push(trick);
}
}
const rex = new Dog('Rex');
rex.learn('shake');
console.log(rex.speak()); // "Rex says Woof" — inherited method
Key rules
| Rule | Why |
|---|---|
super() must be called before this in subclass constructors | The parent constructor creates the instance first |
| Methods are on the prototype, not the instance | Memory efficient — shared, not copied per instance |
this refers to the instance | But its value depends on how the method is called |
static methods belong to the class, not instances | Animal.create() not rex.create() |
The this problem preview
class Timer {
constructor() {
this.seconds = 0;
}
tick() {
this.seconds++; // `this` must be the Timer instance
console.log(this.seconds);
}
}
const t = new Timer();
t.tick(); // ✅ Works — `this` is `t`
const fn = t.tick;
fn(); // ❌ TypeError — `this` is undefined (strict mode)
setTimeout(t.tick, 1000); // ❌ Same problem — `this` is lost
This is the exact problem that plagues React class components — and why we'll spend a whole section on it.
3. Your First Class Component
import React, { Component } from 'react';
class Welcome extends Component {
render() {
return <h1>Hello, {this.props.name}!</h1>;
}
}
// Usage
<Welcome name="Priya" />
Anatomy
┌─────────────────────────────────────────────────────┐
│ class Welcome extends Component { │
│ │ │
│ ├── render() { ← REQUIRED method │
│ │ return <JSX />; ← Must return React │
│ │ } element or null │
│ │ │
│ └── this.props.name ← Props accessed via │
│ this.props │
│ } │
└─────────────────────────────────────────────────────┘
Minimum requirements for a class component
- Import
Componentfrom'react'(or useReact.Component) - Extend
Component - Define a
render()method that returns JSX
Everything else — constructor, state, lifecycle methods — is optional.
Equivalent functional component
function Welcome({ name }) {
return <h1>Hello, {name}!</h1>;
}
Identical output. 4 lines vs 7 lines. This is why the community moved to functions for simple components.
4. The Constructor Method
The constructor is called once when the component instance is created — before it mounts to the DOM.
Basic constructor pattern
class Counter extends Component {
constructor(props) {
super(props); // MUST call super(props) first
this.state = { // Initialize state
count: 0,
lastClicked: null,
};
}
render() {
return <p>Count: {this.state.count}</p>;
}
}
Why super(props)?
┌──────────────────────────────────────────────────────────────┐
│ React.Component constructor: │
│ │
│ constructor(props) { │
│ this.props = props; ← Sets this.props │
│ this.state = {}; ← Initializes empty state │
│ } │
│ │
│ YOUR constructor: │
│ │
│ constructor(props) { │
│ super(props); ← Calls the above, sets this.props │
│ this.state = {...}; ← NOW safe to use `this` │
│ } │
└──────────────────────────────────────────────────────────────┘
What happens if you forget super(props)?
class Broken extends Component {
constructor(props) {
// super(props); ← FORGOT!
this.state = { count: 0 };
// ReferenceError: Must call super constructor before using `this`
}
}
What if you call super() without props?
class Risky extends Component {
constructor(props) {
super(); // No props argument
console.log(this.props); // undefined inside constructor!
}
render() {
console.log(this.props); // ✅ Works fine here (React sets it after constructor)
return <div />;
}
}
This is a subtle bug. Always pass props to super().
What goes in the constructor?
| ✅ Do | ❌ Don't |
|---|---|
Initialize this.state | Call setState() |
| Bind event handlers | Cause side effects (fetch, subscribe) |
| Initialize refs | Copy props into state (usually) |
Copying props to state — the classic mistake
// ❌ BAD — state diverges from prop
class Bad extends Component {
constructor(props) {
super(props);
this.state = { name: props.name }; // Never updates when parent re-renders!
}
}
// ✅ OK — only for initial seed value (documented intent)
class Search extends Component {
constructor(props) {
super(props);
this.state = {
query: props.initialQuery || '', // "initial" makes intent clear
};
}
}
5. State in Class Components
State is a plain JavaScript object stored on the component instance at this.state.
Declaring state
// Option 1: In the constructor (traditional)
class Timer extends Component {
constructor(props) {
super(props);
this.state = {
seconds: 0,
isRunning: false,
};
}
}
// Option 2: Class field syntax (modern — no constructor needed)
class Timer extends Component {
state = {
seconds: 0,
isRunning: false,
};
}
Both are identical. Option 2 is shorter and avoids the super(props) ceremony.
Reading state
render() {
return (
<div>
<p>{this.state.seconds} seconds</p>
<p>{this.state.isRunning ? 'Running' : 'Stopped'}</p>
</div>
);
}
Updating state — the setState method
// ❌ NEVER mutate state directly
this.state.seconds = 10; // React won't know it changed — no re-render
// ✅ ALWAYS use setState
this.setState({ seconds: 10 }); // React schedules a re-render
State vs Props comparison
| State | Props | |
|---|---|---|
| Owned by | This component | Parent component |
| Mutable? | Yes (via setState) | No (read-only) |
| Where declared | constructor or class field | JSX attributes by parent |
| Triggers re-render? | Yes, when changed | Yes, when parent changes them |
| Accessed via | this.state.x | this.props.x |
Multiple state properties
Unlike useState (which you call multiple times), class state is one object:
// Class component — ONE state object
this.state = {
user: null,
posts: [],
loading: true,
error: null,
};
// Functional component — MULTIPLE useState calls
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
When you call setState in a class, React shallowly merges the update:
this.state = { a: 1, b: 2, c: 3 };
this.setState({ b: 99 });
// Result: { a: 1, b: 99, c: 3 } ← a and c preserved!
// With useState, there's NO merge — you replace the whole value:
setState({ b: 99 });
// Result: { b: 99 } ← a and c LOST unless you spread
This shallow merge is a key difference that catches people converting from classes to functions.
6. The this Binding Problem
This is the #1 source of bugs in class components. Let's understand it deeply.
The problem
class Counter extends Component {
state = { count: 0 };
handleClick() {
console.log(this); // What is `this`?
this.setState({ count: this.state.count + 1 });
}
render() {
return (
<button onClick={this.handleClick}>
Count: {this.state.count}
</button>
);
}
}
This crashes. When React calls this.handleClick, this is undefined.
Why?
When you write: onClick={this.handleClick}
React stores: const handler = this.handleClick;
Later, React calls: handler(); ← No object context!
In strict mode (which React uses):
function.call(undefined) → this = undefined
It's the same as:
const obj = {
name: 'Counter',
greet() { return this.name; }
};
const fn = obj.greet; // Extract the function
fn(); // TypeError — this is undefined
Solution 1: Bind in constructor
class Counter extends Component {
constructor(props) {
super(props);
this.state = { count: 0 };
this.handleClick = this.handleClick.bind(this); // ← Creates bound copy
}
handleClick() {
this.setState({ count: this.state.count + 1 }); // ✅ Works
}
render() {
return <button onClick={this.handleClick}>+1</button>;
}
}
How bind works:
this.handleClick.bind(this)
↓
Returns a NEW function where `this` is permanently set to the component instance
↓
Stored as this.handleClick (overrides the prototype method)
Solution 2: Arrow function class fields (PREFERRED)
class Counter extends Component {
state = { count: 0 };
// Arrow function captures `this` from the enclosing scope (the class instance)
handleClick = () => {
this.setState({ count: this.state.count + 1 }); // ✅ Works
};
render() {
return <button onClick={this.handleClick}>+1</button>;
}
}
Arrow functions don't have their own this — they inherit it from where they're defined.
Solution 3: Inline arrow in JSX (NOT recommended)
class Counter extends Component {
state = { count: 0 };
handleClick() {
this.setState({ count: this.state.count + 1 });
}
render() {
return (
<button onClick={() => this.handleClick()}>
+1
</button>
);
}
}
Why not? A new function is created every render. This breaks shouldComponentUpdate / React.memo optimizations on child components.
Comparison table
| Approach | Syntax | Perf | Readability |
|---|---|---|---|
| Bind in constructor | this.fn = this.fn.bind(this) | ✅ Good | 😐 Verbose |
| Arrow class field | fn = () => { ... } | ✅ Good | ✅ Clean |
| Inline arrow in JSX | onClick={() => this.fn()} | ❌ New fn each render | 😐 OK for simple cases |
.bind() in JSX | onClick={this.fn.bind(this)} | ❌ New fn each render | ❌ Worst of both |
Rule: Always use arrow class fields in class components.
Passing arguments
class TodoList extends Component {
handleDelete = (id) => {
this.setState(prev => ({
todos: prev.todos.filter(t => t.id !== id)
}));
};
render() {
return this.state.todos.map(todo => (
<button
key={todo.id}
onClick={() => this.handleDelete(todo.id)} // Inline arrow OK here
>
Delete {todo.text}
</button>
));
}
}
When you need to pass arguments, an inline arrow is acceptable because there's no cleaner alternative.
7. setState — Updating State in Classes
setState is the only correct way to update state in class components. It does three things:
- Schedules a state update (it's asynchronous)
- Shallow merges the new values with existing state
- Triggers a re-render
Form 1: Object argument
this.setState({ count: 10 });
Pass an object containing only the properties you want to change. React merges it with the current state.
// Current state: { name: 'Priya', age: 25, city: 'Mumbai' }
this.setState({ age: 26 });
// New state: { name: 'Priya', age: 26, city: 'Mumbai' }
// ↑ only this changed
Form 2: Updater function (PREFERRED for derived updates)
this.setState((prevState, props) => ({
count: prevState.count + 1
}));
Why use the function form?
Because setState is asynchronous and React batches multiple calls:
// ❌ BUG — all three read the SAME stale state
handleTripleIncrement = () => {
this.setState({ count: this.state.count + 1 }); // 0 + 1 = 1
this.setState({ count: this.state.count + 1 }); // 0 + 1 = 1 (still!)
this.setState({ count: this.state.count + 1 }); // 0 + 1 = 1 (still!)
// Result: count = 1, not 3!
};
// ✅ FIX — each call gets the latest state
handleTripleIncrement = () => {
this.setState(prev => ({ count: prev.count + 1 })); // 0 → 1
this.setState(prev => ({ count: prev.count + 1 })); // 1 → 2
this.setState(prev => ({ count: prev.count + 1 })); // 2 → 3
// Result: count = 3 ✅
};
Form 3: Callback after state update
this.setState(
{ count: 10 },
() => {
// This runs AFTER state is updated AND component re-rendered
console.log('New count:', this.state.count); // 10
}
);
The callback is the class component equivalent of what you'd do with useEffect in a function component.
setState is asynchronous
handleClick = () => {
console.log('Before:', this.state.count); // 0
this.setState({ count: 1 });
console.log('After:', this.state.count); // Still 0! (not yet updated)
};
┌──────────────────────────────────────────────────────────┐
│ handleClick called │
│ ├── console.log → 0 │
│ ├── setState({ count: 1 }) ← Queued, not applied │
│ ├── console.log → 0 ← Still old state │
│ └── handler returns │
│ │
│ React processes queue: │
│ ├── Merges { count: 1 } into state │
│ ├── Calls render() │
│ └── Updates DOM │
└──────────────────────────────────────────────────────────┘
Batching
React batches setState calls within event handlers:
handleClick = () => {
this.setState({ a: 1 }); // Queued
this.setState({ b: 2 }); // Queued
this.setState({ c: 3 }); // Queued
// React merges all three and does ONE re-render
};
In React 17 and earlier, calls outside event handlers (setTimeout, promises) were NOT batched. In React 18+, all setState calls are batched regardless of context.
Deep state updates
setState does shallow merge — it doesn't deeply merge nested objects:
this.state = {
user: {
name: 'Priya',
address: {
city: 'Mumbai',
zip: '400001'
}
}
};
// ❌ WRONG — replaces entire user object
this.setState({ user: { name: 'Ravi' } });
// Result: { user: { name: 'Ravi' } } ← address LOST!
// ✅ CORRECT — spread to preserve nested structure
this.setState(prev => ({
user: {
...prev.user,
address: {
...prev.user.address,
city: 'Delhi'
}
}
}));
8. The Render Method
render() is the only required method in a class component. It tells React what the component should display.
Rules of render
| Rule | Detail |
|---|---|
| Must return something | JSX element, array, string, number, null, or false |
| Must be pure | Same props + state = same output. No side effects |
Cannot call setState | Would cause infinite loop |
| Called on every re-render | Must be fast |
What render can return
class Examples extends Component {
render() {
// 1. JSX element
return <div>Hello</div>;
// 2. Fragment
return (
<>
<h1>Title</h1>
<p>Body</p>
</>
);
// 3. Array (must have keys)
return [
<li key="a">First</li>,
<li key="b">Second</li>,
];
// 4. String
return 'Just text';
// 5. Number
return 42;
// 6. null (renders nothing)
return null;
// 7. Boolean (renders nothing)
return false;
// 8. Portal
return ReactDOM.createPortal(<Modal />, document.body);
}
}
Conditional rendering in render
class Greeting extends Component {
render() {
const { isLoggedIn, user } = this.props;
// Early return
if (!isLoggedIn) {
return <p>Please log in.</p>;
}
return (
<div>
<h1>Welcome, {user.name}!</h1>
{user.isAdmin && <AdminPanel />}
{user.notifications.length > 0
? <NotificationBadge count={user.notifications.length} />
: <p>No new notifications</p>
}
</div>
);
}
}
Common mistake: Side effects in render
class Bad extends Component {
render() {
// ❌ NEVER do this in render
fetch('/api/data'); // Side effect
document.title = this.state.title; // DOM manipulation
this.setState({ rendered: true }); // Causes infinite loop
localStorage.setItem('key', 'value'); // Side effect
return <div />;
}
}
Side effects belong in lifecycle methods or useEffect — never in render.
9. Class Fields Syntax (Modern Shorthand)
Modern JavaScript (stage 3 proposal, widely supported via Babel/TypeScript) lets you skip the constructor entirely:
Before (traditional)
class Counter extends Component {
constructor(props) {
super(props);
this.state = { count: 0 };
this.increment = this.increment.bind(this);
}
increment() {
this.setState(prev => ({ count: prev.count + 1 }));
}
render() {
return <button onClick={this.increment}>{this.state.count}</button>;
}
}
After (class fields)
class Counter extends Component {
state = { count: 0 };
increment = () => {
this.setState(prev => ({ count: prev.count + 1 }));
};
render() {
return <button onClick={this.increment}>{this.state.count}</button>;
}
}
What changed
| Traditional | Class Fields |
|---|---|
constructor(props) { super(props); this.state = {...}; } | state = {...}; |
this.fn = this.fn.bind(this) in constructor | fn = () => {...}; |
| Prototype methods | Instance arrow functions |
How class fields work under the hood
// What you write:
class Example {
state = { count: 0 };
handleClick = () => { };
}
// What it compiles to (roughly):
class Example {
constructor() {
this.state = { count: 0 }; // Instance property
this.handleClick = () => { }; // Instance method (arrow)
}
}
Each instance gets its own copy of arrow methods. This means:
- ✅
thisis always correct - ❌ Methods aren't shared via prototype (tiny memory cost)
- ✅ Worth the trade-off for correct behaviour
Static class fields
class Card extends Component {
static defaultProps = {
variant: 'default',
size: 'md',
};
static propTypes = {
title: PropTypes.string.isRequired,
variant: PropTypes.oneOf(['default', 'outlined', 'filled']),
};
render() {
return <div className={`card card-${this.props.variant}`} />;
}
}
10. Class vs Functional Components — Side by Side
Let's build the exact same component both ways to see the differences clearly.
Feature: A user profile card that fetches data on mount
Class version
class UserProfile extends Component {
state = {
user: null,
loading: true,
error: null,
};
componentDidMount() {
this.fetchUser();
}
componentDidUpdate(prevProps) {
if (prevProps.userId !== this.props.userId) {
this.fetchUser();
}
}
componentWillUnmount() {
this.abortController?.abort();
}
fetchUser = async () => {
this.abortController = new AbortController();
this.setState({ loading: true, error: null });
try {
const res = await fetch(`/api/users/${this.props.userId}`, {
signal: this.abortController.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const user = await res.json();
this.setState({ user, loading: false });
} catch (err) {
if (err.name !== 'AbortError') {
this.setState({ error: err.message, loading: false });
}
}
};
render() {
const { user, loading, error } = this.state;
if (loading) return <Spinner />;
if (error) return <ErrorMessage message={error} />;
if (!user) return null;
return (
<div className="profile">
<img src={user.avatar} alt={user.name} />
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
}
Functional version
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => {
setUser(data);
setLoading(false);
})
.catch(err => {
if (err.name !== 'AbortError') {
setError(err.message);
setLoading(false);
}
});
return () => controller.abort(); // Cleanup
}, [userId]);
if (loading) return <Spinner />;
if (error) return <ErrorMessage message={error} />;
if (!user) return null;
return (
<div className="profile">
<img src={user.avatar} alt={user.name} />
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
Comparison
| Aspect | Class | Function |
|---|---|---|
| Lines of code | ~50 | ~40 |
| State declaration | One object | Multiple useState |
| Data fetching | componentDidMount + componentDidUpdate | Single useEffect |
| Cleanup | componentWillUnmount | Return function from useEffect |
this issues | Yes — must use arrow fields | None |
| Logic reuse | HOCs, render props | Custom hooks |
| Readability | Scattered across methods | Co-located in one function |
The fundamental mental model difference
CLASS COMPONENT thinks:
"WHEN does something happen?"
├── On mount → componentDidMount
├── On update → componentDidUpdate
└── On unmount → componentWillUnmount
FUNCTIONAL COMPONENT thinks:
"WHAT do I synchronize with?"
└── useEffect(fn, [userId])
→ "Keep this effect in sync with userId"
→ React handles when to run/cleanup
11. The Three Lifecycle Phases
Every class component instance goes through three phases:
┌─────────────────────────────────────────────────────────────────┐
│ COMPONENT LIFECYCLE │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ MOUNTING │ │ UPDATING │ │ UNMOUNTING │ │
│ │ │ │ │ │ │ │
│ │ constructor │ │ new props │ │ componentWill │ │
│ │ render │ → │ setState │ → │ Unmount │ │
│ │ DOM updated │ │ forceUpdate │ │ │ │
│ │ cDM │ │ render │ │ Cleanup │ │
│ │ │ │ DOM updated │ │ │ │
│ │ │ │ cDU │ │ │ │
│ └──────────────┘ └──────────────┘ └───────────────┘ │
│ │
│ cDM = componentDidMount │
│ cDU = componentDidUpdate │
└─────────────────────────────────────────────────────────────────┘
Phase 1: Mounting (Birth)
The component is being created and inserted into the DOM.
constructor(props)
↓
static getDerivedStateFromProps(props, state)
↓
render()
↓
React updates DOM
↓
componentDidMount() ← Safe to do side effects here
Phase 2: Updating (Growth)
Triggered by new props, setState(), or forceUpdate().
static getDerivedStateFromProps(props, state)
↓
shouldComponentUpdate(nextProps, nextState)
↓ (if returns true)
render()
↓
getSnapshotBeforeUpdate(prevProps, prevState)
↓
React updates DOM
↓
componentDidUpdate(prevProps, prevState, snapshot)
Phase 3: Unmounting (Death)
The component is being removed from the DOM.
componentWillUnmount() ← Clean up here
↓
React removes from DOM
↓
Instance is garbage collected
Phase 4: Error Handling (Special)
When a child component throws during rendering:
static getDerivedStateFromError(error)
↓
componentDidCatch(error, info)
Mapping to functional equivalents
| Class Lifecycle | Functional Equivalent |
|---|---|
constructor | Function body + useState initial value |
render | The function's return statement |
componentDidMount | useEffect(() => {...}, []) |
componentDidUpdate | useEffect(() => {...}, [deps]) |
componentWillUnmount | useEffect(() => { return () => {...} }, []) |
shouldComponentUpdate | React.memo |
getDerivedStateFromError | No hook equivalent (must use class) |
componentDidCatch | No hook equivalent (must use class) |
12. Component Instance vs Function Call
A fundamental difference between class and function components:
Class: React creates an instance
class Counter extends Component {
state = { count: 0 };
render() { return <p>{this.state.count}</p>; }
}
// React internally does:
const instance = new Counter(props); // Creates object in memory
instance.render(); // Calls render on the instance
// The instance PERSISTS between renders
// instance.state, instance.props, instance.refs — all on the same object
Function: React just calls the function
function Counter() {
const [count, setCount] = useState(0);
return <p>{count}</p>;
}
// React internally does:
const result = Counter(props); // Calls the function, gets JSX back
// No persistent instance
// Hooks provide the "memory" between calls via React's internal fiber
Implications
| Class | Function | |
|---|---|---|
| Identity | this = persistent instance | No this, no instance |
| State storage | this.state on the instance | React's fiber node (internal) |
| Accessing latest props | this.props (mutable — always current) | Closure capture (snapshot at render time) |
| Method sharing | Prototype chain | Closures |
The stale closure issue
This difference causes a subtle bug unique to functional components:
// Class — always reads latest props
class Timer extends Component {
componentDidMount() {
setInterval(() => {
console.log(this.props.count); // Always current value
}, 1000);
}
}
// Function — captures the value at render time
function Timer({ count }) {
useEffect(() => {
const id = setInterval(() => {
console.log(count); // Stale! Captured from when effect ran
}, 1000);
return () => clearInterval(id);
}, []); // Empty deps = captures initial count forever
}
Fix: Add count to the dependency array, or use useRef to hold the latest value.
13. When You Still Encounter Class Components
Despite the shift to hooks, class components are alive and well in these situations:
1. Error Boundaries (NO hook equivalent)
class ErrorBoundary extends Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// Log to error reporting service
logErrorToService(error, errorInfo.componentStack);
}
render() {
if (this.state.hasError) {
return (
<div className="error-fallback">
<h2>Something went wrong</h2>
<pre>{this.state.error?.message}</pre>
<button onClick={() => this.setState({ hasError: false, error: null })}>
Try again
</button>
</div>
);
}
return this.props.children;
}
}
// Usage
<ErrorBoundary>
<App />
</ErrorBoundary>
As of React 19, there is still no hook equivalent for componentDidCatch / getDerivedStateFromError. Error boundaries must be class components.
2. Legacy codebases
Any codebase started before 2019 likely has extensive class component usage. Companies like:
- Large banks and enterprises
- E-commerce platforms
- Media companies
You'll be expected to read, debug, and modify class components even if new features use hooks.
3. Third-party libraries
Some libraries still expose class-based APIs or require class components for certain features.
4. Performance edge cases
In very specific scenarios, the explicit control offered by shouldComponentUpdate and getSnapshotBeforeUpdate may be preferred over their hook approximations.
14. Converting a Class Component to a Function
Step-by-step conversion guide with a real example.
Original class component
class SearchBar extends Component {
constructor(props) {
super(props);
this.state = {
query: '',
results: [],
loading: false,
};
this.debounceTimer = null;
}
componentDidMount() {
this.inputRef.focus();
}
componentDidUpdate(prevProps) {
if (prevProps.category !== this.props.category) {
this.setState({ query: '', results: [] });
}
}
componentWillUnmount() {
clearTimeout(this.debounceTimer);
}
handleChange = (e) => {
const query = e.target.value;
this.setState({ query });
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
this.search(query);
}, 300);
};
search = async (query) => {
if (!query.trim()) {
this.setState({ results: [] });
return;
}
this.setState({ loading: true });
try {
const res = await fetch(
`/api/search?q=${query}&cat=${this.props.category}`
);
const data = await res.json();
this.setState({ results: data, loading: false });
} catch {
this.setState({ loading: false });
}
};
render() {
return (
<div>
<input
ref={el => (this.inputRef = el)}
value={this.state.query}
onChange={this.handleChange}
placeholder="Search..."
/>
{this.state.loading && <Spinner />}
<ResultsList results={this.state.results} />
</div>
);
}
}
Step-by-step conversion
Step 1: Change class to function, remove extends
// class SearchBar extends Component {
function SearchBar({ category }) { // Destructure props in parameter
Step 2: Replace this.state with useState
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
Step 3: Replace refs
// this.inputRef → useRef
const inputRef = useRef(null);
// this.debounceTimer → useRef (for mutable values that don't trigger re-render)
const debounceTimer = useRef(null);
Step 4: Replace componentDidMount
useEffect(() => {
inputRef.current?.focus();
}, []);
Step 5: Replace componentDidUpdate
useEffect(() => {
setQuery('');
setResults([]);
}, [category]);
Step 6: Replace componentWillUnmount
useEffect(() => {
return () => clearTimeout(debounceTimer.current);
}, []);
Step 7: Convert methods to functions
const handleChange = (e) => {
const value = e.target.value;
setQuery(value);
clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(() => search(value), 300);
};
const search = async (q) => {
if (!q.trim()) { setResults([]); return; }
setLoading(true);
try {
const res = await fetch(`/api/search?q=${q}&cat=${category}`);
const data = await res.json();
setResults(data);
} catch { /* ignore */ }
setLoading(false);
};
Step 8: Replace render with return
return (
<div>
<input
ref={inputRef}
value={query}
onChange={handleChange}
placeholder="Search..."
/>
{loading && <Spinner />}
<ResultsList results={results} />
</div>
);
Final converted component
function SearchBar({ category }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const inputRef = useRef(null);
const debounceTimer = useRef(null);
// Focus on mount
useEffect(() => {
inputRef.current?.focus();
}, []);
// Reset on category change
useEffect(() => {
setQuery('');
setResults([]);
}, [category]);
// Cleanup timer on unmount
useEffect(() => {
return () => clearTimeout(debounceTimer.current);
}, []);
const search = async (q) => {
if (!q.trim()) { setResults([]); return; }
setLoading(true);
try {
const res = await fetch(`/api/search?q=${q}&cat=${category}`);
const data = await res.json();
setResults(data);
} catch { /* ignore */ }
setLoading(false);
};
const handleChange = (e) => {
const value = e.target.value;
setQuery(value);
clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(() => search(value), 300);
};
return (
<div>
<input
ref={inputRef}
value={query}
onChange={handleChange}
placeholder="Search..."
/>
{loading && <Spinner />}
<ResultsList results={results} />
</div>
);
}
Conversion cheat sheet
| Class | Function |
|---|---|
this.state = { x: 1 } | const [x, setX] = useState(1) |
this.setState({ x: 2 }) | setX(2) |
this.setState(prev => ...) | setX(prev => ...) |
this.props.x | x (destructured param) |
React.createRef() | useRef(null) |
this.timer = ... (instance var) | const timer = useRef(...) |
componentDidMount | useEffect(() => {...}, []) |
componentDidUpdate(prevProps) | useEffect(() => {...}, [dep]) |
componentWillUnmount | useEffect(() => { return () => {...} }, []) |
shouldComponentUpdate | React.memo(Component) |
this.handleClick = () => {} | const handleClick = () => {} |
15. Common Mistakes with Class Components
Mistake 1: Forgetting to bind methods
// ❌ Crash: "Cannot read property 'setState' of undefined"
class Bad extends Component {
handleClick() {
this.setState({ clicked: true });
}
render() {
return <button onClick={this.handleClick}>Click</button>;
}
}
// ✅ Fix: Arrow class field
class Good extends Component {
handleClick = () => {
this.setState({ clicked: true });
};
// ...
}
Mistake 2: Mutating state directly
// ❌ No re-render — React doesn't know state changed
this.state.items.push(newItem);
this.setState({ items: this.state.items }); // Same reference!
// ✅ Create new array
this.setState(prev => ({
items: [...prev.items, newItem]
}));
Mistake 3: Using setState in render
// ❌ Infinite loop
class Bad extends Component {
render() {
this.setState({ rendered: true }); // render → setState → render → setState...
return <div />;
}
}
Mistake 4: Forgetting super(props)
// ❌ this.props is undefined in constructor
class Bad extends Component {
constructor(props) {
super(); // Missing props!
console.log(this.props); // undefined
}
}
Mistake 5: Not cleaning up in componentWillUnmount
// ❌ Memory leak — interval runs forever
class Bad extends Component {
componentDidMount() {
setInterval(() => this.tick(), 1000);
}
// Missing componentWillUnmount!
}
// ✅ Always clean up
class Good extends Component {
componentDidMount() {
this.intervalId = setInterval(() => this.tick(), 1000);
}
componentWillUnmount() {
clearInterval(this.intervalId);
}
}
Mistake 6: Calling setState on unmounted component
// ❌ Warning: Can't perform React state update on unmounted component
class Bad extends Component {
componentDidMount() {
fetch('/api/data')
.then(res => res.json())
.then(data => this.setState({ data })); // Component might be gone!
}
}
// ✅ Track mounted state or use AbortController
class Good extends Component {
controller = new AbortController();
componentDidMount() {
fetch('/api/data', { signal: this.controller.signal })
.then(res => res.json())
.then(data => this.setState({ data }))
.catch(err => {
if (err.name !== 'AbortError') throw err;
});
}
componentWillUnmount() {
this.controller.abort();
}
}
16. Key Takeaways
- Class components use
extends Component, must haverender(), and access props/state viathis. - Constructor initialises state and binds methods — always call
super(props)first. thisbinding is the #1 gotcha — use arrow class fields to avoid it entirely.setStateis asynchronous, shallowly merges, and batches — use the updater function form when state depends on previous state.- Three lifecycle phases: mounting, updating, unmounting — each has specific methods.
- Component instance persists between renders (class) vs function is called fresh each render (hooks rely on React's fiber for "memory").
- Error boundaries are the only remaining feature that requires class components.
- Converting class → function: Replace
this.state→useState,this.x→useRef, lifecycle methods →useEffect, methods → local functions. - You will encounter class components in production — learn to read and modify them fluently.
- Modern default: Write functions + hooks for new code. Use classes only for error boundaries.
Explain-It Challenge
-
The Hotel Room: A class component is like checking into a hotel room — you get a room (instance) that persists across your stay, with your belongings (state) in it. A functional component is like ordering room service — each call gets you a fresh tray, and the hotel (React) remembers your preferences (hooks) behind the scenes. How does this analogy explain why
this.propsis always current but a closure in a function captures a snapshot? -
The Delegation: Imagine
setStateis like submitting a form at a government office. You don't get the result immediately — they process it later, possibly batching your form with others submitted the same day. How does this explain whyconsole.log(this.state.count)shows the old value right afterthis.setState({ count: 5 })? -
The Name Tag: At a conference, a class component wears a name tag that says "Hi, I'm Counter (this = me)." But when someone photocopies just the
handleClickmethod and hands it to an event system, the copy has no name tag — it doesn't know who "this" is anymore. How does the arrow function class field solve this? (Hint: it's like laminating the name tag directly onto the photocopy.)
Navigation: ← Overview · Next → React's Lifecycle Methods