This README is your hands-on workshop package. It includes working code (TypeScript + React), inline comments, and short explanations for each block. Copy files as-is, then advance session by session.
Package manager: uses Bun in all commands. UI kit: shadcn/ui components under
components/ui/*
.
# Add shadcn/ui components you’ll need in Phase 1
bunx --bun shadcn@latest add button input checkbox switch card scroll-area dialog alert-dialog badge separator sonner
# Also add nanoid for id generation
bun add nanoid
src/App.tsx
src/components/todo-form.tsx
src/components/todo-list.tsx
src/components/todo-item.tsx
src/lib/storage.ts
src/state/reducers/todos.ts
src/types/todos.ts
What & why: Centralizes shared types (todo item, filter, reducer’s state/actions) to prevent drift and improve maintainability.
// src/types/todos.ts
export type TTodo = {
id: string;
title: string;
done: boolean;
createdAt: number; // epoch ms for sorting/analytics
};
export type TFilter = "all" | "active" | "done";
export type TState = {
todos: TTodo[];
filter: TFilter;
query: string; // used for search
};
export type TAction =
| { type: "ADD_TODO"; title: string }
| { type: "TOGGLE_TODO"; id: string }
| { type: "EDIT_TODO"; id: string; title: string }
| { type: "REMOVE_TODO"; id: string }
| { type: "SET_FILTER"; filter: TFilter }
| { type: "SET_QUERY"; query: string };
What & why: A pure reducer with immutable updates keeps business logic predictable, easy to test, and scalable.
// src/state/reducers/todos.ts
import type { TAction, TState, TTodo } from "@/types/todos";
import { nanoid } from "nanoid";
export const initialState: TState = { todos: [], filter: "all", query: "" };
export function reducer(state: TState, action: TAction): TState {
switch (action.type) {
case "ADD_TODO": {
const title = action.title.trim();
if (!title) return state; // guard: no empty todos
const next: TTodo = {
id: nanoid(),
title,
done: false,
createdAt: Date.now(),
};
return { ...state, todos: [next, ...state.todos] };
}
case "TOGGLE_TODO":
return {
...state,
todos: state.todos.map((t) =>
t.id === action.id ? { ...t, done: !t.done } : t
),
};
case "EDIT_TODO":
return {
...state,
todos: state.todos.map((t) =>
t.id === action.id ? { ...t, title: action.title } : t
),
};
case "REMOVE_TODO":
return { ...state, todos: state.todos.filter((t) => t.id !== action.id) };
case "SET_FILTER":
return { ...state, filter: action.filter };
case "SET_QUERY":
return { ...state, query: action.query };
default:
return state;
}
}
Key choices:
- Prevent empty todos.
- Prepend new items so recent tasks appear on top.
- Zero mutations; always return new arrays/objects.
What & why: Encapsulates LocalStorage I/O, keeps components clean, makes it testable.
// src/lib/storage.ts
import type { TState } from "@/types/todos";
const KEY = "todos:v1";
export const loadState = (): TState | null => {
if (typeof window === "undefined") return null; // SSR safety
try {
return JSON.parse(localStorage.getItem(KEY) || "null");
} catch {
return null;
}
};
export const saveState = (s: TState) => {
if (typeof window === "undefined") return;
localStorage.setItem(KEY, JSON.stringify(s));
};
What & why: Hosts the reducer, wires persistence, provides layout, and renders Form + List. Also mounts Sonner <Toaster />
for toasts.
// src/App.tsx
import { useEffect, useMemo, useReducer } from "react";
import { initialState, reducer } from "./state/reducers/todos";
import type { TAction, TState, TFilter } from "./types/todos";
import { Card } from "./components/ui/card";
import TodoForm from "./components/todo-form";
import TodoList from "./components/todo-list";
import { Separator } from "./components/ui/separator";
import { Badge } from "./components/ui/badge";
import { Toaster } from "./components/ui/sonner"; // shadcn sonner export
import { loadState, saveState } from "./lib/storage";
// Debounce helper to avoid frequent writes
function useDebouncedSave(state: TState, delay = 300) {
useEffect(() => {
const t = setTimeout(() => saveState(state), delay);
return () => clearTimeout(t);
}, [state, delay]);
}
export default function App() {
// Hydrate from LocalStorage (once)
const bootState = useMemo(() => loadState() ?? initialState, []);
const [state, dispatch] = useReducer(reducer, bootState);
// Persist with debounce
useDebouncedSave(state);
// Derived counts for UI badges
const active = state.todos.filter((t) => !t.done).length;
const done = state.todos.length - active;
const setFilter = (f: TFilter) => dispatch({ type: "SET_FILTER", filter: f });
return (
<main className="mx-auto max-w-xl p-4">
{/* Sonner toaster (Vite/CRA). If Next.js App Router, put <Toaster/> in app/layout.tsx */}
<Toaster />
<Card className="p-4 space-y-4">
<header className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold">Advanced Todo</h1>
<div className="flex items-center gap-2 text-sm opacity-80">
<Badge variant="secondary">All: {state.todos.length}</Badge>
<Badge variant="secondary">Active: {active}</Badge>
<Badge variant="secondary">Done: {done}</Badge>
</div>
<Separator />
</header>
<TodoForm dispatch={dispatch} />
{/* Filters & search (Session 3). Keep here for a compact header */}
<div className="flex flex-wrap items-center gap-2">
<button
className={`px-3 py-1 rounded-md border ${
state.filter === "all" ? "bg-muted" : ""
}`}
onClick={() => setFilter("all")}
>
All
</button>
<button
className={`px-3 py-1 rounded-md border ${
state.filter === "active" ? "bg-muted" : ""
}`}
onClick={() => setFilter("active")}
>
Active
</button>
<button
className={`px-3 py-1 rounded-md border ${
state.filter === "done" ? "bg-muted" : ""
}`}
onClick={() => setFilter("done")}
>
Done
</button>
<input
placeholder="Search…"
className="ml-auto w-40 px-3 py-1 rounded-md border"
value={state.query}
onChange={(e) =>
dispatch({ type: "SET_QUERY", query: e.currentTarget.value })
}
/>
</div>
<TodoList state={state} dispatch={dispatch} />
</Card>
</main>
);
}
Why these choices:
- Loads once from LocalStorage to avoid SSR/hydration issues.
- Debounced persistence prevents noisy writes.
- Keeps filter/query controls close to the list for a focused UX.
What & why: Minimal, keyboard-first form. Uses ref
for instant reset/focus. Shows toast for validation (Session 1 homework integrated).
// src/components/todo-form.tsx
import type { TAction } from "@/types/todos";
import { useRef, type Dispatch, type FormEvent } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { toast } from "@/components/ui/sonner";
const MAX_LEN = 100;
export default function TodoForm({
dispatch,
}: {
dispatch: Dispatch<TAction>;
}) {
const ref = useRef<HTMLInputElement>(null);
const onSubmit = (e: FormEvent) => {
e.preventDefault();
const v = ref.current?.value ?? "";
const title = v.trim();
if (!title) {
toast.error("Title can’t be empty");
return;
}
if (title.length > MAX_LEN) {
toast.error(`Max ${MAX_LEN} characters`);
return;
}
dispatch({ type: "ADD_TODO", title });
if (ref.current) {
ref.current.value = ""; // clear
ref.current.focus(); // auto-focus (Session 2)
}
toast.success("Todo added");
};
return (
<form onSubmit={onSubmit} className="flex gap-2">
<Input ref={ref} placeholder="What’s on your mind?" />
<Button type="submit">Add</Button>
</form>
);
}
What & why: TodoList
handles filtering + searching, and renders to a scrollable area. TodoItem
focuses on single-item interactions and UX feedback.
// src/components/todo-list.tsx
import type { TAction, TState, TTodo } from "@/types/todos";
import TodoItem from "./todo-item";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { Dispatch } from "react";
function applyFilterAndSearch(todos: TTodo[], state: TState) {
let rows = todos;
// filter
if (state.filter === "active") rows = rows.filter((t) => !t.done);
if (state.filter === "done") rows = rows.filter((t) => t.done);
// search
const q = state.query.trim().toLowerCase();
if (q) rows = rows.filter((t) => t.title.toLowerCase().includes(q));
return rows;
}
export default function TodoList({
state,
dispatch,
}: {
state: TState;
dispatch: Dispatch<TAction>;
}) {
const rows = applyFilterAndSearch(state.todos, state);
return (
<ScrollArea className="h-96 pr-3">
{rows.length === 0 ? (
<p className="text-sm opacity-70 p-2">
No items. Add your first task ✨
</p>
) : (
<ul className="space-y-2">
{rows.map((t) => (
<TodoItem key={t.id} todo={t} dispatch={dispatch} />
))}
</ul>
)}
</ScrollArea>
);
}
// src/components/todo-item.tsx
import type { TAction, TTodo } from "@/types/todos";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import type { Dispatch } from "react";
import { toast } from "@/components/ui/sonner";
export default function TodoItem({
todo,
dispatch,
}: {
todo: TTodo;
dispatch: Dispatch<TAction>;
}) {
const onToggle = () => {
dispatch({ type: "TOGGLE_TODO", id: todo.id });
};
const onEdit = () => {
const title = prompt("Edit title:", todo.title) ?? todo.title; // simple inline edit
if (title.trim() !== todo.title.trim()) {
dispatch({ type: "EDIT_TODO", id: todo.id, title: title.trim() });
toast.success("Todo updated");
// Smooth scroll to the edited item
document
.getElementById(todo.id)
?.scrollIntoView({ behavior: "smooth", block: "center" });
}
};
const onDelete = () => {
dispatch({ type: "REMOVE_TODO", id: todo.id });
toast("Todo deleted");
};
return (
<li
id={todo.id}
className="flex items-center gap-3 justify-between rounded-md border p-2 transition-all"
>
<div className="flex items-center gap-3">
<Checkbox checked={todo.done} onCheckedChange={onToggle} />
<span className={todo.done ? "line-through opacity-60" : ""}>
{todo.title}
</span>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={onEdit}>
Edit
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">Delete</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this todo?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onDelete}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</li>
);
}
Why these choices:
AlertDialog
prevents accidental deletions.scrollIntoView
helps users keep context after edits.- Lightweight inline edit avoids extra component state for Phase 1.
Final output: Display todo list + add form.
- Basic CRUD (add/remove/toggle/edit) works.
- No empty item is added.
- React console is warning-free.
Homework:
- Validation toast for empty input (implemented in
TodoForm
). - Trim and enforce max length (implemented).
Final output: LocalStorage persistence + auto-focus after add.
- Persist with LocalStorage (
lib/storage.ts
). - Debounced save via
useDebouncedSave
inApp.tsx
. - Auto-focus after add (in
TodoForm
).
Homework:
- Already included: debounced save.
Final output: Filter (All / Active / Done) + Search + smooth scroll + confirm delete + toasts.
- Filter + search implemented in
TodoList
. - Smooth scroll to edited item in
TodoItem
. -
AlertDialog
confirm on delete. - Toast feedback for actions.
- Stretch: Animate item entry/exit with Tailwind (
transition
,opacity
,translate-y
classes or Framer Motion).
Hint for simple animations:
- Add
transition
,duration-200
, and conditionalopacity-0
/opacity-100
classes when mounting/unmounting with a small key-based trick, or switch to Framer MotionAnimatePresence
for more control.
- Form:
Input
,Button
- Item:
Checkbox
,Button (secondary/destructive)
- Layout:
Card
,ScrollArea
,Separator
- Feedback:
sonner
(toast
,<Toaster />
),AlertDialog
- Meta:
Badge
for counters (active/done counts)
- Full CRUD with no console errors.
- LocalStorage load + debounced save.
- Accurate search/filter.
- UX niceties: auto-focus, confirm delete, smooth scroll, basic animations (optional).
-
SSR safety: Only access
window/localStorage
on the client. -
Keys: always
key={todo.id}
, never array index. -
Reducer purity: don’t mutate arrays/objects.
-
Toast provider:
- Next.js: add
<Toaster />
toapp/layout.tsx
. - Vite/CRA: render
<Toaster />
once inApp.tsx
.
- Next.js: add
- Inline edit with editable
<Input>
and Enter/ESC handling. - Empty state card with “Add your first task”.
- Export/Import JSON of tasks (download/upload file).
Export/Import sketch:
// Export: create a Blob(JSON.stringify(state.todos)) and download via <a download>
// Import: <input type="file" accept="application/json">, parse JSON, dispatch add/replace