Skip to main content

Lifting State Up: Building a To-Do List Example

Lifting state up is the React pattern of moving state to a common ancestor component so that sibling components can share the same state. This guide builds on core theory with a practical To-Do list application that demonstrates inverse data flow—how child components communicate with parents through callbacks—and how to manage collections of stateful data across multiple components.

Key Takeaways

  • State ownership is singular — the common ancestor component owns and manages the state as the single source of truth
  • Data flows down as props — state passes from parent to children via props (downward data flow)
  • Events flow up via callbacks — children call functions passed from parents to request state changes (upward/inverse data flow)
  • Separation of concerns — each component has a single responsibility: data management (parent), display (child), or input (child)
  • Scalable pattern — this architecture works for simple to-do lists and complex enterprise applications

Core Principles of Lifting State

Lifting state up solves the problem of keeping sibling components in sync. The three core principles are:

  1. Single Source of Truth: One component (usually an ancestor) owns and manages a piece of state. No duplicates.
  2. Downward Data Flow: The owner passes state down to children via props. Data always flows from parent to children.
  3. Upward Inverse Data Flow: Children communicate with parents by calling callback functions passed as props. This is how children request state changes.

This pattern ensures that the entire app has a predictable, one-way data flow: data down, events up.

Component Architecture: The To-Do List

Let's build a To-Do list with three components:

App (owns tasks array state)
├── AddTaskForm (captures user input, calls callback)
└── TaskList (displays tasks, receives array)

The App component is the common ancestor that owns the tasks state. It passes data down and receives events up.

Implementation: Building the To-Do List Step-by-Step

TaskList Component (Presentational)

The TaskList component is a simple "presentational" component. It receives an array of tasks and renders them. It does not manage state or know how tasks are created.

// TaskList.jsx
import React from 'react';

function TaskList({ tasks }) {
return (
<ul>
{tasks.map((task, index) => (
<li key={index}>{task}</li>
))}
</ul>
);
}

export default TaskList;

Code breakdown:

  • Input: Receives tasks as a prop (an array of strings)
  • Rendering: Uses .map() to iterate over the array and render a <li> for each task
  • Key: Uses index as the key (works for this simple example; in production, use unique IDs)
  • Responsibility: Displays data only; has no logic or state

AddTaskForm Component (Input Handler)

The AddTaskForm component captures user input via a controlled form input. Crucially, it does not add the task to the list itself. Instead, it calls a callback function (onAddTask) that is passed from the parent. This is inverse data flow in action.

// AddTaskForm.jsx
import React, { useState } from 'react';

function AddTaskForm({ onAddTask }) {
const [newTask, setNewTask] = useState('');

const handleSubmit = (e) => {
e.preventDefault();

// Validate before calling the callback
if (newTask.trim() === '') {
return; // Don't add empty tasks
}

// Call the callback function passed from parent
onAddTask(newTask);

// Clear the local input state
setNewTask('');
};

return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={newTask}
onChange={(e) => setNewTask(e.target.value)}
placeholder="Add a new task"
/>
<button type="submit">Add Task</button>
</form>
);
}

export default AddTaskForm;

Code breakdown:

  • Local state: newTask manages the form input field (controlled input)
  • Props: Receives onAddTask function from parent
  • Submit handler: When form submits, validates the input, then calls onAddTask(newTask) to communicate with the parent
  • Reset: Clears the local input after submission
  • Responsibility: Captures and validates user input; delegates state updates to the parent

This component is "dumb" from the app's perspective—it knows nothing about the tasks array or how data is stored. It only knows how to gather input and send it upward.

App Component (State Owner)

The App component owns the tasks state and coordinates the two child components. It is the single source of truth.

// App.jsx
import React, { useState } from 'react';
import TaskList from './TaskList';
import AddTaskForm from './AddTaskForm';

function App() {
// State: This is the single source of truth
const [tasks, setTasks] = useState(['Learn React', 'Build a project']);

// Callback: This is passed down to AddTaskForm
const handleAddTask = (taskText) => {
// Add the new task to the array
setTasks([...tasks, taskText]);
};

return (
<div>
<h1>My To-Do List</h1>

{/* Pass callback down to form */}
<AddTaskForm onAddTask={handleAddTask} />

{/* Pass state down to list */}
<TaskList tasks={tasks} />
</div>
);
}

export default App;

Code breakdown:

  1. State Ownership: App initializes and owns the tasks array via useState
  2. Callback Function: handleAddTask knows how to update tasks using setTasks
  3. Downward Flow: Passes tasks to <TaskList /> as a prop
  4. Upward Flow: Passes handleAddTask to <AddTaskForm onAddTask={...} />
  5. Re-render Cycle: When handleAddTask is called, setTasks updates state → App re-renders → TaskList receives updated tasks and re-renders

How the Data Flow Works

User adds a task:

  1. User types "Buy groceries" into the input field
  2. User clicks "Add Task"
  3. AddTaskForm.handleSubmit is called
  4. onAddTask('Buy groceries') is called (which is actually App.handleAddTask)
  5. App.handleAddTask updates state with setTasks([...tasks, 'Buy groceries'])
  6. App re-renders with the new state
  7. TaskList receives the updated tasks array and re-renders with the new item
  8. The UI displays "Buy groceries" in the list

This entire flow happens automatically through React's state management and re-rendering. No manual DOM manipulation needed.

Handling More Complex State: Objects in Arrays

The same pattern works with objects instead of strings. Here's a task object with a unique ID and completion status:

// Enhanced version with task objects
import React, { useState } from 'react';

function TaskList({ tasks }) {
return (
<ul>
{tasks.map((task) => (
<li key={task.id}>
{task.text} {task.completed && '✓'}
</li>
))}
</ul>
);
}

function App() {
const [tasks, setTasks] = useState([
{ id: 1, text: 'Learn React', completed: false },
{ id: 2, text: 'Build a project', completed: true }
]);

const handleAddTask = (taskText) => {
const newTask = {
id: Date.now(), // Simple unique ID
text: taskText,
completed: false
};
setTasks([...tasks, newTask]);
};

return (
<div>
<h1>My To-Do List</h1>
<AddTaskForm onAddTask={handleAddTask} />
<TaskList tasks={tasks} />
</div>
);
}

The pattern remains the same: state down, events up.

Best Practices

Identify the lowest common ancestor — find the minimal component that needs to own the state. Don't lift state higher than necessary; this keeps components focused.

Use meaningful callback names — name callbacks descriptively: onAddTask, onDeleteTask, onUpdateTask. The on prefix signals that it's an event handler.

Avoid prop drilling — if you have many intermediate components, consider using Context API (learned in later chapters) to avoid passing props through components that don't use them.

Keep components single-responsibility — each component should have one job. Parents manage state, presentational components display, input components gather data.

Frequently Asked Questions

How do I remove a task from the list?

Pass a second callback from App to TaskList. The callback accepts the task ID or index:

const handleRemoveTask = (id) => {
setTasks(tasks.filter(task => task.id !== id));
};

// Pass callback to TaskList
<TaskList tasks={tasks} onRemoveTask={handleRemoveTask} />

Then in TaskList, add a delete button that calls onRemoveTask(task.id).

What if I have deeply nested components?

If you need to pass callbacks through many levels, you've likely identified a use case for the Context API (coming in Chapter 4). Context allows you to share state and callbacks without prop drilling.

Can multiple components own the same state?

No. This is a fundamental rule of React. Only one component should own a piece of state. If multiple components need the same state, lift it up to their common ancestor.

What's the difference between state and props?

State is data owned by a component that it can change via setState. Props are data passed to a component that it cannot change (they are read-only from the child's perspective). Always lift state to the highest component that needs to access it.

How does React know when to re-render?

React re-renders a component when its state or props change. When setTasks is called in App, React knows the state changed, so it re-renders App and all its children with the new state/props.

Further Reading