Build a Todo List as a single React component. The list owns its own state with useState, the input is controlled, and two user actions — Add and Delete — drive the array of tasks.
You're given a starter App.tsx with the form, a pre-seeded <ul> of three tasks, and the Delete buttons wired into the markup. Make it work:
useState<Task[]>. Map them in the <ul>, with a stable key on each <li>.styles.css.value + onChange bound to state, not a ref poking the DOM. This is the React idiom and unlocks things like "disable Submit while empty" for free.key on each <li> (e.g. crypto.randomUUID()), not the array index. When the list mutates, index keys cause React to reuse the wrong DOM nodes.tasks.push(...) does not re-render; return a new array with [...prev, newTask] or prev.filter(...).event.preventDefault() in the onSubmit handler so the form doesn't trigger a page reload.Three files in the sandbox:
App.tsx — the component you'll edit. Renders the form, three pre-seeded <li> rows, and Delete buttons. Replace the static markup with a state-driven render.index.tsx — bootstraps the React root with <StrictMode>. Don't edit.styles.css — dark-theme styling for the page, form, and list. Edit only if you want to tweak visuals.You'll build a single React component that owns the task list and a draft input, with two user actions: add and delete.
A todo list is the smallest interesting React component: it has state, it accepts user input through a form, and it renders a dynamic list. Three things have to work together: an array of tasks, an input that the user can type into and submit, and a delete button next to each task.
One component owns everything. The user-facing tree is shallow — <App> renders a <form> and a <ul>. The <ul> maps the tasks array to <li> rows; each row carries a delete button that fires a callback back up to <App>.
State lives in App because both children (the form and the list) need to read or update it. Lifting it any higher (e.g. into a context) is over-engineering for two readers.
A reasonable first try makes the input uncontrolled and pushes onto the tasks array in place:
export default function App() {
const tasks = [];
function handleSubmit(e) {
e.preventDefault();
const input = document.querySelector('input');
tasks.push({ id: Math.random(), text: input.value });
input.value = '';
}
return (
<form onSubmit={handleSubmit}>
<input />
<button>Add</button>
<ul>{tasks.map((t) => <li>{t.text}</li>)}</ul>
</form>
);
}
Three things go wrong:
tasks is a local variable, not state. Pushing to it doesn't trigger a re-render. The new task never appears.key on <li>. React warns, and once you have deletion, missing keys cause the wrong rows to update.import { useId, useState, type FormEvent } from 'react';
import './styles.css';
type Task = { id: string; text: string };
const initial: Task[] = [
{ id: '1', text: 'Walk the dog' },
{ id: '2', text: 'Water the plants' },
{ id: '3', text: 'Wash the dishes' },
];
export default function App() {
const [tasks, setTasks] = useState<Task[]>(initial);
const [draft, setDraft] = useState('');
const inputId = useId();
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const text = draft.trim();
if (!text) return;
setTasks((prev) => [...prev, { id: crypto.randomUUID(), text }]);
setDraft('');
}
function handleDelete(id: string) {
setTasks((prev) => prev.filter((t) => t.id !== id));
}
return (
<main>
<h1>Todo List</h1>
<form onSubmit={handleSubmit}>
<label htmlFor={inputId}>Add your task</label>
<input
id={inputId}
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Add your task"
/>
<button type="submit" disabled={!draft.trim()}>Submit</button>
</form>
<ul>
{tasks.map((task) => (
<li key={task.id}>
<span>{task.text}</span>
<button onClick={() => handleDelete(task.id)}>Delete</button>
</li>
))}
</ul>
</main>
);
}
The shifts from the first attempt:
useState for both tasks and draft so React knows when to re-render.value={draft} and onChange={setDraft} keep the input's text and the state in lockstep.crypto.randomUUID() for stable ids, used as the key on each <li>.[...prev, ...]) and filter to update the array without mutating it.onChange, which calls setDraft(e.target.value). React schedules a re-render; the input's value reflects the new draft.onSubmit fires. We preventDefault (so the page doesn't reload), trim the draft, bail if empty, then call setTasks(prev => [...prev, newTask]). The spread creates a new array; React sees the new reference and re-renders the list. We also setDraft('') to clear the input.onClick on a delete button calls handleDelete(id). We call setTasks(prev => prev.filter(t => t.id !== id)) — again a new array, this time without the matching task.tasks.push(newTask) does not trigger a re-render. Always return a new array ([...prev, newTask] or prev.filter(...)).key — when the list mutates (add/delete), indices shift. React reuses the wrong DOM nodes and components carry stale state. Use a stable id.setTasks(prev => ...) reads prev at update time. setTasks([...tasks, newTask]) captures tasks at render time and can stale-out under batched updates. Prefer the functional form when the new value depends on the old.event.preventDefault() in onSubmit — the form submits to the page URL, triggering a full reload that wipes state.Once add and delete work, the natural extensions are:
done: boolean field, render with strikethrough.useEffect mirrors tasks into localStorage; another useEffect reads it on mount.setTasks with a mutation against an API, with rollback on failure.Each is a small addition on top of the foundation here.
The reducer owns every list transition while the component keeps only the input draft locally.
import { useReducer, useState } from 'react';
import './styles.css';
const INITIAL = ['Walk the dog', 'Water the plants', 'Wash the dishes'];
type Action = { type: 'add'; text: string } | { type: 'delete'; index: number };
function reducer(tasks: string[], action: Action): string[] {
if (action.type === 'add') return [...tasks, action.text];
return tasks.filter((_, index) => index !== action.index);
}
export default function App() {
const [tasks, dispatch] = useReducer(reducer, INITIAL);
const [draft, setDraft] = useState('');
function submit(event: React.FormEvent) {
event.preventDefault();
const text = draft.trim();
if (!text) return;
dispatch({ type: 'add', text });
setDraft('');
}
return (
<main className="todo">
<h1>Todo List</h1>
<form className="todo-form" onSubmit={submit}>
<input value={draft} onChange={(event) => setDraft(event.target.value)}
placeholder="Add your task" autoComplete="off" />
<button type="submit">Submit</button>
</form>
<ul className="todo-list">
{tasks.map((task, index) => (
<li key={`${task}-${index}`}>
<span className="todo-text">{task}</span>
<button type="button" className="delete"
onClick={() => dispatch({ type: 'delete', index })}>Delete</button>
</li>
))}
</ul>
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build a Todo List as a single React component. The list owns its own state with useState, the input is controlled, and two user actions — Add and Delete — drive the array of tasks.
You're given a starter App.tsx with the form, a pre-seeded <ul> of three tasks, and the Delete buttons wired into the markup. Make it work:
useState<Task[]>. Map them in the <ul>, with a stable key on each <li>.styles.css.value + onChange bound to state, not a ref poking the DOM. This is the React idiom and unlocks things like "disable Submit while empty" for free.key on each <li> (e.g. crypto.randomUUID()), not the array index. When the list mutates, index keys cause React to reuse the wrong DOM nodes.tasks.push(...) does not re-render; return a new array with [...prev, newTask] or prev.filter(...).event.preventDefault() in the onSubmit handler so the form doesn't trigger a page reload.Three files in the sandbox:
App.tsx — the component you'll edit. Renders the form, three pre-seeded <li> rows, and Delete buttons. Replace the static markup with a state-driven render.index.tsx — bootstraps the React root with <StrictMode>. Don't edit.styles.css — dark-theme styling for the page, form, and list. Edit only if you want to tweak visuals.You'll build a single React component that owns the task list and a draft input, with two user actions: add and delete.
A todo list is the smallest interesting React component: it has state, it accepts user input through a form, and it renders a dynamic list. Three things have to work together: an array of tasks, an input that the user can type into and submit, and a delete button next to each task.
One component owns everything. The user-facing tree is shallow — <App> renders a <form> and a <ul>. The <ul> maps the tasks array to <li> rows; each row carries a delete button that fires a callback back up to <App>.
State lives in App because both children (the form and the list) need to read or update it. Lifting it any higher (e.g. into a context) is over-engineering for two readers.
A reasonable first try makes the input uncontrolled and pushes onto the tasks array in place:
export default function App() {
const tasks = [];
function handleSubmit(e) {
e.preventDefault();
const input = document.querySelector('input');
tasks.push({ id: Math.random(), text: input.value });
input.value = '';
}
return (
<form onSubmit={handleSubmit}>
<input />
<button>Add</button>
<ul>{tasks.map((t) => <li>{t.text}</li>)}</ul>
</form>
);
}
Three things go wrong:
tasks is a local variable, not state. Pushing to it doesn't trigger a re-render. The new task never appears.key on <li>. React warns, and once you have deletion, missing keys cause the wrong rows to update.import { useId, useState, type FormEvent } from 'react';
import './styles.css';
type Task = { id: string; text: string };
const initial: Task[] = [
{ id: '1', text: 'Walk the dog' },
{ id: '2', text: 'Water the plants' },
{ id: '3', text: 'Wash the dishes' },
];
export default function App() {
const [tasks, setTasks] = useState<Task[]>(initial);
const [draft, setDraft] = useState('');
const inputId = useId();
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const text = draft.trim();
if (!text) return;
setTasks((prev) => [...prev, { id: crypto.randomUUID(), text }]);
setDraft('');
}
function handleDelete(id: string) {
setTasks((prev) => prev.filter((t) => t.id !== id));
}
return (
<main>
<h1>Todo List</h1>
<form onSubmit={handleSubmit}>
<label htmlFor={inputId}>Add your task</label>
<input
id={inputId}
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Add your task"
/>
<button type="submit" disabled={!draft.trim()}>Submit</button>
</form>
<ul>
{tasks.map((task) => (
<li key={task.id}>
<span>{task.text}</span>
<button onClick={() => handleDelete(task.id)}>Delete</button>
</li>
))}
</ul>
</main>
);
}
The shifts from the first attempt:
useState for both tasks and draft so React knows when to re-render.value={draft} and onChange={setDraft} keep the input's text and the state in lockstep.crypto.randomUUID() for stable ids, used as the key on each <li>.[...prev, ...]) and filter to update the array without mutating it.onChange, which calls setDraft(e.target.value). React schedules a re-render; the input's value reflects the new draft.onSubmit fires. We preventDefault (so the page doesn't reload), trim the draft, bail if empty, then call setTasks(prev => [...prev, newTask]). The spread creates a new array; React sees the new reference and re-renders the list. We also setDraft('') to clear the input.onClick on a delete button calls handleDelete(id). We call setTasks(prev => prev.filter(t => t.id !== id)) — again a new array, this time without the matching task.tasks.push(newTask) does not trigger a re-render. Always return a new array ([...prev, newTask] or prev.filter(...)).key — when the list mutates (add/delete), indices shift. React reuses the wrong DOM nodes and components carry stale state. Use a stable id.setTasks(prev => ...) reads prev at update time. setTasks([...tasks, newTask]) captures tasks at render time and can stale-out under batched updates. Prefer the functional form when the new value depends on the old.event.preventDefault() in onSubmit — the form submits to the page URL, triggering a full reload that wipes state.Once add and delete work, the natural extensions are:
done: boolean field, render with strikethrough.useEffect mirrors tasks into localStorage; another useEffect reads it on mount.setTasks with a mutation against an API, with rollback on failure.Each is a small addition on top of the foundation here.
The reducer owns every list transition while the component keeps only the input draft locally.
import { useReducer, useState } from 'react';
import './styles.css';
const INITIAL = ['Walk the dog', 'Water the plants', 'Wash the dishes'];
type Action = { type: 'add'; text: string } | { type: 'delete'; index: number };
function reducer(tasks: string[], action: Action): string[] {
if (action.type === 'add') return [...tasks, action.text];
return tasks.filter((_, index) => index !== action.index);
}
export default function App() {
const [tasks, dispatch] = useReducer(reducer, INITIAL);
const [draft, setDraft] = useState('');
function submit(event: React.FormEvent) {
event.preventDefault();
const text = draft.trim();
if (!text) return;
dispatch({ type: 'add', text });
setDraft('');
}
return (
<main className="todo">
<h1>Todo List</h1>
<form className="todo-form" onSubmit={submit}>
<input value={draft} onChange={(event) => setDraft(event.target.value)}
placeholder="Add your task" autoComplete="off" />
<button type="submit">Submit</button>
</form>
<ul className="todo-list">
{tasks.map((task, index) => (
<li key={`${task}-${index}`}>
<span className="todo-text">{task}</span>
<button type="button" className="delete"
onClick={() => dispatch({ type: 'delete', index })}>Delete</button>
</li>
))}
</ul>
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.