React Dynamic List Rendering
Table of Contents + −
In the last lesson you learned about React Keys in Lists, so each item in a map now gets a steady key. This lesson shows how to make the list on screen update by itself when people add and delete items.
🤔 Why a list in state?
Here’s the pain. A plain array in your component is frozen, so the screen will not move when you change it.
- A normal array is just a value. React reads it once when it draws, then forgets about it.
- So if you push into that array later, nothing on screen reacts. The page looks stale.
- You need React to watch the list and redraw whenever it changes.
That watching is exactly what state does. Put the list in useState, and every time you update it React re-runs your map and paints the new list.
🔁 The core idea: change state, map runs again
The whole lesson sits on one short loop. Keep it in your head.
- You hold the list in state with
useState. - You render it with
map, one element per item, each with akey. - You change the state array with the setter (add, remove, whatever).
- React notices the new array and re-runs your
map. The screen updates by itself.
Here is that loop in the smallest possible form, where a button adds one number to a list.
import { useState } from "react";
function NumberList() { const [numbers, setNumbers] = useState([1, 2, 3]);
return ( <div> <button onClick={() => setNumbers([...numbers, numbers.length + 1])}> Add number </button> <ul> {numbers.map((n) => ( <li key={n}>{n}</li> ))} </ul> </div> );}So you never touch the DOM yourself. You only change state, and the map redraws the list for you. That is the auto-update you are after.
➕ Adding an item from an input
A real list usually grows from what the user types, so we need an input, a button, and the add logic. This re-uses the add-with-spread move you already know from the state lesson.
Here a text box and a button add whatever the user typed to the list.
import { useState } from "react";
function TodoApp() { const [todos, setTodos] = useState([]); const [text, setText] = useState("");
function addTodo() { if (text.trim() === "") return; // ignore empty input const newTodo = { id: Date.now(), text: text }; setTodos([...todos, newTodo]); // old items + the new one setText(""); // clear the box }
return ( <div> <input value={text} onChange={(e) => setText(e.target.value)} /> <button onClick={addTodo}>Add</button> </div> );}Let’s read it from the top.
todosholds the list,textholds what is currently typed in the box.- On every keystroke,
onChangesaves the text into state, so the input stays in sync. addTodobuilds a new item with a uniqueidfromDate.now().setTodos([...todos, newTodo])copies the old list and tacks on the new item, so React gets a fresh array.- Then we clear the box with
setText("")so it is ready for the next item.
So one click changes state, and React re-runs map to show the longer list. The id from Date.now() gives each item a stable key for later.
Why Date.now() for the id?
You need a value that stays the same for the life of the item and never clashes with another. The array index is a bad key for a changing list, because positions shift when you add or remove. Date.now() gives a simple, unique stamp that works fine for learning.
➖ Removing an item with a delete button
Now the other direction. Each rendered item gets its own delete button that passes the item’s id, and removing uses filter, the same move from the state lesson.
Here we draw the list and give every row a delete button that knows its own id.
function deleteTodo(id) { // keep every todo whose id is NOT the one we are removing setTodos(todos.filter((todo) => todo.id !== id));}
// inside the return:<ul> {todos.map((todo) => ( <li key={todo.id}> {todo.text} <button onClick={() => deleteTodo(todo.id)}>Delete</button> </li> ))}</ul>;Here is what happens on a click.
- Each button wraps its call in an arrow function, so
deleteTodoruns on the click, not during render. - That arrow function holds the right
id, so the row deletes itself, not some other row. filterreturns a new array without that one item, and we pass it tosetTodos.- React sees the shorter array and re-runs
map, so the row disappears.
So the delete button and filter together shrink the list live. Same loop as adding, just going the other way.
Pass the id, not the whole event
Write onClick={() => deleteTodo(todo.id)}, with the arrow wrapper. If you write onClick={deleteTodo(todo.id)} instead, it runs during render and deletes things before anyone clicks. The arrow function delays the call until the actual click.
🧱 Pulling out a list-item component
When a row gets bigger, it helps to move it into its own small component. The parent keeps the list and the logic, and the child just shows one item and reports back when its delete button is clicked.
Here the row becomes a TodoItem component that takes the item and a callback.
function TodoItem({ todo, onDelete }) { return ( <li> {todo.text} <button onClick={() => onDelete(todo.id)}>Delete</button> </li> );}A few things to notice about this split.
- The child takes
todoas a prop, so it only knows about one item. - It also takes
onDelete, a function the parent passes down. - When the button is clicked, the child calls
onDelete(todo.id), sending theidback up. - The parent owns the state, so the parent decides what to actually remove.
So the child component stays simple and reusable. It owns no list logic. It just renders one item and tells the parent which one was clicked.
Where does the key go now?
The key stays on the element you repeat in map, which is the TodoItem itself, not the li inside it. So you write todos.map((todo) => <TodoItem key={todo.id} todo={todo} onDelete={deleteTodo} />). The key belongs on the outer repeated element, always.
🖥️ The full working example
Let’s put every piece together into one app you could actually run. Input plus add button on top, then the live list, each row a TodoItem with its own delete button.
This is the complete dynamic list, start to finish.
import { useState } from "react";
function TodoItem({ todo, onDelete }) { return ( <li> {todo.text} <button onClick={() => onDelete(todo.id)}>Delete</button> </li> );}
function TodoApp() { const [todos, setTodos] = useState([]); const [text, setText] = useState("");
function addTodo() { if (text.trim() === "") return; setTodos([...todos, { id: Date.now(), text: text }]); setText(""); }
function deleteTodo(id) { setTodos(todos.filter((todo) => todo.id !== id)); }
return ( <div> <input value={text} onChange={(e) => setText(e.target.value)} placeholder="Add a task" /> <button onClick={addTodo}>Add</button>
<ul> {todos.map((todo) => ( <TodoItem key={todo.id} todo={todo} onDelete={deleteTodo} /> ))} </ul> </div> );}So trace the flow once, step by step.
- You type, and
onChangesaves it into state. - You click Add, so
addTodospreads a new array into state. React re-runsmapand a new row shows up. - You click Delete on a row, so
filterbuilds a shorter array. React re-runsmap, and that row is gone. - You never touched the screen directly. The state-to-map loop did all of it.
Output
After typing "Buy milk" and clicking Add, then "Walk dog" and Add: • Buy milk [Delete] • Walk dog [Delete]
After clicking Delete on "Buy milk": • Walk dog [Delete]🧩 What You’ve Learned
- ✅ A list that changes over time must live in state, set up with
const [todos, setTodos] = useState([]) - ✅ The core loop: change the state array → React re-runs
map→ the screen updates by itself - ✅ Add an item by spreading the old list into a new one:
setTodos([...todos, newTodo]) - ✅ Remove an item with
filter, passing the item’sid:setTodos(todos.filter((t) => t.id !== id)) - ✅ Give each item a stable
key(likeDate.now()), never the array index for a changing list - ✅ Wrap handler calls in an arrow function so they run on the click, not during render
- ✅ Move a row into its own component, passing the item as a prop and a callback for delete
Check Your Knowledge
Test what you learned. Pick an answer for each question, then click Check.
- 1
Why does a list need to be in state for the screen to update when it changes?
Why: React only redraws when state changes. A plain array is read once and ignored after that, so updating it does nothing on screen. State makes React re-run your map.
- 2
Which line correctly adds newTodo to the list in state?
Why: [...todos, newTodo] copies the old items into a brand-new array and adds the new one, then hands that new array to setTodos so React re-renders.
- 3
How should each item's delete button pass its own id?
Why: The arrow wrapper delays the call until the click and carries the right id. Writing deleteTodo(todo.id) directly would run during render and delete items too early.
- 4
When you split a row into a TodoItem component, where does the key go?
Why: The key always goes on the outer element you repeat inside map. Here that is the TodoItem itself, not the li nested inside it.
🚀 What’s Next?
You can now build a list that grows and shrinks on screen, all from changing state. Next you’ll learn how to show only some of those items, like only the done tasks or only the matches for a search box.