Chapter 2: Build Core Todo
Local Storage
Local Storage
Everything we have built so far disappears when you refresh the page. Let's fix that by persisting todos to the browser's localStorage with a custom React hook.
The prompt
"Create a custom hook called useLocalStorage at src/hooks/useLocalStorage.ts. It should work like useState but automatically save to and load from localStorage. Handle the case where localStorage is not available (server-side rendering in Next.js). Use a key parameter to namespace the storage. Include proper TypeScript generics."
We are asking for a custom hook, not just inline localStorage calls. This is how professional React developers handle persistence -- a reusable hook that encapsulates all the complexity of reading, writing, and error handling.
What Claude Code creates
'use client';
import { useState, useEffect } from 'react';
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
// Initialize state with localStorage value or initial value
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === 'undefined') {
return initialValue;
}
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.warn(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
// Update localStorage whenever the state changes
useEffect(() => {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(key, JSON.stringify(storedValue));
} catch (error) {
console.warn(`Error setting localStorage key "${key}":`, error);
}
}, [key, storedValue]);
return [storedValue, setStoredValue];
}This is the most complex code in the chapter. You don't need to understand every line — Claude Code wrote it for you. Focus on the one-line change in page.tsx below that makes persistence work. If you're curious about how the hook works internally, ask Claude Code to explain it. The "Understanding the hook" section below breaks it down, but it's completely optional reading.
Understanding the hook
Ask Claude Code to break down the key decisions:
"Why do you check for typeof window === 'undefined'? And why is the initial state a function?"
- Server-side check -- Next.js renders components on the server first, where
windowdoes not exist. Without this check, the code would crash during SSR. - Lazy initial state -- Passing a function to
useState(() => ...)means the localStorage read only happens once, on the first render. Without the function wrapper, it would read localStorage on every render. - Try/catch -- localStorage can throw errors in private browsing mode, when storage is full, or when blocked by security settings. Wrapping in try/catch ensures the app still works.
- useEffect for writes -- Writing to localStorage in an effect keeps it automatically in sync with state changes.
The file structure
src/ hooks/ useLocalStorage.ts -- New custom hook components/ AddTodo.tsx TodoList.tsx CategoryFilter.tsx SearchBar.tsx types/ todo.ts
Keeping hooks in a dedicated hooks/ directory is a common convention. It signals to other developers that these are reusable pieces of logic, not UI components.
Use the hook in page.tsx
"Replace the useState for todos in page.tsx with the useLocalStorage hook. Use the key 'todos'. Keep the sample data as a fallback for first-time users who have nothing in localStorage yet."
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { sampleTodos } from '@/data/sample-todos';
export default function Home() {
const [todos, setTodos] = useLocalStorage<Todo[]>('todos', sampleTodos);
// ... everything else stays exactly the same
}That's it -- one line change. The beauty of a custom hook is that the rest of your code does not need to know anything about localStorage. setTodos works exactly like before, but now it also saves to the browser automatically.
Test persistence
- Add a few new todos with different categories and priorities
- Refresh the page (Cmd+R or Ctrl+R)
- Your todos should still be there
- Complete some todos, refresh -- completed states persist
- Delete a todo, refresh -- it stays deleted
- Open DevTools > Application > Local Storage to see the raw JSON data
Your todos now survive page refreshes. Go ahead — close the browser tab entirely, reopen it, navigate back to localhost:3000. Everything is still there. You just went from a demo to an app someone could actually use daily, with a one-line code change.
If you want to reset to sample data, open the browser console and run localStorage.removeItem('todos'), then refresh. This clears the stored data and falls back to the initial sample todos.
Handling hydration warnings
You might see a Next.js hydration warning in the console. This happens because the server renders with the initial value (sample todos) but the client loads different data from localStorage.
"I'm getting a hydration mismatch warning because localStorage data differs from the server render. How do I fix this?"
Claude Code will suggest one of these approaches:
- Suppress the warning with
suppressHydrationWarningon the parent element - Use a loading state that shows a skeleton until localStorage is read on the client
- Delay the localStorage read to after hydration completes
For a learning project, option 1 is fine. For production, option 2 gives the best user experience -- we will touch on this in the empty states section.
localStorage has a limit of roughly 5MB per domain. For a todo app this is plenty, but if you were storing images or large files, you would need IndexedDB or a server-side database instead.
Why a custom hook instead of a library?
You might wonder if there is a library for this. There are several (use-local-storage-state, @uidotdev/usehooks, etc.), but building your own teaches you how hooks work. And at 20 lines, the code is simple enough that a dependency is not worth the overhead.
Your todos now survive page refreshes. That is a huge UX improvement with minimal code change. Next: sorting the list so users can organize it their way.
The useLocalStorage hook is the most complex code in this chapter. You don't need to understand every line — Claude Code wrote it for you. Focus on how to USE the hook (the one-line change in page.tsx), not how it works internally. That's the power of working with Claude Code: it handles the complexity so you can focus on the product.
Milestone: Your app remembers! Refresh the browser. Your todos are still there. Add a few, close the tab, reopen it — everything persists. Your app just went from a demo to something genuinely useful. This is the moment it becomes a real tool.