9 min read

React's Chain of Command: Parent-Child Communication and Lifting State Up

React's Chain of Command: Parent-Child Communication and Lifting State Up

Welcome back to our journey into the heart of React! If you’ve been following along, you’ve seen how we can create predictable UIs with components and JSX, and how we can design for reuse, as we discussed when Designing reusable component systems, and manage dynamic lists with tools like keys, which we explored in Keys in lists and why they matter for dynamic UIs. We’ve built a solid foundation.

But what happens when our components need to start talking to each other?

Think of a well-organized kitchen. The head chef (the parent component) knows the entire menu and holds the master list of orders. They pass specific instructions down to the line cooks (the child components), like “prepare one pizza” or “start grilling one steak.” That’s a one-way street of information, clear and simple.

But what if a line cook finishes a dish? They can’t just change the master order list themselves; that would be chaos. Instead, they need a way to signal back to the head chef: “Hey, that steak is ready!” The chef then updates the master list and moves on to the next step.

This is the essence of parent-child communication in React. It’s a beautifully simple, yet powerful, pattern that keeps our applications organized, predictable, and easy to debug. In this article, we’ll break down how data flows down from parent to child, and more importantly, how we can "lift state up" to allow children to communicate back to their parents.

The One-Way Street: Passing Data Down with Props

First, let's revisit the most common way components communicate: from parent to child. This is done using props (short for properties). Props are like arguments you pass to a function. They are read-only data that a parent component passes down to its children.

Imagine we have an OrderDashboard that displays a single, featured order. The dashboard knows all the details of this order and passes them to a specialized FeaturedOrderItem component whose only job is to display it nicely.

The Code in Action

Let’s build this. We’ll have our main App component act as the entry point, an OrderDashboard that holds the data, and a FeaturedOrderItem that just displays it.

How to Run This Locally:

  1. Create a new React app: npx create-react-app order-system
  2. Navigate into the src folder.
  3. Create a new file named FeaturedOrderItem.js.
  4. Replace the code in App.js and add the code to FeaturedOrderItem.js as shown below.

src/FeaturedOrderItem.js

// This component's only job is to display the data it receives.
// It doesn't know where the order came from; it only knows how to present it.
function FeaturedOrderItem({ order }) {
  // If no order is passed, it displays nothing.
  if (!order) {
    return <p>No featured order at the moment.</p>;
  }

  return (
    <div style={{ border: '2px solid #007bff', padding: '15px', borderRadius: '8px', backgroundColor: '#f0f8ff' }}>
      <h3>⭐ Featured Order ⭐</h3>
      <p><strong>Order ID:</strong> #{order.id}</p>
      <p><strong>Customer:</strong> {order.customer}</p>
      <p><strong>Items:</strong> {order.items.join(', ')}</p>
      <p><strong>Status:</strong> {order.status}</p>
    </div>
  );
}

export default FeaturedOrderItem;

src/App.js

import React from 'react';
import FeaturedOrderItem from './FeaturedOrderItem';

// This component acts as the "parent" or the "head chef."
// It holds the data and decides what to pass down.
function OrderDashboard() {
  const featuredOrder = {
    id: 101,
    customer: 'Alice',
    items: ['Espresso', 'Croissant'],
    status: 'Processing'
  };

  return (
    <div>
      <h1>Today's Dashboard</h1>
      <p>Welcome, manager! Here is the order that needs your attention:</p>
      {/* We pass the entire 'featuredOrder' object down to the child as a prop. */}
      <FeaturedOrderItem order={featuredOrder} />
    </div>
  );
}

// The main App component that renders our dashboard.
function App() {
  return <OrderDashboard />;
}

export default App;

When you run this (npm start), you'll see the OrderDashboard rendering the FeaturedOrderItem with all the correct details. The data flows in one direction: down. The child is completely dependent on the parent for its information.

The Challenge: When a Child Needs to Talk Back

This one-way data flow is great for keeping things simple. But modern apps are interactive. Users click buttons, fill out forms, and change settings. Often, these actions happen in a child component, but the state they need to change lives in a parent.

Let’s extend our example. What if our FeaturedOrderItem had a button to mark the order as "Shipped"?

If we add the button inside FeaturedOrderItem, how does it tell OrderDashboard to update the master order status? The child can’t reach up and modify the parent’s data. Props are read-only. A child cannot change the props it receives. This is a fundamental rule in React that prevents chaos and ensures a predictable data flow.

This is where many new React developers get stuck. It feels like hitting a brick wall. But React has an elegant solution for this.

The Solution: Lifting State Up

The pattern to solve this is called "lifting state up." It sounds fancy, but the idea is beautifully simple:

  1. Move the State: If multiple components need to share or modify the same state, that state should live in their closest common ancestor. In our case, the OrderDashboard is the "single source of truth" for the order data.
  2. Pass Down a Function: The parent component defines a function that can modify its state (e.g., handleUpdateStatus). It then passes this function down to the child as a prop.
  3. Call the Function from the Child: The child component, when an event occurs (like a button click), calls the function it received in its props. It can pass arguments back up with this function call to give the parent information about what happened.

The child isn’t changing the state itself. It’s just telling the parent, "Hey, something happened down here! Here’s the info, please handle it."

Putting It Into Practice: An Interactive Order Tracker

Let’s build a more complete OrderManagementSystem. This system will have two main features:

  1. A form to add new orders (AddOrderForm).
  2. A list of active orders, where each order can be marked as "Completed" (LiveOrderTracker).

Both the form and the list need to interact with the same piece of state: the array of orders. The form needs to add to the array, and the list items need to update it. The closest common ancestor is the OrderManagementSystem itself. That’s where we’ll lift our state.

How to Run This Locally:

  1. In your src folder, create three new files: AddOrderForm.jsLiveOrderTracker.js, and Order.js.
  2. Copy the code below into the corresponding files.

src/Order.js (The Grandchild)

// This component only knows about a single order and how to update its status.
function Order({ order, onUpdateStatus }) {
  const handleCompleteClick = () => {
    // When clicked, it calls the function passed down from the grandparent.
    // It passes the ID of the order that needs to be updated.
    onUpdateStatus(order.id);
  };

  return (
    <li style={{
      padding: '10px',
      borderBottom: '1px solid #ccc',
      display: 'flex',
      justifyContent: 'space-between',
      alignItems: 'center'
    }}>
      <span>
        #{order.id}: {order.item} ({order.customer}) - <strong>{order.status}</strong>
      </span>
      {order.status === 'Pending' && (
        <button onClick={handleCompleteClick}>Mark as Completed</button>
      )}
    </li>
  );
}

export default Order;

src/LiveOrderTracker.js (Child 1)

import React from 'react';
import Order from './Order';

// This component receives the list of orders and the update function,
// and it passes them down another level to each individual Order.
function LiveOrderTracker({ orders, onUpdateStatus }) {
  return (
    <div>
      <h2>Live Orders</h2>
      <ul style={{ listStyle: 'none', padding: 0 }}>
        {orders.length > 0 ? (
          orders.map(order => (
            <Order
              key={order.id}
              order={order}
              onUpdateStatus={onUpdateStatus}
            />
          ))
        ) : (
          <p>No active orders.</p>
        )}
      </ul>
    </div>
  );
}

export default LiveOrderTracker;

src/AddOrderForm.js (Child 2)

import React, { useState } from 'react';

// This form manages its own temporary state (the input values).
// When submitted, it lifts the final data up to the parent.
function AddOrderForm({ onAddOrder }) {
  const [item, setItem] = useState('');
  const [customer, setCustomer] = useState('');

  const handleSubmit = (event) => {
    event.preventDefault();
    if (!item || !customer) {
      alert('Please fill out both fields!');
      return;
    }
    // Call the function passed down from the parent,
    // sending the new order's data up.
    onAddOrder({ item, customer });
    // Clear the form for the next entry
    setItem('');
    setCustomer('');
  };

  return (
    <form onSubmit={handleSubmit} style={{ marginBottom: '20px' }}>
      <h2>Add New Order</h2>
      <input
        type="text"
        placeholder="Item Name (e.g., Pizza)"
        value={item}
        onChange={(e) => setItem(e.target.value)}
        style={{ marginRight: '10px', padding: '8px' }}
      />
      <input
        type="text"
        placeholder="Customer Name"
        value={customer}
        onChange={(e) => setCustomer(e.target.value)}
        style={{ marginRight: '10px', padding: '8px' }}
      />
      <button type="submit">Add Order</button>
    </form>
  );
}

export default AddOrderForm;

src/App.js (The Parent with the Lifted State)

import React, { useState } from 'react';
import AddOrderForm from './AddOrderForm';
import LiveOrderTracker from './LiveOrderTracker';
import './App.css';

// Our main component, the "single source of truth."
function OrderManagementSystem() {
  // The "lifted state" - an array of all orders.
  const [orders, setOrders] = useState([
    { id: 1, item: 'Latte', customer: 'Bob', status: 'Pending' },
    { id: 2, item: 'Muffin', customer: 'Charlie', status: 'Completed' },
  ]);

  // Function to add a new order. This will be passed to AddOrderForm.
  const addOrderHandler = (newOrderData) => {
    setOrders((prevOrders) => [
      ...prevOrders,
      {
        id: Math.max(0, ...prevOrders.map(o => o.id)) + 1, // Generate a new unique ID
        ...newOrderData,
        status: 'Pending',
      },
    ]);
  };

  // Function to update an order's status. This will be passed down to LiveOrderTracker and then to Order.
  const updateOrderStatusHandler = (orderId) => {
    setOrders((prevOrders) =>
      prevOrders.map((order) =>
        order.id === orderId ? { ...order, status: 'Completed' } : order
      )
    );
  };

  return (
    <div style={{ padding: '20px', maxWidth: '700px', margin: 'auto' }}>
      <h1>Order Management System</h1>
      {/* Pass the addOrderHandler function down as a prop */}
      <AddOrderForm onAddOrder={addOrderHandler} />

      {/* Pass the orders list and the update handler down as props */}
      <LiveOrderTracker orders={orders} onUpdateStatus={updateOrderStatusHandler} />
    </div>
  );
}

function App() {
  return <OrderManagementSystem />;
}

export default App;

Now, when you run this, you have a fully interactive system!

  • The AddOrderForm can add new orders to the list, even though the list state lives in OrderManagementSystem.
  • Each Order component in the LiveOrderTracker can mark itself as "Completed," updating the central state.

This pattern is fundamental to building complex React apps. It ensures that your app has a single source of truth. There’s only one orders array, and only the OrderManagementSystem can truly change it. This makes your application’s behavior predictable and much easier to trace when bugs appear.

Human Questions, Honest Answers

"This seems overly complicated. Why not just let the child change the state directly?"

I get it, it feels like a roundabout way of doing things! But this restriction is one of React's greatest strengths. By enforcing a one-way data flow, React makes your app predictable. You always know where the state lives and how it can be changed. If any component could change any other component’s state at any time, tracking down bugs would become a nightmare. It’s like having a clear chain of command versus letting everyone give orders at once.

"What is 'prop drilling' and is it a bad thing?"

"Prop drilling" is the term for when you have to pass props through multiple layers of components that don't actually need the props themselves, just to get them to a deeply nested child that does. In our example, LiveOrderTracker didn’t use onUpdateStatus itself; it just passed it along to Order.

For one or two levels, this is perfectly fine and is the standard React way. However, if you find yourself drilling props through 5, 10, or more levels, it can become cumbersome. That’s a sign that you might need a more advanced state management solution, like the Context API or libraries like Redux, which act like teleportation devices for your data, but it's a topic for another day.

"So when should I lift state vs. keeping it local in a component?"

Great question! The rule of thumb is: keep state as local as possible.

If a piece of state is only used by one component (like the item and customer input values in our AddOrderForm), keep it inside that component using useState. There’s no need for any other component to know about it.

Lift state up only when multiple components need to share or react to that state. If you find yourself thinking, "I need to get data from this component over to that one," that's your cue to find their common ancestor and lift the state there.

Final Thoughts: The Conductor and the Musicians

We started with the idea of a head chef and line cooks, or an orchestra conductor and musicians. The conductor holds the master score (the state) and gives directions (props). When a musician finishes a solo (an event occurs), they look to the conductor, who then cues the next part of the symphony. They don't just start playing a different song on their own.

"Lifting state up" is React's way of ensuring the conductor is always in control. It promotes clear communication, establishes a single source of truth, and turns potentially chaotic UIs into well-orchestrated, predictable applications. It’s a pattern you’ll use every single day as a React developer, and mastering it is a huge leap toward building robust, scalable apps.

So go ahead, start lifting that state. It might feel a bit strange at first, but soon it will become second nature, and your code will thank you for the clarity.


And that’s a wrap on component communication! As your apps grow, you’ll find that managing the state inside your components becomes an art in itself. In our next article, we’ll zoom in on the most essential tool for that job as we explore "useState — practical pitfalls and fixes". See you then.