Build a Todo App with React #5: Persistence and Wrap-up

7 min read

Last time we finished inline editing. But refreshing the page wipes all the data — everything lives in memory and isn’t saved anywhere. In this post we’ll persist with localStorage so the data survives a refresh, then look back over the series and wrap it up.

Goals for this step #

  • Todos are automatically saved to localStorage
  • On page load, the saved data is restored
  • The filter state (active, completed) is preserved too
  • Series retrospective + next-steps guidance

useLocalStorage custom hook #

A hook we already built in the basics course #13. Bring it over and use it.

src/hooks/useLocalStorage.js:

src/hooks/useLocalStorage.js
import { useState, useEffect } from 'react';

export function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const stored = localStorage.getItem(key);
      return stored !== null ? JSON.parse(stored) : initialValue;
    } catch {
      return initialValue;
    }
  });

  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // Quietly ignore save failures (quota exceeded, etc.)
    }
  }, [key, value]);

  return [value, setValue];
}

We added try/catch to what we built in #13. localStorage has a few failure modes:

  • JSON.parse failure — bad data from some other code is stored
  • localStorage.setItem failure — quota exceeded (typically 5MB), some private browsing modes, etc.

In those cases, silently falling back is a better user experience than crashing the whole app.

Note
Passing a function as the initial argument (useState(() => { ... })) is the “lazy initializer” pattern we briefly mentioned in #5. With direct invocation like useState(localStorage.getItem(...)), localStorage is read on every render; wrapping it in a function makes it run only once on the first render. Good for initialization that has a cost, like localStorage.

Apply to TodoApp #

All you need to do is swap useState for useLocalStorage. The interface is the same, so other code stays put.

The change in src/TodoApp.jsx:

src/TodoApp.jsx (changed parts)
import { useState } from 'react';
import { useLocalStorage } from './hooks/useLocalStorage';
// ... other imports ...

function TodoApp() {
  const [todos, setTodos] = useLocalStorage('todos', []);
  const [filter, setFilter] = useLocalStorage('todoFilter', 'all');
  const [editingId, setEditingId] = useState(null);

  // ... the rest stays the same ...
}

todos and filter are persisted; editingId is transient UI state, so it stays as plain useState — having edit mode still active after a refresh would be awkward.

The key names ('todos', 'todoFilter') are identifiers stored in localStorage. Use names clear enough not to clash with other apps. In larger apps, prefixes like 'myapp:todos' are a common convention.

Verify it works #

Save and try the following.

  1. Add some todos and complete some
  2. Switch the filter to “Active”
  3. Refresh the page (Cmd+R or F5)
  4. All todos and the filter state are restored
  5. Open the browser dev tools → Application tab → Local Storage → your domain → check that 'todos' and 'todoFilter' keys exist

If you open the same page in a new tab (same domain), localStorage is shared, so the data is visible there too.

Empty start vs demo data #

Showing a brand-new visitor a blank screen can leave them puzzled about how to use the app. Sometimes you’ll want to seed it with initial data. Options:

A. Empty start + helpful messaging (current)

  • Simple and clear
  • “No todos yet. Add a new one.” plays the role of empty-state handling

B. Start with demo data

const [todos, setTodos] = useLocalStorage('todos', [
  { id: 'demo-1', text: 'Study React', completed: true },
  { id: 'demo-2', text: 'Exercise 30 minutes', completed: false },
]);
  • The user can immediately explore the features
  • Downside: the user has to delete the demos one by one

This series goes with A. Both are reasonable; pick what suits you.

Sync across tabs (optional) #

Open two tabs of the same domain and add a todo in one — the other tab won’t notice until refresh. localStorage itself changed, but React doesn’t know.

The browser fires a storage event when localStorage changes in another tab. Subscribing to it enables auto-sync.

hook extension (optional)
useEffect(() => {
  function handleStorage(e) {
    if (e.key === key && e.newValue !== null) {
      try {
        setValue(JSON.parse(e.newValue));
      } catch {
        // ignore
      }
    }
  }
  window.addEventListener('storage', handleStorage);
  return () => window.removeEventListener('storage', handleStorage);
}, [key]);

You can add this when you actually need it. For simplicity, this series leaves it out.

Other improvement ideas #

Once you’ve gotten this far, you’ll see lots of directions to grow it.

  • Visual polish: split inline styles into CSS Modules / Tailwind / styled-components. Mobile responsiveness
  • Drag to reorder: with react-dnd or @dnd-kit/core
  • Categories/tags: tag todos and add tag filtering
  • Due dates: add a date field, highlight overdue items
  • Search: filter by typed text instantly (a great chance for #13’s useDebounce)
  • Dark mode: apply the ThemeContext from #12
  • TypeScript migration: define the todo object type for safety
  • Testing: unit-test core behavior with Vitest + React Testing Library
  • Backend integration: real syncing needs a server. Try JSON Server or a BaaS like Supabase

Each is a great learning project, and the small size makes experimenting low-risk. If something interests you, give it a try.

Series retrospective #

Across this series we built:

#Added featureKey patterns/tools
1Add/delete, component decompositionUnidirectional data flow, lifting state up, UUID, controlled form
2Completion toggle, statsImmutable update (map pattern), derived values
3Filtering, bulk deleteRender options from a data array, empty-state branching
4Inline editinguseRef, draft state, keyboard handling, onBlur
5localStorage persistenceCustom hook reuse, lazy initializer

If you followed along in order, you experienced firsthand a small real-world app emerging from a single simple component.

How the basics combined #

Almost every basic concept showed up naturally in this series.

  • Components and props (#4) — TodoForm, TodoItem, TodoList, TodoStats, TodoFilter — splitting the screen into responsibilities
  • useState (#5) — todos, filter, editingId, draft — every changing piece of data
  • Event handling (#6) — onClick, onChange, onSubmit, onKeyDown, onBlur
  • Conditional rendering (#7) — empty states, edit-mode branching, the bulk-delete button
  • Lists and key (#8) — rendering todos with map and using UUIDs as keys
  • Working with forms (#9) — controlled component pattern, checkboxes
  • useEffect (#10) — localStorage sync, focus on entering edit mode
  • Lifting state up (#11) — todos lives in TodoApp, children notify via callbacks
  • useContext (#12) — didn’t appear (prop passing is clearer at this scale)
  • Custom hooks (#13) — useLocalStorage
  • Performance optimization (#14) — intentionally not optimized (unnecessary at this scale)
  • Routing (#15) — single screen, so unused (would appear in a multi-page app)

Not every basic concept appears every time. What matters more is gradually building the sense of picking the right tool for the situation. Real skill is being able to judge “this fits better here” rather than trying to solve everything with one tool.

Recommended next steps #

If you’ve finished this series, one of the following is a good next move.

A. Build another small project (most recommended) #

Try building another small app yourself. A small tool useful in your own daily life is the best choice.

  • A workout tracker (compare against yesterday’s reps)
  • A habit tracker (a checkbox calendar)
  • A note app (markdown support, search)
  • A budget app (monthly totals)
  • A reading log (book + notes)

Rather than chasing features, finish the first version quickly → improve as you use it. That cycle accelerates learning.

B. Modern React 19 + Next.js series (planned) #

The next series on this blog will cover the latest patterns like Server Components / use() / Actions / Suspense. You’ll see how problems that pure client-side React can’t solve are addressed.

C. A bigger practical build (planned) #

A larger project that ties routing + state management + data fetching together — like a blog or an e-commerce app.

Wrap-up #

Thanks for following along this far. Starting from a single text input, we got through component decomposition → toggling → filtering → editing → persistence, ending with a small real-world app. Things that felt overwhelming at first probably became second nature as we added each layer.

React is just a tool. What we built may not be flashy, but the experience of turning an idea in your head into something with your hands is the biggest takeaway. Once that intuition takes root, you can pick up any library or framework quickly.

This post wraps up the Todo app build series.

X