A todo application built with React, Next.js, and TypeScript. Uses localStorage for persistence and shadcn/ui for the component library.
- Next.js 16 — App Router, server/client components
- React 19 —
useState+useEffectfor localStorage sync - TypeScript 5 — strict type safety
- Tailwind CSS 4 — utility-first styling
- shadcn/ui — Radix UI primitives with Tailwind
- React Hook Form + Zod — form handling and validation
npm install
npm run devOpen http://localhost:3000.
src/
app/
layout.tsx # Root layout, fonts, ThemeProvider
page.tsx # Home page, renders TodoList
globals.css # Tailwind config, CSS variables, keyframes
components/
todo-list.tsx # Main orchestrator (state, filtering, progress)
todo-item.tsx # Single todo row (checkbox, text, actions)
form-add-todo.tsx # Add todo form with Zod validation
card-empty-state.tsx # Reusable empty state card
dialog-confirm-delete.tsx # Delete confirmation (AlertDialog)
dialog-edit-todo.tsx # Edit todo (Dialog)
button-theme-toggle.tsx # Dark/light mode toggle
theme-provider.tsx # next-themes wrapper
ui/ # shadcn/ui base components
hooks/
use-todos.ts # Todo CRUD operations + localStorage sync
use-mounted.ts # Hydration-safe mounted check
types/
todo.ts # Todo interface
lib/
utils.ts # cn() helper (clsx + tailwind-merge)
The suppressHydrationWarning on the root <html> element and the todo list container are intentional and necessary. Here's why:
next-themes adds a class="dark" or class="light" to <html> at runtime via a blocking script. During server-side rendering, React doesn't know what class the element will have — the server renders without it, and the client adds it immediately. This causes a harmless hydration mismatch on the class attribute. suppressHydrationWarning tells React to expect this and not warn about it. This is the recommended approach from both next-themes and shadcn/ui.
Todos are stored in localStorage, which only exists in the browser. During SSR, useState initializes with an empty array. After hydration, a useEffect loads the real data from localStorage.
This means the server HTML and client HTML can differ (empty state vs. populated list). However, the entire container starts with opacity: 0 and only fades in after the useMounted() hook confirms hydration is complete. The user never sees the mismatch — it happens while the content is invisible. suppressHydrationWarning silences the expected warning for this specific element.