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:
- Single Source of Truth: One component (usually an ancestor) owns and manages a piece of state. No duplicates.
- Downward Data Flow: The owner passes state down to children via props. Data always flows from parent to children.
- 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
tasksas a prop (an array of strings) - Rendering: Uses
.map()to iterate over the array and render a<li>for each task - Key: Uses
indexas 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:
newTaskmanages the form input field (controlled input) - Props: Receives
onAddTaskfunction 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:
- State Ownership:
Appinitializes and owns thetasksarray viauseState - Callback Function:
handleAddTaskknows how to updatetasksusingsetTasks - Downward Flow: Passes
tasksto<TaskList />as a prop - Upward Flow: Passes
handleAddTaskto<AddTaskForm onAddTask={...} /> - Re-render Cycle: When
handleAddTaskis called,setTasksupdates state →Appre-renders →TaskListreceives updatedtasksand re-renders
How the Data Flow Works
User adds a task:
- User types "Buy groceries" into the input field
- User clicks "Add Task"
AddTaskForm.handleSubmitis calledonAddTask('Buy groceries')is called (which is actuallyApp.handleAddTask)App.handleAddTaskupdates state withsetTasks([...tasks, 'Buy groceries'])Appre-renders with the new stateTaskListreceives the updatedtasksarray and re-renders with the new item- 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.