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 through this.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

  1. What Are Class Components?
  2. ES6 Class Syntax Refresher
  3. Your First Class Component
  4. The Constructor Method
  5. State in Class Components
  6. The this Binding Problem
  7. setState — Updating State in Classes
  8. The Render Method
  9. Class Fields Syntax (Modern Shorthand)
  10. Class vs Functional Components — Side by Side
  11. The Three Lifecycle Phases
  12. Component Instance vs Function Call
  13. When You Still Encounter Class Components
  14. Converting a Class Component to a Function
  15. Common Mistakes with Class Components
  16. 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:

FeatureHow
Local statethis.state + this.setState()
Lifecycle hookscomponentDidMount, componentDidUpdate, etc.
Instance methodsRegular methods on the class
Refsthis.myRef = React.createRef()
Error boundariescomponentDidCatch + 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

RuleWhy
super() must be called before this in subclass constructorsThe parent constructor creates the instance first
Methods are on the prototype, not the instanceMemory efficient — shared, not copied per instance
this refers to the instanceBut its value depends on how the method is called
static methods belong to the class, not instancesAnimal.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

  1. Import Component from 'react' (or use React.Component)
  2. Extend Component
  3. 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.stateCall setState()
Bind event handlersCause side effects (fetch, subscribe)
Initialize refsCopy 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

StateProps
Owned byThis componentParent component
Mutable?Yes (via setState)No (read-only)
Where declaredconstructor or class fieldJSX attributes by parent
Triggers re-render?Yes, when changedYes, when parent changes them
Accessed viathis.state.xthis.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

ApproachSyntaxPerfReadability
Bind in constructorthis.fn = this.fn.bind(this)✅ Good😐 Verbose
Arrow class fieldfn = () => { ... }✅ Good✅ Clean
Inline arrow in JSXonClick={() => this.fn()}❌ New fn each render😐 OK for simple cases
.bind() in JSXonClick={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:

  1. Schedules a state update (it's asynchronous)
  2. Shallow merges the new values with existing state
  3. 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

RuleDetail
Must return somethingJSX element, array, string, number, null, or false
Must be pureSame props + state = same output. No side effects
Cannot call setStateWould cause infinite loop
Called on every re-renderMust 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

TraditionalClass Fields
constructor(props) { super(props); this.state = {...}; }state = {...};
this.fn = this.fn.bind(this) in constructorfn = () => {...};
Prototype methodsInstance 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:

  • this is 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

AspectClassFunction
Lines of code~50~40
State declarationOne objectMultiple useState
Data fetchingcomponentDidMount + componentDidUpdateSingle useEffect
CleanupcomponentWillUnmountReturn function from useEffect
this issuesYes — must use arrow fieldsNone
Logic reuseHOCs, render propsCustom hooks
ReadabilityScattered across methodsCo-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 LifecycleFunctional Equivalent
constructorFunction body + useState initial value
renderThe function's return statement
componentDidMountuseEffect(() => {...}, [])
componentDidUpdateuseEffect(() => {...}, [deps])
componentWillUnmountuseEffect(() => { return () => {...} }, [])
shouldComponentUpdateReact.memo
getDerivedStateFromErrorNo hook equivalent (must use class)
componentDidCatchNo 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

ClassFunction
Identitythis = persistent instanceNo this, no instance
State storagethis.state on the instanceReact's fiber node (internal)
Accessing latest propsthis.props (mutable — always current)Closure capture (snapshot at render time)
Method sharingPrototype chainClosures

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

ClassFunction
this.state = { x: 1 }const [x, setX] = useState(1)
this.setState({ x: 2 })setX(2)
this.setState(prev => ...)setX(prev => ...)
this.props.xx (destructured param)
React.createRef()useRef(null)
this.timer = ... (instance var)const timer = useRef(...)
componentDidMountuseEffect(() => {...}, [])
componentDidUpdate(prevProps)useEffect(() => {...}, [dep])
componentWillUnmountuseEffect(() => { return () => {...} }, [])
shouldComponentUpdateReact.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

  1. Class components use extends Component, must have render(), and access props/state via this.
  2. Constructor initialises state and binds methods — always call super(props) first.
  3. this binding is the #1 gotcha — use arrow class fields to avoid it entirely.
  4. setState is asynchronous, shallowly merges, and batches — use the updater function form when state depends on previous state.
  5. Three lifecycle phases: mounting, updating, unmounting — each has specific methods.
  6. Component instance persists between renders (class) vs function is called fresh each render (hooks rely on React's fiber for "memory").
  7. Error boundaries are the only remaining feature that requires class components.
  8. Converting class → function: Replace this.stateuseState, this.xuseRef, lifecycle methods → useEffect, methods → local functions.
  9. You will encounter class components in production — learn to read and modify them fluently.
  10. Modern default: Write functions + hooks for new code. Use classes only for error boundaries.

Explain-It Challenge

  1. 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.props is always current but a closure in a function captures a snapshot?

  2. The Delegation: Imagine setState is 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 why console.log(this.state.count) shows the old value right after this.setState({ count: 5 })?

  3. 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 handleClick method 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