Tangy is building your next step
Shaping the lesson around what you want to make.
Tangy is building your next step
Shaping the lesson around what you want to make.
Computer Science
You will turn your existing web skills into a small full-stack to-do app. Starting with a familiar semantic page and focused CSS, you will add an Express server, connect the browser to a tiny task API, and finish with a working app you can run locally and demonstrate.
Shape the app before the server
2 lessonsUse the learner's existing HTML and CSS confidence to create the complete interface contract first. The module stands alone so the reference app is not needed while the backend is still being built.
Give the page an Express home
2 lessonsAdd one new layer at a time: first a minimal Node project and server, then static-file serving. By the end, the familiar page runs from Express rather than from a file preview.
Build the tiny task API
3 lessonsKeep the backend deliberately small and understandable: an in-memory array, JSON responses, and four task operations. Each endpoint is added only when the frontend is ready to use it.
Connect the browser and polish the finish
3 lessonsReplace the static page's placeholder behavior with a small vanilla JavaScript client. The finished project will visibly respond to every task action and explain what is happening when the server is slow or returns an error.
Public lesson
Your HTML work already gives you a good foundation: meaningful headings, sections, labels, and lists. We’ll bring those habits into React now.
For this lesson, keep everything in memory. The app only needs to show how the interface behaves; saving data to a backend comes later.
Tasks
Before opening your editor, write down the small set of actions this app supports:
Now map what the user can see after each action:
The error is not a separate kind of task list. It is feedback shown alongside whichever list state you are currently in.
Create a short note in your project or on paper with these four states:
| State | What the user sees |
|---|---|
| Empty | An explanation that there are no tasks yet |
| Populated | One or more task rows |
| Completed | Every visible task is complete, with a completed status |
| Error | A useful message when the submitted text is blank |
Use this structure as your plan:
main
├── header
│ ├── h1
│ └── introductory paragraph
├── section: add a task
│ └── form
│ ├── label
│ ├── text input
│ └── submit button
├── section: task list
│ ├── heading
│ ├── live status text
│ ├── empty message OR unordered list
│ │ └── list item
│ │ ├── checkbox and label
│ │ └── remove button
│ └── clear-completed button
main
├── header
│ ├── h1
│ └── introductory paragraph
├── section: add a task
│ └── form
│ ├── label
│ ├── text input
│ └── submit button
├── section: task list
│ ├── heading
│ ├── live status text
│ ├── empty message OR unordered list
│ │ └── list item
│ │ ├── checkbox and label
│ │ └── remove button
│ └── clear-completed button
Use a real <form> for adding tasks. That gives the Enter key the expected behavior and keeps the interface usable without relying on a click.
Use an unordered list for tasks because the tasks are a collection of peer items. Avoid using <div> elements for every part of the page when a more meaningful element exists.
Tasks
Open your main component, probably src/App.jsx. Keep this in one component for now so you can focus on the state transitions before extracting smaller components.
Replace the existing demo content with this scaffold. The missing parts are yours to write.
import { useState } from "react";
export default function App() {
const [draft, setDraft] = useState("");
const [tasks, setTasks] = useState([]);
const [error, setError] = useState("");
const completedCount = tasks.filter((task) => task.completed).length;
const openCount = tasks.length - completedCount;
function handleSubmit(event) {
event.preventDefault();
// TODO:
// 1. Reject input that is empty after trimming.
// 2. Set an error message without adding a task.
// 3. For valid input, add a task with:
// - a unique id
// - the trimmed text
// - completed: false
// 4. Clear the input and any old error.
}
function toggleTask(taskId) {
// TODO: update only the task whose id matches taskId
}
function removeTask(taskId) {
// TODO: create a new list without the matching task
}
function clearCompleted() {
// TODO: keep only tasks whose completed value is false
}
return (
<main>
<header>
<h1>Task list</h1>
<p>Keep a short list of things you want to finish.</p>
</header>
<section aria-labelledby="add-task-heading">
<h2 id="add-task-heading">Add a task</h2>
<form onSubmit={handleSubmit}>
<label htmlFor="task-input">Task description</label>
<input
id="task-input"
name="task"
type="text"
value={draft}
onChange={(event) => {
setDraft(event.target.value);
// TODO: clear the old error when the learner starts editing
}}
/>
<button type="submit">Add task</button>
</form>
{error && (
<p role="alert">{error}</p>
)}
</section>
<section aria-labelledby="task-list-heading">
<h2 id="task-list-heading">Your tasks</h2>
<p aria-live="polite">
{/* TODO: describe the current count, including the completed state */}
</p>
{tasks.length === 0 ? (
<p>
{/* TODO: write the empty-state message */}
</p>
) : (
<ul>
{tasks.map((task) => (
<li key={task.id}>
<label>
<input
type="checkbox"
checked={task.completed}
onChange={() => toggleTask(task.id)}
/>
<span>{task.text}</span>
</label>
<button
type="button"
onClick={() => removeTask(task.id)}
>
Remove
</button>
</li>
))}
</ul>
)}
<button type="button" onClick={clearCompleted}>
Clear completed
</button>
</section>
</main>
);
}
import { useState } from "react";
export default function App() {
const [draft, setDraft] = useState("");
const [tasks, setTasks] = useState([]);
const [error, setError] = useState("");
const completedCount = tasks.filter((task) => task.completed).length;
const openCount = tasks.length - completedCount;
function handleSubmit(event) {
event.preventDefault();
// TODO:
// 1. Reject input that is empty after trimming.
// 2. Set an error message without adding a task.
// 3. For valid input, add a task with:
// - a unique id
// - the trimmed text
// - completed: false
// 4. Clear the input and any old error.
}
function toggleTask(taskId) {
// TODO: update only the task whose id matches taskId
}
function removeTask(taskId) {
// TODO: create a new list without the matching task
}
function clearCompleted() {
// TODO: keep only tasks whose completed value is false
}
return (
<main>
<header>
<h1>Task list</h1>
<p>Keep a short list of things you want to finish.</p>
</header>
<section aria-labelledby="add-task-heading">
<h2 id="add-task-heading">Add a task</h2>
<form onSubmit={handleSubmit}>
<label htmlFor="task-input">Task description</label>
<input
id="task-input"
name="task"
type="text"
value={draft}
onChange={(event) => {
setDraft(event.target.value);
// TODO: clear the old error when the learner starts editing
}}
/>
<button type="submit">Add task</button>
</form>
{error && (
<p role="alert">{error}</p>
)}
</section>
<section aria-labelledby="task-list-heading">
<h2 id="task-list-heading">Your tasks</h2>
<p aria-live="polite">
{/* TODO: describe the current count, including the completed state */}
</p>
{tasks.length === 0 ? (
<p>
{/* TODO: write the empty-state message */}
</p>
) : (
<ul>
{tasks.map((task) => (
<li key={task.id}>
<label>
<input
type="checkbox"
checked={task.completed}
onChange={() => toggleTask(task.id)}
/>
<span>{task.text}</span>
</label>
<button
type="button"
onClick={() => removeTask(task.id)}
>
Remove
</button>
</li>
))}
</ul>
)}
<button type="button" onClick={clearCompleted}>
Clear completed
</button>
</section>
</main>
);
}
For the unique ID, use a value that will not collide when two tasks have the same text. A timestamp is sufficient for this practice app. The ID is for React’s key and for finding the correct task; the task text itself is not reliable enough.
Tasks
Run the app and inspect it in the browser.
At this point, confirm:
<h1>.<h2>.type="button" unless they submit the form.The interactions will not all work until you fill the TODOs. That is expected.
Tasks
Complete handleSubmit.
Use this order:
draft.When adding to an array in React, create a new array rather than modifying tasks directly. The new array is what tells React that the state changed.
A useful shape for one task is:
{
id: /* your unique value */,
text: /* trimmed draft */,
completed: false
}
{
id: /* your unique value */,
text: /* trimmed draft */,
completed: false
}
Tasks
In the browser:
The error message should be connected to the form visually and semantically. role="alert" makes the feedback available immediately to assistive technology.
Tasks
Fill in toggleTask, removeTask, and clearCompleted.
For toggling, use map:
For removing and clearing, use filter.
Do not change a task like this:
task.completed = !task.completed;
task.completed = !task.completed;
That changes an existing object in place. Instead, create a new object for the changed task using the spread operator, then replace the task array with the new array.
Tasks
Create at least three tasks and verify:
Complete the live status text. It should tell the user what changed without requiring them to count rows.
Your status logic needs to distinguish:
You already have the two values needed:
completedCount
openCount
completedCount
openCount
Write the text inside the <p aria-live="polite">. For example, your wording might communicate:
Use the singular form when the count is 1. This is a small detail, but it makes status text sound like part of a finished interface rather than debug output.
Tasks
Use the browser to reach each state and read the status aloud:
The status should update immediately after checking or removing a task.
Tasks
Your existing stylesheet may already contain the starter styles. If it does not, add a small amount of CSS. The important behavior is that completed tasks look different and the error is easy to find.
:root {
font-family: system-ui, sans-serif;
color: #202124;
background: #f5f6f8;
}
body {
margin: 0;
}
main {
width: min(42rem, calc(100% - 2rem));
margin: 0 auto;
padding: 3rem 0;
}
section {
margin-top: 2rem;
padding: 1.25rem;
background: white;
border: 1px solid #d9dce1;
border-radius: 0.5rem;
}
form {
display: grid;
gap: 0.5rem;
}
ul {
display: grid;
gap: 0.75rem;
padding: 0;
list-style: none;
}
li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
label {
display: flex;
gap: 0.5rem;
align-items: center;
}
[role="alert"] {
color: #a12622;
}
/* TODO:
Add a class or data attribute for completed task text,
then style it with a line-through and lower emphasis.
*/
:root {
font-family: system-ui, sans-serif;
color: #202124;
background: #f5f6f8;
}
body {
margin: 0;
}
main {
width: min(42rem, calc(100% - 2rem));
margin: 0 auto;
padding: 3rem 0;
}
section {
margin-top: 2rem;
padding: 1.25rem;
background: white;
border: 1px solid #d9dce1;
border-radius: 0.5rem;
}
form {
display: grid;
gap: 0.5rem;
}
ul {
display: grid;
gap: 0.75rem;
padding: 0;
list-style: none;
}
li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
label {
display: flex;
gap: 0.5rem;
align-items: center;
}
[role="alert"] {
color: #a12622;
}
/* TODO:
Add a class or data attribute for completed task text,
then style it with a line-through and lower emphasis.
*/
To style only completed text, add a class conditionally to the <span> in your task row. The class should be present when task.completed is true and absent otherwise.
Tasks
Test the complete flow in this order:
Your finished page should have a semantic structure, predictable state transitions, and controls that match the actions the user can take.
4 modules · 10 lessons
Shape the app before the server
Give the page an Express home
Build the tiny task API
Connect the browser and polish the finish
Learn by building your own version.
Remix this public project to open the workspace, follow the guided build, and let the AI mentor teach you through the work instead of doing it for you.