Learn React by building a complete task board: components, JSX, props, useState, controlled forms, stable keys, and immutable updates, with runnable App.jsx code and practical checks.
Learning React becomes easier when every new idea answers a visible problem. A task board is a useful first project: typing needs a form, adding needs state, completion needs an event, and filtering needs a decision about what to show. This guide builds that small application in one file, then follows its data through the interface. You will finish with a working example you can change, a practical debugging routine, and a clearer idea of what to learn next.
What should you know before React?
You should be comfortable writing an HTML form, connecting a label to an input, and using CSS to control spacing. In JavaScript, practise functions, objects, arrays, imports, destructuring, and the array methods map and filter. You do not need to master every language feature before starting. You should, however, be able to explain why filtering an array returns a new collection and why a function can be passed to another function.
React handles the relationship between changing data and the interface that displays it. It does not replace HTML semantics or CSS layout. For a broader view of how a browser, server, and database fit together, read our guide to modern web development. This tutorial stays inside the browser: it has no account, remote API, or database.
Create a small learning project with Vite
React recommends a framework when starting a new application, while its documentation also describes building from scratch for learning or specific constraints. We use Vite here to keep the first exercise focused. A production decision should consider routing, data loading, deployment, and rendering requirements. See React’s project guidance and its from-scratch learning path.
Install a supported Node.js LTS release that meets the current Vite requirements, then open a terminal in a directory where you keep practice projects. Run these commands one at a time. If the scaffolder offers to install dependencies and start immediately, decline that optional step and continue with the explicit commands below.
node --version
npm create vite@latest react-task-board -- --template react
cd react-task-board
npm install
npm run dev
Open the local address printed by the development server. Keep the terminal running; stop it with Ctrl+C when finished. The react template uses JavaScript and JSX. Open src/App.jsx in your editor. Later, replace that file completely with the example below, leaving the generated src/main.jsx in place. No additional package or stylesheet is required; the starter styles may affect its appearance.
Components, JSX, and props in one picture
Think of the board as a parent component called App and a repeated row called TaskItem. A component name begins with a capital letter. JSX describes what a component returns, with JavaScript expressions inside braces. Close tags, use className for CSS classes, and group adjacent elements under a parent or fragment. The official JSX guide explains the syntax.
Our row receives three props: a task object and two callback functions. It displays the task and asks the parent to change it through those callbacks. That makes responsibility clear: the board owns the collection, while a row owns how one item is presented. Props are read-only inputs; see passing props. Define TaskItem outside App, so typing into the board does not create a new component type.
The complete App.jsx example
Copy this entire block into src/App.jsx and save. It starts with two tasks, including one completed task, so each filter has something to demonstrate. All task identifiers remain the same while you edit the interface.
import { useState } from "react";
const initialTasks = [
{ id: "read-components", text: "Read a chapter about components", done: false },
{ id: "try-board", text: "Try the task board", done: true },
];
function TaskItem({ task, onToggle, onRemove }) {
return (
<li>
<label>
<input
type="checkbox"
checked={task.done}
onChange={() => onToggle(task.id)}
/>
{" "}
{task.done ? <s>{task.text}</s> : task.text}
</label>
{" "}
<button
type="button"
onClick={() => onRemove(task.id)}
aria-label={"Delete: " + task.text}
>
Delete
</button>
</li>
);
}
export default function App() {
const [tasks, setTasks] = useState(initialTasks);
const [draft, setDraft] = useState("");
const [filter, setFilter] = useState("all");
const [error, setError] = useState("");
const completedCount = tasks.filter((task) => task.done).length;
const visibleTasks = tasks.filter((task) =>
filter === "all" ? true : filter === "done" ? task.done : !task.done
);
function handleAdd(event) {
event.preventDefault();
const text = draft.trim();
if (!text) {
setError("Enter a task name before adding it.");
return;
}
const newTask = { id: crypto.randomUUID(), text, done: false };
setTasks((currentTasks) => [...currentTasks, newTask]);
setDraft("");
setError("");
}
function toggleTask(id) {
setTasks((currentTasks) =>
currentTasks.map((task) =>
task.id === id ? { ...task, done: !task.done } : task
)
);
}
function removeTask(id) {
setTasks((currentTasks) =>
currentTasks.filter((task) => task.id !== id)
);
}
return (
<main lang="en" dir="ltr" style={{ maxWidth: "40rem", padding: "1.5rem", textAlign: "start" }}>
<h1>My small task board</h1>
<p>Total tasks: {tasks.length} — Completed: {completedCount}</p>
<form onSubmit={handleAdd}>
<label htmlFor="new-task">New task</label>
{" "}
<input
id="new-task"
value={draft}
maxLength={120}
onChange={(event) => {
setDraft(event.target.value);
setError("");
}}
aria-invalid={error ? true : undefined}
aria-describedby={error ? "task-error" : undefined}
/>
{" "}
<button type="submit">Add task</button>
{error ? <p id="task-error" role="alert">{error}</p> : null}
</form>
<p>
<label htmlFor="task-filter">Show tasks</label>
{" "}
<select
id="task-filter"
value={filter}
onChange={(event) => setFilter(event.target.value)}
>
<option value="all">All</option>
<option value="open">Remaining</option>
<option value="done">Completed</option>
</select>
</p>
{visibleTasks.length > 0 ? (
<ul>
{visibleTasks.map((task) => (
<TaskItem
key={task.id}
task={task}
onToggle={toggleTask}
onRemove={removeTask}
/>
))}
</ul>
) : (
<p>No tasks in this view.</p>
)}
<p>Tasks last only for this page session.</p>
</main>
);
}
Follow one interaction from start to finish
Type “Review the contact form” into the field. The input’s change handler reads the current value and calls setDraft. React renders with that draft, so the controlled input displays it. Submitting calls handleAdd; preventDefault keeps the browser from navigating. The handler trims the text, rejects a blank result, creates an identifier, and adds a task. Finally it clears the field. Try submitting with Enter as well as clicking the button.
useState returns the current value and a setter. Calling the setter schedules a subsequent render; it does not rewrite the variable inside the already running handler. Call Hooks at the top level of the component, before any conditional return. These rules are documented in the useState reference. Here, the four stored values represent the tasks, the draft, the selected filter, and a validation message.
Update collections without changing the old version
Adding creates a new array with spread syntax. Removing uses filter to create an array without the selected identifier. Toggling uses map: the matching task becomes a new object with a changed done value, while other objects can be reused. Avoid tasks.push(...) or assigning to task.done. React’s array update guidance describes this immutable approach.
Every task update uses a function such as setTasks((currentTasks) => ...). This expresses a calculation from the latest queued collection. Keep that calculation pure: the new identifier is created in the event handler before the updater runs. Development checks can call an updater more than once, so it should not send requests or change outside data. Setting an entirely new draft or filter can use a value directly.
Stable keys and clear empty states
The list uses key={task.id}. A task’s identifier describes its identity, even if filtering changes its position. An array index describes a position; a random key created during rendering changes every time. Neither suits this editable list. React explains why keys must remain stable in its list rendering guide. Two tasks may have identical text and still need separate identifiers.
The example uses an explicit conditional to display either a list or an empty message. Delete every completed task, then choose “Completed” to see that message. The board’s total still counts all tasks, while the list shows the selected subset. That difference is intentional. A conditional based on length > 0 also avoids accidentally rendering the number zero.
A controlled form and values you should not store
The field has both value and onChange; removing the handler would stop normal editing. Starting the draft as an empty string keeps the input controlled throughout its life. Its visible label and linked error help identify the field. See the React input reference for the controlled-input contract. The checkbox uses checked because its state is boolean.
Completed count and visible tasks are calculated from existing state. Storing either separately would create another value to keep synchronized after adding, deleting, or toggling. No Effect is needed for these calculations; that distinction is covered in You Might Not Need an Effect. For this small collection, ordinary filtering is readable and sufficient. Measure a real performance problem before introducing caching or a state library.
Verify behavior before adding features
- Add normal text, then submit only spaces. The first adds one item; the second shows an error.
- Add duplicate names, complete just one, and confirm each item remains independent.
- Switch between all three filters. Toggle and delete tasks in a filtered view, checking the counters afterward.
- Use Tab, Enter, and Space to operate the form, buttons, select, and checkboxes.
- Reload the page. The initial tasks return because this exercise stores data only in memory.
Then check that the production bundle can be created and previewed locally:
npm run build
npm run preview
A successful build checks compilation; it does not prove every interaction works. Open the preview address and repeat the short behavior checks.
Common errors and the next useful step
- Blank screen: read the first browser-console error and terminal message. Check the JSX closing tags, import spelling, and default export.
- Repeated rendering: pass a handler, such as
onClick={() => onRemove(task.id)}, instead of calling a setter while rendering. - Input cannot be edited: check its change handler and state update. Changing the DOM value manually will conflict with the controlled value.
crypto.randomUUIDunavailable: use the local development address or HTTPS in a modern browser. The example is intended for those environments.- A new task seems missing: the “Completed” filter hides newly added unfinished tasks. Switch to “All” before assuming the update failed.
Next, add task editing or a remaining count, then write interaction tests for those behaviors. After that, explore persistence, server validation, and loading and failure states. For multilingual layouts, our Arabic and RTL website guide explains the broader design concerns; use mobile performance guidance when the project grows. If this exercise becomes a business application, review our development services with a written list of required pages and workflows.