The previous module closed with a very concrete debt: TypeSelector keeps the chosen type in its own state and notifies the parent, but the catalogue keeps showing all five bikes no matter what; and BikeCard notifies through onSelect, but App can only do a console.log, because it has nowhere to store the selected bike. Both failures share the same cause: state is private to each component, and two siblings can never see each other's state. In this lesson you'll learn the technique that solves this — lifting state up —, apply it so CicloUrbano's filter genuinely works, understand why duplicating the same piece of data in two places is a guaranteed source of bugs, and see the price you pay for lifting state: prop drilling.

Contents

  1. The problem: two siblings and one shared piece of data
  2. The rule: move up to the nearest common ancestor
  3. CicloUrbano's refactor, step by step
  4. App gets state: chosenType and selectedBike
  5. Derived state: visibleBikes isn't state
  6. Unidirectional flow, seen end to end
  7. A single source of truth: the duplication trap
  8. Controlled and uncontrolled components, at component level
  9. What NOT to lift
  10. The cost of lifting: prop drilling

  1. The problem: two siblings and one shared piece of data

This is CicloUrbano's tree as it stood at the end of Module 3:

flowchart TD
    APP["App<br/>(no state)"] --> CAB["Header"]
    APP --> RES["FleetSummary"]
    APP --> SEL["TypeSelector<br/><b>state: chosenType</b>"]
    APP --> LIS["BikeList<br/>(no state)"]
    APP --> PIE["Footer"]
    LIS --> T1["BikeCard"]
    LIS --> T2["BikeCard"]
    SEL -. "how does<br/>chosenType get here?" .-x LIS

The crossed-out arrow is the problem. chosenType lives inside TypeSelector, and from there the data can only travel downward (to TypeSelector's children, which it doesn't have) or upward (notifying the parent through a function prop). What doesn't exist in React is a sideways path: a component can't read its sibling's state.

And it isn't an arbitrary limitation. If BikeList could read TypeSelector's state, the two would be coupled: you couldn't reuse the list on another screen without dragging the selector along, and to understand why the list shows what it shows you'd have to read a component that doesn't even appear in its code. React chooses this restriction on purpose, and in exchange offers a simple rule for working around it.

  1. The rule: move up to the nearest common ancestor

When two or more components need the same piece of data, that data must live in the state of their nearest common ancestor, and flow down to each of them as a prop.

The technique is called lifting state up, and it takes three moves:

Step What happens In CicloUrbano
1. Identify Which components need the data? TypeSelector (to paint the active button) and BikeList (to filter)
2. Locate What's their nearest common ancestor? App
3. Move State moves up; data flows down as a prop; the child notifies via a callback chosenType moves from TypeSelector to App

The component that loses its state isn't left mute: it receives two props instead.

  • The current value (chosenType), to paint itself with.
  • A notification function (onTypeChange), to ask the parent to change it.

If this sounds familiar, it's because it's exactly the pattern you used in 03-04 with a controlled <input>: value + onChange. The difference is that there the controlled component was a DOM tag, and here it's a component of your own. The pattern is the same, and we'll come back to it in section 8.

  1. CicloUrbano's refactor, step by step

Step 1: TypeSelector stops storing state

This is the version from 03-01, the one we're about to replace:

// src/components/TypeSelector.jsx  — VERSION TO REPLACE
function TypeSelector({ onTypeChange }) {
  const [chosenType, setChosenType] = useState('todos');   // <- the state getting in the way

  function handleTypeSelect(type) {
    setChosenType(type);
    if (onTypeChange) {
      onTypeChange(type);
    }
  }
  // …
}

And here's the new one:

// src/components/TypeSelector.jsx
import { cx } from '../utils/classNames.js';
import styles from './TypeSelector.module.css';

const TYPES = ['todos', 'urbana', 'electrica', 'carga'];

const LABELS = {
  todos: 'All',
  urbana: 'Urban',
  electrica: 'Electric',
  carga: 'Cargo'
};

/**
 * Bike type selector. CONTROLLED component: stores no state.
 * Props:
 *  - chosenType   (string, required): the type that's active right now
 *  - onTypeChange (function, required): receives the type that was clicked
 */
function TypeSelector({ chosenType, onTypeChange }) {
  function handleTypeSelect(type) {
    onTypeChange(type);
  }

  function handleClearKey(event) {
    if (event.key === 'Escape') {
      onTypeChange('todos');
    }
  }

  return (
    <div className={styles.selector} onKeyDown={handleClearKey}>
      <p className={styles.title}>Filter by type:</p>

      {TYPES.map((type) => (
        <button
          key={type}
          type="button"
          className={cx(styles.button, type === chosenType && styles.active)}
          onClick={() => handleTypeSelect(type)}
          aria-pressed={type === chosenType}
        >
          {LABELS[type]}
        </button>
      ))}

      <p className={styles.selection}>
        Current selection: <strong>{LABELS[chosenType]}</strong>
      </p>
    </div>
  );
}

export default TypeSelector;

What changed, and why:

  • useState and its import disappear. The component no longer remembers anything: everything it paints comes from its props. It has become a pure presentational component, the kind you saw in 02-01.
  • onTypeChange moves from optional to required. Before, it was an extra: the selector worked without it because it painted itself from its own state. Now it's the only way to change anything; without that prop the component would be a decoration that doesn't respond. Document it in the /** Props: … */ block.
  • The active button is decided with chosenType, which now arrives from outside. The JSX hasn't changed a single line: it doesn't care where the value comes from.
  • aria-pressed communicates the button's state to assistive technologies, following what you learned in 03-06: selection can't be conveyed through CSS colour alone.

Step 2: App takes ownership of the state

// src/App.jsx
import { useState } from 'react';
import { bikes, stations } from './data/domain.js';
import Header from './components/Header.jsx';
import FleetSummary from './components/FleetSummary.jsx';
import TypeSelector from './components/TypeSelector.jsx';
import BikeList from './components/BikeList.jsx';
import BookingPanel from './components/BookingPanel.jsx';
import Footer from './components/Footer.jsx';

function App() {
  const [chosenType, setChosenType] = useState('todos');
  const [selectedBike, setSelectedBike] = useState(null);

  // DERIVED value: recalculated on every render, never stored
  const visibleBikes =
    chosenType === 'todos'
      ? bikes
      : bikes.filter((bike) => bike.type === chosenType);

  function handleTypeChange(type) {
    setChosenType(type);
  }

  function handleBikeSelect(bike) {
    setSelectedBike(bike);
  }

  function handleBooking(bike) {
    setSelectedBike(bike);
  }

  function handleConfirm({ bike, hours, total }) {
    console.log('Booking confirmed', bike.id, hours, total);
    setSelectedBike(null);
  }

  return (
    <>
      <Header />
      <main>
        <FleetSummary fleet={bikes} />

        <TypeSelector chosenType={chosenType} onTypeChange={handleTypeChange} />

        <BikeList
          bikes={visibleBikes}
          stations={stations}
          onSelect={handleBikeSelect}
          onBook={handleBooking}
        />

        <BookingPanel
          bike={selectedBike}
          hours={2}
          onConfirm={handleConfirm}
        />
      </main>
      <Footer />
    </>
  );
}

export default App;

What's happened here is the whole module condensed into one file:

  • App finally has state, and it's exactly two pieces of data: which filter is active, and which bike is selected. Not one more.
  • BikeList receives visibleBikes, not bikes. The component hasn't changed: it still receives an array and paints it with map. It doesn't even know a filter exists, and that ignorance is a virtue, because it's what makes it reusable on any screen.
  • BookingPanel finally receives a real bike. The early returns you wrote in 03-02 — "Select a bike from the catalogue" when the prop is missing — stop being a hypothesis: with selectedBike starting at null, that's literally the first state the person using the app sees.
  • handleConfirm clears the selection by setting it to null, which sends the panel back to its initial message. A complete cycle, no tricks.

And BikeList gets a free bonus from something you'd already written: its empty state. If you filter by "Cargo" and the only cargo bike is in maintenance, the bikes.length === 0 check you wrote in 03-03 fires and shows the message. You haven't had to write a single new line.

The tree, afterward

flowchart TD
    APP["App<br/><b>state: chosenType</b><br/><b>state: selectedBike</b>"]
    APP -- "chosenType ▼" --> SEL["TypeSelector<br/>(no state)"]
    APP -- "visibleBikes ▼" --> LIS["BikeList"]
    APP -- "bike ▼" --> PAN["BookingPanel"]
    SEL -. "onTypeChange(type) ▲" .-> APP
    LIS --> TAR["BikeCard"]
    TAR -. "onSelect(bike) ▲" .-> APP

Data flows down through props (solid arrows), and notifications flow up through functions (dashed arrows). There isn't a single horizontal arrow: the siblings still don't talk to each other — they communicate through the parent.

  1. App gets state: what belongs and what doesn't

When state moves up, the temptation is to move everything up. Run each piece of data through this filter before turning it into the parent's state:

Question If the answer is yes…
Does it change over time through user action? It can be state
Does more than one component need it? It must live in the common ancestor
Can it be computed from other state or from props? It isn't state: it's a derived value
Does only one component use it, and no one else? Leave it inside that component

In CicloUrbano, chosenType and selectedBike pass the first two questions and fail the third: there's no way to derive them from anything. They're legitimate state.

A detail about selectedBike: we store the whole object, not its id. With static data imported from domain.js, that works perfectly. Once the data comes from a server and can be updated (Module 7), the recommended practice will be to store the id and look up the object on the fly, because an object stored in state is a frozen copy that has no idea if the original changes. It's the same warning about duplication that comes up in section 7.

  1. Derived state: visibleBikes isn't state

This is the most common mistake when lifting state. The incorrect version:

// INCORRECT: two states that have to be kept in sync by hand
const [chosenType, setChosenType] = useState('todos');
const [visibleBikes, setVisibleBikes] = useState(bikes);

function handleTypeChange(type) {
  setChosenType(type);
  setVisibleBikes(
    type === 'todos' ? bikes : bikes.filter((b) => b.type === type)
  );
}

It works… until someone adds another way to change the filter and forgets the second line. At that point the selector says "Electric" and the list shows every bike. The bug isn't in the filtering logic: it's that the same information is stored twice, and nothing guarantees the two copies agree.

You've already seen the correct version: a single line, no state.

const visibleBikes =
  chosenType === 'todos'
    ? bikes
    : bikes.filter((bike) => bike.type === chosenType);

It's recalculated on every render, exactly when it's needed, and it's impossible for it to drift out of sync because there's nothing to keep in sync. It's the same lesson from 02-04 — state versus derived value — now applied to a container component.

"Isn't filtering on every render inefficient?" With five bikes, or with five hundred, no. filter over a small array costs microseconds — far less than the complexity of keeping two states in hand-written sync. If the calculation is ever genuinely expensive, and you've measured it, useMemo exists (08-03). Measure first, optimize later.

  1. Unidirectional flow, seen end to end

Let's trace a click from start to finish. Someone clicks "Electric":

sequenceDiagram
    participant U as Person using the app
    participant S as TypeSelector
    participant A as App
    participant L as BikeList
    U->>S: clicks "Electric"
    S->>A: onTypeChange('electrica')
    A->>A: setChosenType('electrica')
    Note over A: React schedules a new render
    A->>A: visibleBikes = filter(...)
    A->>S: chosenType = 'electrica' (active button)
    A->>L: bikes = [bici-002, bici-005]
    L->>U: two cards on screen

Notice an important detail: TypeSelector doesn't change anything by itself. You click the button and nothing visible happens until App updates its state and re-renders. The selector only requests the change. If App decided to ignore the request — say, forbidding the "Cargo" filter for customers — the button wouldn't activate. All the authority sits in one place, and that's what makes the behaviour predictable and debuggable: if something looks wrong, the wrong state is in App.

This closed loop is what's known as unidirectional data flow, and it's why a large React application can be reasoned about just by reading top to bottom.

  1. A single source of truth: the duplication trap

The principle reads like this: each piece of data must have exactly one owner. Any other component that needs it receives it as a prop; no one makes a local copy.

Let's see the bug with a reproducible example. Imagine that, after lifting the state, you also leave a copy inside the child "for convenience":

// INCORRECT: the child copies the prop into its own state
function TypeSelector({ chosenType, onTypeChange }) {
  const [localType, setLocalType] = useState(chosenType);   // <- the poisoned copy

  function handleTypeSelect(type) {
    setLocalType(type);
    onTypeChange(type);
  }
  // … paints the active button using localType
}

At first glance it works. But it plants two time bombs:

  1. useState(chosenType) only reads the prop on the first render. It's the initial value, not a permanent link. If tomorrow App adds a "Reset filters" button that does setChosenType('todos'), the parent will say "todos" and the selector will keep showing "Electric" as active. Forever.
  2. There are two truths at once. Which is the actually chosen type? It depends on who you ask. And debugging that means looking at two components in DevTools and comparing them.

The practical rule, with no exceptions worth learning right now:

Situation What to do
The child needs to display data from the parent Receive it as a prop and use it directly
The child needs to change that data Call a function the parent passes down
The child needs data no one else uses useState inside the child, no guilt
The child needs to "initialize" from a prop and then go its own way Rethink the design; the data almost always belonged up above

  1. Controlled and uncontrolled components, at component level

In 03-04 and 03-05 you applied these terms to form fields. Now they apply just the same to components you write yourself, and the distinction is exactly the same.

Controlled component Uncontrolled component
Where the data lives In the parent Inside the component itself
What props it receives The value + a change callback At most, an initial value
Who's in charge The parent The component
Advantage The parent can read it, coordinate it, reset it Used in a single line, no ceremony
Drawback The parent has to declare state No one else can see or change the data
Example in CicloUrbano TypeSelector (after this lesson) Accordion with defaultOpen

The same component can support both modes, and it's a pattern you'll see in many libraries. The technique: if the value prop arrives defined, the component obeys the parent; if not, it manages its own state.

// src/components/Accordion.jsx

/**
 * Collapsible section. Supports two modes:
 *  - Controlled:   <Accordion open={open} onToggle={...} />
 *  - Uncontrolled: <Accordion defaultOpen />
 * Props:
 *  - title       (string, required)
 *  - children    (content)
 *  - open        (boolean, optional): activates controlled mode
 *  - defaultOpen (boolean, optional): initial value for uncontrolled mode
 *  - onToggle    (function, optional): receives the new value
 */
function Accordion({ title, children, open, defaultOpen = false, onToggle }) {
  const [internalOpen, setInternalOpen] = useState(defaultOpen);

  const isControlled = open !== undefined;
  const isOpen = isControlled ? open : internalOpen;

  function handleToggle() {
    if (!isControlled) {
      setInternalOpen(!isOpen);
    }
    if (onToggle) {
      onToggle(!isOpen);
    }
  }

  return (
    <section>
      <h3>
        <button type="button" onClick={handleToggle} aria-expanded={isOpen}>
          {title}
        </button>
      </h3>
      {isOpen && <div>{children}</div>}
    </section>
  );
}

export default Accordion;

The three key lines:

  • const isControlled = open !== undefined; The contract is explicit: passing the prop means "I'm in charge." It's compared against undefined rather than a falsy value, because open={false} is a perfectly valid value that must still activate controlled mode.
  • const isOpen = isControlled ? open : internalOpen; The rest of the component always uses this variable and never knows which mode is active. All the branching fits in one line.
  • if (!isControlled) setInternalOpen(...) In controlled mode the component never touches its internal state: it only notifies. Updating both would duplicate the truth — the mistake from section 7.

Not every component of yours needs to support both modes — it adds complexity — but it's worth being able to read this pattern, because it's the design behind almost every library component you'll ever use.

  1. What NOT to lift

Lifting is a tool, not a dogma. Moving up state that nobody else needs makes the code worse: it forces App to re-render the whole tree for changes that only affect one corner, and it clutters the root component with irrelevant data.

Clear cases of state that should stay put:

Local state Why it stays Where it lives
Text typed into a debounced search box The parent only needs the settled term, not every keystroke In the search box; it notifies upward once things settle
Whether a panel is collapsed or expanded No one else decides anything based on that data In the panel
Whether a dropdown menu is open Purely visual and short-lived In the menu
The active tab index inside an isolated widget A presentation detail In the widget
Whether the mouse is hovering over a card Short-lived and per-instance In the card

The debounced search box illustrates this well:

// src/components/BikeSearch.jsx
/**
 * Props:
 *  - onSearch (function): receives the term, but only once typing settles
 */
function BikeSearch({ onSearch }) {
  const [text, setText] = useState('');   // LOCAL STATE: doesn't move up

  function handleChange(event) {
    setText(event.target.value);
    // the parent gets notified with a delay; you'll see the mechanism in 05-02
  }

  return (
    <input
      type="search"
      value={text}
      onChange={handleChange}
      aria-label="Search bikes by model"
    />
  );
}

If text lived in App, every keystroke would trigger a render of the whole tree, including the entire catalogue. By staying inside, only the <input> repaints, and the parent finds out once, when there's actually something to do.

The control question is always the same: does anyone else need this data to render themselves or to decide something? If the answer is no, it stays where it is. And if the answer changes tomorrow, lifting it is a ten-minute refactor: there's no need to anticipate it.

  1. The cost of lifting: prop drilling

Lifting has a cost, and it's worth being upfront about it. When the common ancestor is far away, the data has to pass through every component in between, even though none of them have any use for it. It's called prop drilling.

Imagine CicloUrbano grows and the card needs to know the user's role (usr-02 is an operator and can flag maintenance):

// App -> Catalogue -> BikeList -> BikeCard

function App() {
  const [user, setUser] = useState(users[0]);
  return <Catalogue user={user} />;              // level 1
}

function Catalogue({ user }) {                   // doesn't use it, just forwards it
  return <BikeList user={user} bikes={bikes} />;
}

function BikeList({ user, bikes }) {              // doesn't use it either
  return bikes.map((bike) => (
    <BikeCard key={bike.id} bike={bike} user={user} />
  ));
}

function BikeCard({ bike, user }) {               // finally, it's used here
  return user.role === 'operario' ? <MaintenanceButton /> : null;
}
flowchart TD
    A["App<br/>state: user"] -- "user" --> B["Catalogue<br/>❌ doesn't use it"]
    B -- "user" --> C["BikeList<br/>❌ doesn't use it"]
    C -- "user" --> D["BikeCard<br/>✅ uses it"]

The symptoms are easy to spot:

  • Intermediate components with props that only get forwarded, never used.
  • Adding a new piece of data means touching four files just to get it to one.
  • Renaming the prop means a find-and-replace across the whole chain.
  • The intermediate components lose reusability: they demand a prop they don't even care about.

With two levels it isn't a problem, and it's not worth fixing before it hurts. With four or five, it is. React has an answer for this: the Context API, which lets you make a value available to an entire subtree without passing it hand to hand. You'll see it in useContext and, at the scale of a whole application, in Module 7, where external state managers also show up. Here we've only put a name to the problem.

Common Mistakes and Tips

  • Copying a prop into the child's state. useState(props.value) only reads the prop once, on the first render. From then on, the two copies diverge and never match again. If you need to both read and change the data, receive it as a prop and notify upward.
  • Storing in state what can be computed. visibleBikes, totals, counters, formatted text — all of that is derived. Every extra piece of state is one more chance for things to drift out of sync.
  • Lifting too high "just in case." The right destination is the nearest common ancestor, not the root. Putting everything in App turns the root component into a dumping ground and causes unnecessary renders.
  • Forgetting to pass the callback. If you turn a component into a controlled one and forget the change prop, you get an inert component that doesn't respond to clicks, with no error in the console. Document required props and consider a warning in development.
  • Passing the value but not the handler (or the other way around). A controlled component needs both props: with only one, it's left half-working, just like an <input value> without onChange ends up read-only.
  • Tip: look at DevTools. Select App in React DevTools and you'll see its two pieces of state change live as you click filters and cards. It's the best way to confirm the source of truth lives where you think it does.
  • Tip: name your props like a contract. The pair chosenType / onTypeChange reads like value / onChange. Keeping that symmetry across the whole project means components can be used without ever reading their code.

Exercises

Exercise 1. Lift the state of BookingPanel. Right now it receives hours as a fixed prop with value 2, so it can't be changed. Make App store bookingHours in its state (initial value 2), and have BookingPanel receive hours and a new prop onHoursChange, with two buttons ("−" and "+") that never go below 1 hour. The total should recalculate on its own. Explain why the total must not be state.

Exercise 2. Add an accessible results counter to App. Below TypeSelector, show text along the lines of "Showing 2 of 5 bikes," with correct singular/plural agreement, inside an element with aria-live="polite" (03-06). Don't add any new state: the text must come entirely from visibleBikes and bikes.

Exercise 3. Spot and fix the desync bug. This component has the mistake from section 7. Describe it with a concrete case that makes it fail, and rewrite it correctly.

function SelectionSummary({ selectedBike }) {
  const [model, setModel] = useState(
    selectedBike ? selectedBike.model : 'None'
  );

  return <p>Selected bike: {model}</p>;
}

Solutions

Solution 1.

// src/App.jsx (excerpt)
const [bookingHours, setBookingHours] = useState(2);

function handleHoursChange(newHours) {
  setBookingHours(Math.max(1, newHours));
}

<BookingPanel
  bike={selectedBike}
  hours={bookingHours}
  onHoursChange={handleHoursChange}
  onConfirm={handleConfirm}
/>
// src/components/BookingPanel.jsx (excerpt of the final block)
/**
 * Props:
 *  - bike          (object, optional)
 *  - hours         (number, optional, defaults to 1)
 *  - onHoursChange (function, optional): receives the new number of hours
 *  - onConfirm     (function, optional): receives { bike, hours, total }
 */
function BookingPanel({ bike, hours = 1, onHoursChange, onConfirm }) {
  // … guard clauses from 03-02, unchanged …

  const total = bike.pricePerHour * hours;          // DERIVED
  const formattedTotal = total.toFixed(2);

  return (
    <div className={styles.panel}>
      <h3>{bike.model}</h3>

      <div className={styles.hours}>
        <button
          type="button"
          onClick={() => onHoursChange(hours - 1)}
          disabled={hours <= 1}
          aria-label="Remove one hour"
        >
          −
        </button>
        <span>{hours === 1 ? '1 hour' : `${hours} hours`}</span>
        <button type="button" onClick={() => onHoursChange(hours + 1)} aria-label="Add one hour">
          +
        </button>
      </div>

      <p className={styles.total}>Total: €{formattedTotal}</p>
      <button type="button" onClick={() => onConfirm({ bike, hours, total })}>
        Confirm booking
      </button>
    </div>
  );
}

The total isn't state because it's fully derived from bike.pricePerHour and hours. Storing it would force you to recalculate it in two places (when the hours change and when the bike changes), and forgetting just one would be enough to show a false price. The lower bound is enforced in App, the data's owner, not in the panel: that way the rule lives right next to the state.

Solution 2.

// src/App.jsx (excerpt, right below TypeSelector)
<p aria-live="polite" className="results">
  {visibleBikes.length === 1
    ? `Showing 1 bike of ${bikes.length}.`
    : `Showing ${visibleBikes.length} bikes of ${bikes.length}.`}
</p>

No new state: both numbers come from arrays that already exist. aria-live="polite" makes the screen reader announce the change once it finishes reading whatever it's currently reading, so someone filtering with the keyboard knows how many results remain even without seeing the list.

Solution 3. The bug: useState takes the model exactly once, on the first render. Since selectedBike starts as null, model is 'None' forever; click all five cards and the text will never change. Not even a later change of bike fixes it, because the state is already initialized. It's the classic mistake of copying a prop into state.

// CORRECT: no state; the parent already has the data
function SelectionSummary({ selectedBike }) {
  const model = selectedBike ? selectedBike.model : 'None';
  return <p>Selected bike: {model}</p>;
}

The component becomes a pure function of its props: same props in, same output out, always. Impossible for it to drift out of sync.

Conclusion

Lifting state up is the technique that turns a bunch of loose components into an application. The rule fits in one sentence: shared state lives in the nearest common ancestor, flows down as props, and flows back up as notifications. Applying it, CicloUrbano has settled the debt it had been carrying since Module 2: TypeSelector has become a controlled component with no state of its own, App stores chosenType and selectedBike, visibleBikes is computed as a derived value — never as state — and BookingPanel finally receives a real bike. The catalogue genuinely filters now.

You've also seen the technique's limits. Lifting too much makes the code worse: genuinely local state — a search box's text, whether a panel is collapsed — should stay put. And lifting across a long distance has a cost with a name of its own, prop drilling, whose answer arrives with useContext and Module 7.

There's one open question left, and it surfaces as soon as the interface grows: App is already starting to accumulate sections, and soon you'll need to wrap the catalogue in a layout with a header and sidebar, show notices of several kinds, and build reusable panels. In other languages, the answer would be inheritance: a BasePanel that CataloguePanel and BookingsPanel inherit from. In React that answer is the wrong one, and the right one has a different name. The next lesson is Composition vs Inheritance, where you'll learn to build components out of other components without ever inheriting once.

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