A component doesn't exist forever. It appears on screen, updates while its props or state change, and disappears once it's no longer needed. Those three phases — mounting, updating, and unmounting — have existed in React since day one, and for years they were handled with a set of special methods available only to class components. You won't write any of those methods in new code today, but you'll run into them without fail in any project with a few years behind it, and older tutorials still explain React in those terms. In this lesson you'll learn to read the classic lifecycle, understand what problem each method solved, translate it into modern syntax with an equivalence table, and — most importantly — why the correct mental model in React 19 is no longer "moments in a lifecycle" but synchronization with an external system.

Contents

  1. The three phases of a component's life
  2. The mounting methods: constructor and render
  3. componentDidMount: once the component is already on screen
  4. componentDidUpdate: comparing against previous props and state
  5. componentWillUnmount and memory leaks
  6. shouldComponentUpdate and the UNSAFE_ methods
  7. Full example: ActivityPanel with a timer
  8. Equivalence table: class → function with hooks
  9. Why the equivalence is only approximate
  10. Living with classes in a real codebase

  1. The three phases of a component's life

Every component mounted in the DOM goes through this journey:

flowchart TD
    A["MOUNT<br/>The component enters the tree"] --> A1["constructor()"]
    A1 --> A2["render()"]
    A2 --> A3["React inserts the DOM"]
    A3 --> A4["componentDidMount()"]
    A4 --> B{"Do props<br/>or state change?"}
    B -- "yes" --> B1["shouldComponentUpdate()<br/>(optional)"]
    B1 --> B2["render()"]
    B2 --> B3["React updates the DOM"]
    B3 --> B4["componentDidUpdate()"]
    B4 --> B
    B -- "the component leaves the tree" --> C["componentWillUnmount()"]
    C --> D["UNMOUNT<br/>React removes the DOM and forgets the state"]

Three ideas before getting into the detail:

  • Mounting happens exactly once per instance. If the component unmounts and mounts again, it's a new instance: state back to zero and componentDidMount runs again. That's exactly what you saw in 01-05, where changing an element's type destroys the subtree and loses its state.
  • Updating happens many times, once for every change in props or state.
  • Unmounting happens exactly once, and it's the last chance to clean up anything the component left open outside React.

And a warning about StrictMode, which you already know from 01-03: in development, React deliberately mounts, unmounts, and re-mounts every component. You'll see componentDidMount twice with a componentWillUnmount in between. That's not a bug: it's a check that your cleanup actually works. In production it happens only once.

  1. The mounting methods: constructor and render

import { Component } from 'react';

class RentalCounter extends Component {
  constructor(props) {
    super(props);                      // MANDATORY and always first
    this.state = { rentals: 0 };       // the only direct assignment allowed
    this.handleRental = this.handleRental.bind(this);   // the bind from 02-02
  }

  handleRental() {
    this.setState((previousState) => ({ rentals: previousState.rentals + 1 }));
  }

  render() {
    return (
      <div>
        <p>Rentals logged: {this.state.rentals}</p>
        <button type="button" onClick={this.handleRental}>Log rental</button>
      </div>
    );
  }
}
  • constructor(props) runs once, before the first render. It's for exactly two things: initializing this.state and binding methods with bind. super(props) is mandatory; without it, this.props would be empty inside the constructor and JavaScript would throw an error the moment you use this.
  • render() must be pure: it looks at this.props and this.state, returns JSX, and does nothing else. No server requests, no setState, no touching the DOM directly. React can call it several times before reflecting anything on screen, so any side effect inside render would run an unpredictable number of times.

That requirement of purity is the whole reason everything that follows exists: if render can't have side effects, there need to be other places to put them. The lifecycle methods are those places.

  1. componentDidMount: once the component is already on screen

Runs exactly once, right after React has inserted the component into the real DOM. It's the classic place for anything that needs the element to already exist.

componentDidMount() {
  // 1. Request data from the server
  fetch('/api/bicicletas')
    .then((response) => response.json())
    .then((bikes) => this.setState({ bikes, loading: false }));

  // 2. Subscribe to something external
  window.addEventListener('resize', this.handleResize);

  // 3. Start timers
  this.timer = setInterval(this.updateClock, 1000);

  // 4. Measure the DOM, which already exists
  const height = this.container.current.offsetHeight;
}

Here setState actually is allowed: it triggers an immediate second render, before the browser paints, so the person using the app never sees a flicker. It's the usual "loading → data ready" pattern.

What you shouldn't do: call setState unconditionally with a value that could just as well be computed during render. That duplicates work for no reason.

  1. componentDidUpdate: comparing against previous props and state

Runs after every update except the first. Its signature is the part worth understanding:

componentDidUpdate(previousProps, previousState) {
  // this.props and this.state already hold the NEW values
  // previousProps and previousState hold the OLD ones
}

React hands you the previous values because, without them, there'd be no way to know what changed. And that comparison isn't optional: it's mandatory the moment you call setState inside.

// CORRECT: the comparison avoids the infinite loop
componentDidUpdate(previousProps) {
  if (previousProps.stationId !== this.props.stationId) {
    this.loadStationBikes(this.props.stationId);
  }
}
// BROKEN: guaranteed infinite loop
componentDidUpdate() {
  this.loadStationBikes(this.props.stationId);
  // load -> setState -> update -> componentDidUpdate -> load -> …
}

The cycle runs exactly like this: setState triggers an update, the update triggers componentDidUpdate, and componentDidUpdate calls setState again. The browser locks up and React eventually throws "Maximum update depth exceeded."

This method is also the source of a classic duplication in legacy code: the initial load goes in componentDidMount, and the reload on station change goes in componentDidUpdate, with the very same call written out twice, fifty lines apart, in two separate methods. It takes only touching one and forgetting the other for a baffling bug to appear. That structural flaw is one of the reasons hooks were born.

  1. componentWillUnmount and memory leaks

Runs right before React removes the component from the DOM. It's the last chance to undo whatever was done at mount time.

componentWillUnmount() {
  clearInterval(this.timer);
  window.removeEventListener('resize', this.handleResize);
  this.subscription.cancel();
}

What happens if you forget it, with the most illustrative case, a setInterval:

  1. The component mounts and starts an interval that runs every second.
  2. The person navigates to another screen and the component unmounts.
  3. The interval is still alive: setInterval belongs to the browser, not to React, and React was never told to stop it.
  4. Every second, the callback tries to call setState on a component that no longer exists. In the best case React just ignores it; in the worst case, the callback keeps every variable of the component alive by reference — including its data — and the browser can't free them.
  5. If the person enters and leaves that screen twenty times, there are twenty intervals running at once. The app slows down for no apparent reason.

That's a memory leak, and it's the most common failure associated with the lifecycle. The rule is symmetric, no exceptions:

If on mount you do… On unmount you must…
setInterval / setTimeout clearInterval / clearTimeout
addEventListener removeEventListener with the same function reference
Open a WebSocket or an EventSource Close it
Subscribe to a service Cancel the subscription
Fire a request whose response updates state Abort it or mark the component as unmounted

The removeEventListener nuance deserves a warning: it only removes the handler if you pass exactly the same function you registered. With window.addEventListener('resize', () => this.something()) and window.removeEventListener('resize', () => this.something()) you're registering one function and removing a different one, even though the code looks identical. That's why handlers get stored on this or bound in the constructor.

  1. shouldComponentUpdate and the UNSAFE_ methods

shouldComponentUpdate(nextProps, nextState) returns a boolean: true (the default) to render, false to skip the render.

shouldComponentUpdate(nextProps) {
  // Only re-render if the bike or its status changes
  return (
    nextProps.bike.id !== this.props.bike.id ||
    nextProps.bike.status !== this.props.bike.status
  );
}

It's a performance tool, not a correctness one, and it's dangerous when used carelessly: an incomplete comparison makes the component ignore real changes and show stale data, a failure that's very hard to diagnose. The rule is the same one you'll apply in Module 8: don't reach for it until you've measured an actual problem.

There also used to be PureComponent, a base class that implements shouldComponentUpdate with a shallow comparison of all props and state. Its modern equivalent is React.memo (08-02).

The deprecated UNSAFE_-prefixed methods

Three old methods were marked unsafe in React 16.3 and renamed with the UNSAFE_ prefix:

Method What it did Why it's discouraged
UNSAFE_componentWillMount Ran before the first render There's no DOM yet; it used to be misused to fetch data, and it doesn't work with server rendering
UNSAFE_componentWillReceiveProps Announced new props before rendering Invited copying props into state, the mistake from 04-01
UNSAFE_componentWillUpdate Ran before applying the changes Can't safely read the DOM or call setState

The common reason: they aren't safe with interruptible rendering. Since React 18, React can start rendering, pause, and discard the work. A method that runs before render can end up running several times, or for a render that gets abandoned. The methods that run after (componentDidMount, componentDidUpdate) don't have that problem, because by then the result is already committed.

If you see them in real code: they still work, prefix and all, but they're a clear sign of code that hasn't been migrated.

  1. Full example: ActivityPanel with a timer

This is a CicloUrbano component written as a class, the way it might look in an older version of the project. It shows the current time and how long booking res-01 has been active.

// src/components/ActivityPanel.jsx  — LEGACY CODE (class)
import { Component } from 'react';
import styles from './ActivityPanel.module.css';

/**
 * Activity panel for a booking in progress on CicloUrbano.
 * Props:
 *  - booking (Booking object, required)
 */
class ActivityPanel extends Component {
  constructor(props) {
    super(props);
    this.state = {
      now: new Date(),
      ticks: 0
    };
    this.updateClock = this.updateClock.bind(this);
  }

  // MOUNT: the component is already on screen, start the timer
  componentDidMount() {
    console.log('[ActivityPanel] mounted, starting timer');
    this.timer = setInterval(this.updateClock, 1000);
  }

  // UPDATE: only react if the displayed booking changes
  componentDidUpdate(previousProps) {
    if (previousProps.booking.id !== this.props.booking.id) {
      console.log('[ActivityPanel] booking changed, resetting counter');
      this.setState({ ticks: 0 });
    }
  }

  // UNMOUNT: tear down the timer. WITHOUT THIS THERE'S A LEAK
  componentWillUnmount() {
    console.log('[ActivityPanel] unmounted, stopping timer');
    clearInterval(this.timer);
  }

  updateClock() {
    this.setState((previousState) => ({
      now: new Date(),
      ticks: previousState.ticks + 1
    }));
  }

  render() {
    const { booking } = this.props;
    const start = new Date(booking.startDate);
    const minutesElapsed = Math.max(
      0,
      Math.floor((this.state.now - start) / 60000)
    );
    const bookedMinutes = booking.hours * 60;
    const remaining = bookedMinutes - minutesElapsed;

    return (
      <section className={styles.panel}>
        <h2>Activity for booking {booking.id}</h2>
        <p>Current time: {this.state.now.toLocaleTimeString('en-GB')}</p>
        <p>Minutes elapsed: {minutesElapsed}</p>
        <p className={remaining < 15 ? styles.urgent : undefined}>
          {remaining > 0
            ? `${remaining} minutes of booking left.`
            : 'The booking has ended.'}
        </p>
      </section>
    );
  }
}

export default ActivityPanel;

Walking through the pieces:

  • constructor: initializes two pieces of state and binds updateClock, because it will be passed to setInterval and would lose its this there (02-02).
  • componentDidMount: starts the interval and stores its identifier in this.timer. It's stored on the instance, not in state, because it doesn't affect what gets painted and changing it shouldn't trigger a render. That's the same reasoning that will justify useRef in 05-03.
  • componentDidUpdate: the comparison previousProps.booking.id !== this.props.booking.id is essential. Without it, setState would run on every update — and there's one every second — triggering the loop from section 4.
  • componentWillUnmount: the clearInterval that prevents the leak. Try commenting it out, mount and unmount the panel a few times, and watch the console: the clock's log messages pile up, one per dead instance.
  • render: pure. minutesElapsed, remaining, and the conditional class are derived values computed on every render, not state. The same distinction from 02-04, now in class syntax.

That same component, written with hooks, takes up half the space:

// src/components/ActivityPanel.jsx  — MODERN VERSION (preview of 05-02)
import { useState, useEffect } from 'react';
import styles from './ActivityPanel.module.css';

function ActivityPanel({ booking }) {
  const [now, setNow] = useState(new Date());

  useEffect(() => {
    const timer = setInterval(() => setNow(new Date()), 1000);
    return () => clearInterval(timer);   // the cleanup, RIGHT NEXT to what it cleans up
  }, []);

  const start = new Date(booking.startDate);
  const minutesElapsed = Math.max(0, Math.floor((now - start) / 60000));
  const remaining = booking.hours * 60 - minutesElapsed;

  return (
    <section className={styles.panel}>
      <h2>Activity for booking {booking.id}</h2>
      <p>Current time: {now.toLocaleTimeString('en-GB')}</p>
      <p>Minutes elapsed: {minutesElapsed}</p>
      <p className={remaining < 15 ? styles.urgent : undefined}>
        {remaining > 0 ? `${remaining} minutes of booking left.` : 'The booking has ended.'}
      </p>
    </section>
  );
}

export default ActivityPanel;

Notice the difference that matters most: the clearInterval sits three lines below the setInterval, inside the same block, instead of fifty lines away in another method. Forgetting it becomes much harder. The full useEffect API — what that returned function is, what the empty array at the end means — gets explained in 05-02; here only the structural comparison matters.

  1. Equivalence table: class → function with hooks

Class method Hook equivalent Lesson
constructor + this.state = {…} useState(initialValue) 05-01
this.setState({…}) The updater function from useState 05-01
render() The function body, with its return 02-02
componentDidMount useEffect(() => { … }, []) 05-02
componentDidUpdate useEffect(() => { … }, [dependencies]) 05-02
componentWillUnmount The cleanup function returned by useEffect 05-02
shouldComponentUpdate React.memo around the component 08-02
PureComponent React.memo 08-02
this.timer = … (non-visual data) useRef 05-03
static contextType useContext 05-04
this.myNode = createRef() useRef 05-03
HOC or render props (04-02) Custom hook 05-06
getDerivedStateFromError / componentDidCatch No equivalent: still requires a class 04-05
UNSAFE_componentWillReceiveProps Almost always: nothing. Recompute during render 04-01

The last row deserves emphasis. UNSAFE_componentWillReceiveProps was mostly used to copy props into state when they changed, and that need is almost never real: if a value depends on props, it gets computed during render as a derived value, exactly like you did with visibleBikes in 04-01. The lack of an equivalent isn't a gap in hooks: it's that the problem was framed wrong to begin with.

  1. Why the equivalence is only approximate

The table above is a translation aid, not a description of how modern React actually works. If you walk away thinking "useEffect with an empty array is componentDidMount," you'll write incorrect effects. Three real differences:

First: useEffect doesn't fire at the same instants. It runs after the browser has painted, whereas componentDidMount runs before. For most cases it makes no difference, but if you need to measure the DOM and adjust the layout without a flicker, there's a dedicated hook for that (useLayoutEffect, mentioned in 05-02).

Second: a component can have many effects, and a class only ever had one componentDidMount. In a class, the timer subscription, the data fetch, and the analytics logging all shared a method even though they had nothing to do with each other. With hooks, each concern gets its own useEffect, grouped with its matching cleanup. It's organized by subject, not by moment.

Third, and the one that really matters: the mental model changes. The right question is no longer "at what point in the lifecycle do I do this?" but:

"What external system needs to stay synchronized with this component's state, and how does it desynchronize once the component no longer needs it?"

An effect isn't "code that runs on mount." It's a description of a synchronization: as long as this component exists with these props and this state, a timer must be running / a subscription must be open / a connection must be established; and once that stops being true — because the component unmounts or because the dependencies change — the synchronization gets undone and, if needed, redone with the new values.

That shift in mindset explains a behaviour that puzzles anyone coming from classes: an effect with dependencies can run its cleanup and mount again many times over the component's life, not just at the beginning and the end. Under the "lifecycle moments" model that looks like a bug; under the synchronization model it's exactly what should happen. This gets developed in full in useEffect.

Old mental model (classes) Correct mental model (modern React)
"When does this run?" "What should this stay in sync with?"
Code is grouped by moment Code is grouped by subject
Mounting and unmounting are one-off events Syncing and unsyncing can repeat
Cleanup lives far from what it cleans up Cleanup sits right next to what it cleans up
One method per phase As many effects as there are external systems

  1. Living with classes in a real codebase

Classes aren't going away, for three reasons:

  1. React keeps them working. There's no deprecation announcement: class-based code will keep running.
  2. Migrating costs money and adds no functionality. A class component that's been running fault-free for five years is nobody's priority.
  3. Error boundaries still require a class. It's the one piece of React 19 with no hook equivalent, and you'll see it in the next lesson.

How to work with them without suffering:

  • You can freely mix them. A function component can render a class component and vice versa, no adapters needed. Both receive props the same way and take part in the same tree.
  • Don't convert for the sake of converting. Migrate a class component when you already need to touch it for some other reason: a bug, a new feature, a requested refactor. Opportunistic migration has the best risk-to-benefit ratio.
  • Prioritize by real pain. The clear candidates are components with bind scattered everywhere, with the same logic duplicated between componentDidMount and componentDidUpdate, or wrapped in several HOCs (04-02). There, migration pays for itself.
  • A class component can't use hooks. It's an absolute limitation: if you need a hook inside a class, there's no shortcut, you have to convert the component.
  • When migrating, start with an inventory. List which lifecycle methods the component uses, translate each one with the table from section 8, and then review the dependency list of each effect. Copying only the body of render is the mistake flagged back in 02-02.

Common Mistakes and Tips

  • setState without a comparison inside componentDidUpdate. Infinite loop and "Maximum update depth exceeded." Always compare previousProps against this.props before updating.
  • Forgetting componentWillUnmount. Timers, listeners, and subscriptions that outlive the component: a memory leak and setState-on-unmounted-component warnings. One teardown at unmount for every subscription at mount.
  • removeEventListener with a different function. Passing a new arrow function removes nothing. Store the reference on the instance or bind it in the constructor.
  • Forgetting super(props). Immediate error when using this in the constructor. Always the first line.
  • Mutating state directly. this.state.hours = 5 triggers no render and leaves the screen stale. Only this.setState.
  • Trusting that this.state is updated right after setState. It isn't: updates are batched. Use the functional form this.setState((previous) => …) when the new value depends on the previous one, exactly like with useState.
  • Storing data that isn't painted in state. A timer's identifier belongs on this, not in this.state; storing it as state causes pointless renders.
  • Tip: use console.log with a prefix to learn the order. Add a message to each method, mount and unmount the component, and watch the real sequence in the console. With StrictMode you'll see the development double-mount, a good way to check that your cleanup actually works.
  • Tip: don't learn modern React in terms of the lifecycle. Use the table to translate legacy code, but reason about new effects in terms of synchronization.

Exercises

Exercise 1. This CicloUrbano class component has three lifecycle-related bugs. Find them, explain what each one causes, and fix them.

class StationCounter extends Component {
  constructor(props) {
    this.state = { visits: 0, now: new Date() };
  }

  componentDidMount() {
    setInterval(() => this.setState({ now: new Date() }), 1000);
  }

  componentDidUpdate() {
    this.setState({ visits: this.state.visits + 1 });
  }

  render() {
    return <p>Visits: {this.state.visits} — {this.state.now.toLocaleTimeString('en-GB')}</p>;
  }
}

Exercise 2. Translate the following StationClock into a function component with hooks. It shows a station's time and re-subscribes whenever the station changes. You don't need to master useEffect yet: use the table from section 8 and the example from section 7 as your guide.

class StationClock extends Component {
  constructor(props) {
    super(props);
    this.state = { time: new Date() };
    this.updateTime = this.updateTime.bind(this);
  }

  componentDidMount() {
    this.timer = setInterval(this.updateTime, 1000);
  }

  componentWillUnmount() {
    clearInterval(this.timer);
  }

  updateTime() {
    this.setState({ time: new Date() });
  }

  render() {
    return (
      <p>
        {this.props.station.name}: {this.state.time.toLocaleTimeString('en-GB')}
      </p>
    );
  }
}

Exercise 3. For each of these three CicloUrbano cases, explain which lifecycle method it would go in as a class, and which hook solves it today. Justify your answer in terms of synchronization, not moments.

  1. Logging a visit to a bike's detail page in an analytics service, every time the displayed bike changes.
  2. Opening a real-time connection that reports the free docks at the selected station.
  3. Computing a booking's total price from pricePerHour and hours.

Solutions

Solution 1. The three bugs:

  • Missing super(props) in the constructor. JavaScript throws "Must call super constructor before accessing 'this'" and the component never mounts.
  • The interval never gets cancelled: there's no componentWillUnmount, and its identifier is never stored. Every mount leaves a timer running forever.
  • componentDidUpdate calls setState without comparing anything: every update triggers another one, in an infinite loop. And since the interval updates every second, the loop would start on its own even if nobody touched anything. Counting real visits needs something to compare against; the fix assumes the component receives a stationId prop.
class StationCounter extends Component {
  constructor(props) {
    super(props);                                     // 1. fixed
    this.state = { visits: 0, now: new Date() };
  }

  componentDidMount() {
    this.timer = setInterval(                         // 2. store the reference
      () => this.setState({ now: new Date() }),
      1000
    );
  }

  componentDidUpdate(previousProps) {
    if (previousProps.stationId !== this.props.stationId) {   // 3. comparison
      this.setState((previous) => ({ visits: previous.visits + 1 }));
    }
  }

  componentWillUnmount() {
    clearInterval(this.timer);                        // 2. tear down the timer
  }

  render() {
    return <p>Visits: {this.state.visits} — {this.state.now.toLocaleTimeString('en-GB')}</p>;
  }
}

Notice also the functional form (previous) => ({ visits: previous.visits + 1 }): the same precaution as with useState, because the new value depends on the previous one and updates are batched.

Solution 2.

// src/components/StationClock.jsx
import { useState, useEffect } from 'react';

/**
 * Props:
 *  - station (Station object, required)
 */
function StationClock({ station }) {
  const [time, setTime] = useState(new Date());

  useEffect(() => {
    const timer = setInterval(() => setTime(new Date()), 1000);
    return () => clearInterval(timer);
  }, []);

  return (
    <p>
      {station.name}: {time.toLocaleTimeString('en-GB')}
    </p>
  );
}

export default StationClock;

Method-by-method translation: this.state = { time } → useState(new Date()); componentDidMount → the body of the useEffect with an empty array; componentWillUnmount → the function returned by the effect; render → the function's return. The constructor, the bind, the four this occurrences, and the class itself all disappear: from 24 lines down to 12, and the cleanup ends up right next to what it cleans up.

Solution 3.

Case Class method Modern solution Synchronization reasoning
1. Log a visit to a detail page componentDidMount + componentDidUpdate comparing bike.id useEffect with [bike.id] in its dependencies There's an external system (analytics) that needs to know every time the displayed bike changes. The duplication across two methods disappears: a single block covers the first send and every one after
2. Real-time connection for free docks componentDidMount to open it, componentDidUpdate to close the old one and open the new one, componentWillUnmount to close it useEffect with [station.id] and a cleanup function that closes the connection The connection needs to be open while the component shows that station. When the station changes, the synchronization is undone (closed) and redone (opened) with the new value. This is exactly what the "moments" model can't express
3. Compute the total price None No hook at all: a value computed in the function body There's no external system whatsoever. It's a value derived from props and state, just like visibleBikes in 04-01. Putting it in an effect would add an extra render and a chance to fall out of sync, for nothing in return

The third case is the most instructive one: half the unnecessary effects written in React are derived calculations disguised as synchronization.

Conclusion

React components are born, get updated, and die, and for years those three phases were managed with class components' lifecycle methods: constructor and render for mounting, componentDidMount for anything that needs the DOM already painted, componentDidUpdate — with its mandatory comparison against previous props — for reacting to changes, componentWillUnmount for tearing down what was set up and avoiding leaks, shouldComponentUpdate for performance, and the UNSAFE_ methods as a reminder of what not to do. You now know how to read them, fix their classic bugs, and translate them with the equivalence table.

But the conclusion that really matters is the one from section 9: the equivalence is approximate, and the mental model is different. Modern React doesn't think in lifecycle moments; it thinks in terms of synchronizing the component with external systems and undoing that synchronization once it's no longer needed. That idea is the backbone of useEffect.

And one natural question remains: if hooks replace almost everything classes used to do, what exactly are they, and why do they have such strict rules about where they can be written and how many there can be? The next lesson is Hooks: Introduction and Basic Use, where you'll see the internal mechanism behind those rules, the full catalogue of hooks you'll use throughout the course, and your first CicloUrbano component that combines state and an effect.

React Course

Module 1: Getting Started with React

Module 2: React Components

Module 3: Working with Events

Module 4: Advanced Component Concepts

Module 5: React Hooks

Module 6: Routing in React

Module 7: State Management

Module 8: Performance Optimization

Module 9: Testing React Applications

Module 10: Advanced Topics

Module 11: Project: Building a Complete Application

© Copyright 2026. All rights reserved