Persistence: saving tasks across refreshes
Refresh the page and your task tracker forgets everything. That is not a bug, state lives in JavaScript memory, and a page load starts a fresh program. Every real app solves this, either on a server (Unit 7's fetch) or, for small personal data, right in the browser with localStorage: a built-in key-value store that survives refreshes and restarts. It stores only strings, so objects go through JSON.stringify on the way in and JSON.parse on the way out, the same serialization pair from Advanced JavaScript.
Two pieces wire it into React, and both are tools you already own:
Saving is a side effect, it touches the world outside rendering, so it belongs in useEffect, keyed on [tasks] so it re-runs exactly when tasks change:
useEffect(() => { localStorage.setItem("tasks", JSON.stringify(tasks)); }, [tasks]);
Loading happens once, as the initial state. useState accepts a function as its argument, called a lazy initializer. React calls it only on the first render. Why the function form? Because useState(JSON.parse(...)) would parse the string on every render and throw the result away after the first, the function form skips that wasted work:
const [tasks, setTasks] = useState(() => { const saved = localStorage.getItem("tasks"); return saved ? JSON.parse(saved) : []; });
Code exercise · javascript
The save/load round trip, with a plain object standing in for localStorage (the sandbox has no browser). setItem stores strings only, getItem returns null for missing keys, both true of the real API. Run it and follow a task through stringify and back through parse.
Loading defensively
There is a production lesson hiding in that load. JSON.parse throws on malformed input, and stored data goes bad in the real world: an old version of your app saved a different shape, the user edited it in devtools, an extension wrote over your key. If parsing crashes inside the initializer, the component never renders, your app is dead on arrival for exactly the users you cannot debug.
So production load code never trusts storage. It catches the parse error and falls back to a safe default, and it checks the parsed shape too, JSON.parse("{\"a\":1}") succeeds but is not the array your app expects:
function loadTasks(raw) { if (raw === null) return []; try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; } catch (e) { return []; } }
Worst case, the user loses a cached list. That is strictly better than an app that will not open.
Code exercise · javascript
Your turn. Implement loadTasks(raw) exactly as specified: null returns [], invalid JSON returns [] (try/catch), valid JSON that is not an array returns [], and a valid array comes back parsed. The four calls exercise all four cases.
Quiz
Why does the loading code use useState(() => JSON.parse(saved)) with a function, instead of useState(JSON.parse(saved))?
Quiz
The save effect is useEffect(() => localStorage.setItem("tasks", JSON.stringify(tasks)), DEPS). What should DEPS be, and why?