I ran a React workshop for students who had never touched the framework. Two repos went out before the session: a starting guide for installing the tools, and a live-coding companion that mirrored every step I would type on stream. This is the writeup of what we built and the calls I made along the way — not just what the code does, but the order I introduced it in and why.
The pedagogical bet
A Todo app is the most overused beginner project in frontend, and that is exactly why I picked it. Everyone in the room already had a mental model of what a Todo app should do, which meant I could spend the session on React instead of explaining the problem domain. The goal was never to teach Todo lists. It was to teach four ideas — components, props, state, lifting state up — by sneaking them past students one commit at a time.
The structure of the live-coding repo reflects that. Every step has a Live coding block (the incremental edit I type on screen) and a Snippet block (the full file you can copy-paste if you fell behind). Falling behind is the silent killer of code-along workshops. The snippets meant nobody had to ask me to scroll back.
Getting the room to "Hello, React"
Before any React happens, every laptop needs Node, a code editor, Git, and a GitHub account. I put this in a separate guide because mixing "install Node" with "useState lifts state up" in the same document is how you lose a beginner forever.
- Node.js (LTS) — the JavaScript runtime that powers every modern frontend build tool. Get the LTS, not the latest.
- Visual Studio Code — light, free, has a built-in terminal so I don't have to teach two windows at once.
- Git — version control, but on day one really just
git initandgit push. - GitHub — a place to put the repo so we can deploy it on Netlify in the last ten minutes.
Three commands to confirm everything is wired up — node -v, npm -v, git --version. If any of them fails, the student is in Replit for the rest of the session. The escape hatch matters. The workshop cannot wait for one person's PATH variable to cooperate.
In a workshop, the most expensive failure mode is the one quiet person who never got past step zero. Build the off-ramp before you need it.
Step 0 — scaffold the project
I used Create React App because the room was on different machines and CRA is the closest thing to "it just works." Tailwind got added in the same breath, because hand-rolling CSS during a live demo is a guaranteed off-topic detour.
npx create-react-app todo-app
cd todo-app
npm install -D tailwindcss@3
npx tailwindcss initTwo config files, then we never look at the build system again for the rest of the session:
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{js,jsx,ts,tsx}"],
theme: { extend: {} },
plugins: [],
};/* src/index.css */
@tailwind base;
@tailwind components;
@tailwind utilities;Step 1 — the page frame
First commit is always the shell. No components, no state, no logic — just a header, a card, and a footer. The point is to see something in the browser within the first three minutes, because nothing kills momentum like staring at a blank tab.
function App() {
return (
<div className="max-w-2xl mx-auto p-6 bg-gray-50 min-h-screen">
<header className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-800 mb-4">My Todo App</h1>
<div className="flex justify-center gap-6 text-sm text-gray-600">
{/* stats land here later */}
</div>
</header>
<main>
<div className="bg-white rounded-xl shadow-lg p-6">
{/* todo app goes here */}
</div>
<footer className="text-center mt-8 text-gray-500 text-sm">
<p>Built with React — perfect for learning.</p>
</footer>
</main>
</div>
);
}Step 2 — components without state
Now I introduce the word "component." Two stubs — TodoItem and TodoList — and the only thing they prove is that React renders nested things in a predictable order. No useState yet. No props. Just JSX inside arrow functions.
const TodoItem = () => (
<li className="p-4 bg-white rounded-lg mb-2 shadow-sm">Todo</li>
);
const TodoList = () => (
<ul className="space-y-0">
<TodoItem />
<TodoItem />
<TodoItem />
</ul>
);When a student asks "but where does the data come from?" — perfect, that's the next step.
Step 3 — state with useState
This is the first ideas-heavy moment. I tell the room three things and write them on the side of the screen: state lives in a component, useState returns a pair, and React re-renders when the setter is called. Then I show them what that looks like.
import { useState } from "react";
function App() {
const [todos] = useState([
{ id: 1, text: "Learn React basics", completed: true },
{ id: 2, text: "Build a todo app", completed: false },
{ id: 3, text: "Practice state management", completed: false },
]);
return (
/* ...same layout... */
<TodoList todos={todos} />
);
}
const TodoList = ({ todos }) => (
<ul className="space-y-0">
{todos.map((t) => (
<TodoItem key={t.id} todo={t} />
))}
</ul>
);The key prop is the first prop they meet, and I make a small fuss about it. React needs identity for list reconciliation; key={t.id} is what gives it that. Saying it once now saves a "why does my list flicker" question later.
Step 4 — styling without breaking the build
Pure visual polish. Checkboxes, hover states, line-through for completed items. The handlers aren't wired up yet — the checkbox's checked reflects state but has no onChange. I leave it that way on purpose so the next step has something to fix.
const TodoItem = ({ todo }) => (
<li
className={`flex items-center gap-3 p-4 bg-white rounded-lg mb-2 shadow-sm
hover:shadow-md transition-all duration-200
${todo.completed ? "opacity-70" : ""}`}
>
<input
type="checkbox"
checked={todo.completed}
className="w-4 h-4 text-blue-500 rounded focus:ring-2 focus:ring-blue-300"
/>
<span
className={`flex-1 text-gray-800 ${
todo.completed ? "line-through text-gray-500" : ""
}`}
>
{todo.text}
</span>
<button className="px-3 py-1 bg-red-500 text-white text-sm rounded hover:bg-red-600 transition-colors">
Delete
</button>
</li>
);Step 5 — lifting state up
This is the lesson I most wanted to land. TodoItem needs to flip completed, but it doesn't own the array. So the function lives in App, gets passed down as a prop, and TodoItem just calls it. State stays in one place; behaviour travels by prop.
function App() {
const [todos, setTodos] = useState([/* ... */]);
const toggleTodo = (id) => {
setTodos(
todos.map((t) =>
t.id === id ? { ...t, completed: !t.completed } : t
)
);
};
return (
/* ... */
<TodoList todos={todos} onToggle={toggleTodo} />
);
}
const TodoItem = ({ todo, onToggle }) => (
/* ... */
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
className="..."
/>
/* ... */
);When I demoed it, a student asked "why not just put the state in TodoItem?" That is the question. The answer — "because the list lives above the item, and the item shouldn't know about its siblings" — is the entire reason hooks exist in the shape they do.
Step 6 — adding tasks
TodoForm is the first component with its own state (the controlled input) and a prop callback into the parent (onAdd). It's a tiny pattern but it's the one every form in React reuses.
const TodoForm = ({ onAdd }) => {
const [inputValue, setInputValue] = useState("");
const handleSubmit = () => {
if (inputValue.trim()) {
onAdd(inputValue.trim());
setInputValue("");
}
};
const handleKeyPress = (e) => {
if (e.key === "Enter") handleSubmit();
};
return (
<div className="flex gap-2 mb-6">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Add a new task..."
className="flex-1 px-4 py-3 border-2 border-gray-200 rounded-lg focus:border-blue-400 focus:outline-none text-gray-700"
/>
<button
onClick={handleSubmit}
className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors font-medium"
>
Add Task
</button>
</div>
);
};
const addTodo = (text) => {
setTodos([...todos, { id: Date.now(), text, completed: false }]);
};I used Date.now() as the id, which is fine for a workshop and indefensible in production. I named that out loud — the demo id will collide if two tasks are added in the same millisecond, and the real fix is crypto.randomUUID() or a server-issued id. Better to flag the shortcut than pretend it isn't one.
Step 7 — deleting tasks
Same shape as toggle: handler in App, prop down to the item, button calls it. By now this should feel mechanical to the room — and that's the goal. Once the pattern is muscle memory, the next thing is just another variation.
const deleteTodo = (id) => {
setTodos(todos.filter((t) => t.id !== id));
};
<TodoItem
key={t.id}
todo={t}
onToggle={onToggle}
onDelete={onDelete}
/>
<button
className="px-3 py-1 bg-red-500 text-white text-sm rounded hover:bg-red-600 transition-colors"
onClick={() => onDelete(todo.id)}
>
Delete
</button>Step 8 — filters and the empty state
Two new ideas in one step: derived UI state, and not punishing a user who has nothing to look at. filteredTodos isn't stored — it's recomputed every render from the source of truth. And TodoList renders a friendly empty state instead of a void.
const [filter, setFilter] = useState("all");
const filteredTodos = todos.filter((t) => {
if (filter === "active") return !t.completed;
if (filter === "completed") return t.completed;
return true;
});
const FilterButtons = ({ currentFilter, onFilterChange }) => {
const filters = ["all", "active", "completed"];
return (
<div className="flex justify-center gap-2 mb-4">
{filters.map((f) => (
<button
key={f}
className={`px-4 py-2 rounded-full border-2 transition-all ${
currentFilter === f
? "bg-blue-500 text-white border-blue-500"
: "bg-white text-gray-600 border-gray-200 hover:border-blue-400"
}`}
onClick={() => onFilterChange(f)}
>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
);
};
// Empty state inside TodoList
if (todos.length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-500 italic text-lg">No tasks to display</p>
<p className="text-gray-400 text-sm mt-2">
Add a task above to get started!
</p>
</div>
);
}I called out the derivation explicitly. New React developers reach for useState for everything; teaching them to compute instead is one of the highest-leverage corrections you can make early.
Step 9 — the optional polish
Stats in the header — total, active, completed. Three derived numbers, three pills. It's the kind of finish that takes ninety seconds and makes the project feel done, which matters for students who are going to put this on their portfolio at the end of the day.
const totalTodos = todos.length;
const completedTodos = todos.filter((t) => t.completed).length;
const activeTodos = totalTodos - completedTodos;
// In the header:
<div className="flex justify-center gap-6 text-sm text-gray-600">
<span className="bg-white px-3 py-1 rounded-full shadow-sm">
<strong>Total:</strong> {totalTodos}
</span>
<span className="bg-white px-3 py-1 rounded-full shadow-sm">
<strong>Active:</strong> {activeTodos}
</span>
<span className="bg-white px-3 py-1 rounded-full shadow-sm">
<strong>Completed:</strong> {completedTodos}
</span>
</div>Shipping it
The last ten minutes were Git and Netlify. git init, push to a fresh GitHub repo, drag the repo into Netlify, watch the build go green. For a first-time React user, "I built this and it's on the internet" is the moment the abstraction stops being abstract. That's the moment the workshop is for.
For the curious, I left them with a bug-hunt branch — the same app with intentional bugs planted. The exercise is to find and fix them without me. The diff between "I followed the steps" and "I can debug what someone else wrote" is the actual skill we want them to leave with.
What I would change next time
- Talk about
keyearlier. Two students hit the warning in the console before I had introduced it. Five seconds of "every item in a list needs a unique key" up front would have saved both of them. - Show one bad version on purpose. Before lifting state up, I want to spend two minutes putting state in the wrong place and showing what breaks. The lesson lands harder when you've seen the failure mode.
- Replace
Date.now()withcrypto.randomUUID(). Same number of characters, no hidden bug, and it teaches a real API instead of a demo shortcut. - Skip CRA next time. Vite is faster, has better error messages, and the install is smaller. CRA is comfort food but the comfort isn't worth the cold start anymore.
Reflection
What surprised me about teaching this wasn't the React part — it was how much of the session was actually about pacing. The same nine steps in the same nine commits, but the difference between a student who got it and a student who didn't was almost always whether I waited the extra ten seconds before moving on. The framework is small. The conceptual jumps are not.
Credits to Shalok for the original DSEC Todo app the live coding session was built from, and to Vasu for the Git + GitHub slides that ran in parallel. The repos linked at the top of this post are the same ones the room had open the day we ran it.
Final App.js
The full file, identical in shape and behaviour to what we landed on at the end of the workshop.
import "./App.css";
import { useState } from "react";
const TodoItem = ({ todo, onToggle, onDelete }) => (
<li
className={`flex items-center gap-3 p-4 bg-white rounded-lg mb-2 shadow-sm
hover:shadow-md transition-all duration-200
${todo.completed ? "opacity-70" : ""}`}
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
className="w-4 h-4 text-blue-500 rounded focus:ring-2 focus:ring-blue-300"
/>
<span
className={`flex-1 text-gray-800 ${
todo.completed ? "line-through text-gray-500" : ""
}`}
>
{todo.text}
</span>
<button
className="px-3 py-1 bg-red-500 text-white text-sm rounded hover:bg-red-600 transition-colors"
onClick={() => onDelete(todo.id)}
>
Delete
</button>
</li>
);
const TodoList = ({ todos, onToggle, onDelete }) => {
if (todos.length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-500 italic text-lg">No tasks to display</p>
<p className="text-gray-400 text-sm mt-2">
Add a task above to get started!
</p>
</div>
);
}
return (
<ul className="space-y-0">
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</ul>
);
};
const TodoForm = ({ onAdd }) => {
const [inputValue, setInputValue] = useState("");
const handleSubmit = () => {
if (inputValue.trim()) {
onAdd(inputValue.trim());
setInputValue("");
}
};
const handleKeyPress = (e) => {
if (e.key === "Enter") handleSubmit();
};
return (
<div className="flex gap-2 mb-6">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Add a new task..."
className="flex-1 px-4 py-3 border-2 border-gray-200 rounded-lg focus:border-blue-400 focus:outline-none text-gray-700"
/>
<button
onClick={handleSubmit}
className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors font-medium"
>
Add Task
</button>
</div>
);
};
const FilterButtons = ({ currentFilter, onFilterChange }) => {
const filters = ["all", "active", "completed"];
return (
<div className="flex justify-center gap-2 mb-4">
{filters.map((filter) => (
<button
key={filter}
className={`px-4 py-2 rounded-full border-2 transition-all ${
currentFilter === filter
? "bg-blue-500 text-white border-blue-500"
: "bg-white text-gray-600 border-gray-200 hover:border-blue-400"
}`}
onClick={() => onFilterChange(filter)}
>
{filter.charAt(0).toUpperCase() + filter.slice(1)}
</button>
))}
</div>
);
};
function App() {
const [todos, setTodos] = useState([
{ id: 1, text: "Learn React basics", completed: true },
{ id: 2, text: "Build a todo app", completed: false },
{ id: 3, text: "Practice state management", completed: false },
]);
const [filter, setFilter] = useState("all");
const addTodo = (text) => {
setTodos([...todos, { id: Date.now(), text, completed: false }]);
};
const toggleTodo = (id) => {
setTodos(
todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
const deleteTodo = (id) => {
setTodos(todos.filter((todo) => todo.id !== id));
};
const filteredTodos = todos.filter((todo) => {
if (filter === "active") return !todo.completed;
if (filter === "completed") return todo.completed;
return true;
});
const totalTodos = todos.length;
const completedTodos = todos.filter((t) => t.completed).length;
const activeTodos = totalTodos - completedTodos;
return (
<div className="max-w-2xl mx-auto p-6 bg-gray-50 min-h-screen">
<header className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-800 mb-4">My Todo App</h1>
<div className="flex justify-center gap-6 text-sm text-gray-600">
<span className="bg-white px-3 py-1 rounded-full shadow-sm">
<strong>Total:</strong> {totalTodos}
</span>
<span className="bg-white px-3 py-1 rounded-full shadow-sm">
<strong>Active:</strong> {activeTodos}
</span>
<span className="bg-white px-3 py-1 rounded-full shadow-sm">
<strong>Completed:</strong> {completedTodos}
</span>
</div>
</header>
<main>
<div className="bg-white rounded-xl shadow-lg p-6">
<TodoForm onAdd={addTodo} />
<FilterButtons currentFilter={filter} onFilterChange={setFilter} />
<TodoList
todos={filteredTodos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
</div>
<footer className="text-center mt-8 text-gray-500 text-sm">
<p>Built with React — perfect for learning.</p>
</footer>
</main>
</div>
);
}
export default App;