Course outline · 0% complete

0/28 lessons0%

Course overview →

Persistence: saving tasks across refreshes

lesson 9-4 · ~12 min · 28/28

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) : [];
});

The save and load round trip

A plain object stands in for localStorage, since this example is plain JavaScript rather than a page in a browser, and the stand-in copies the two behaviors that matter.

const storage = {};
function setItem(key, value) { storage[key] = String(value); }
function getItem(key) { return key in storage ? storage[key] : null; }

const tasks = [{ id: 1, title: "Water plants", done: true }];
setItem("tasks", JSON.stringify(tasks));

const loaded = JSON.parse(getItem("tasks"));
console.log(loaded.length + " task(s), first: " + loaded[0].title + ", done: " + loaded[0].done);
console.log(getItem("missing"));

Output

1 task(s), first: Water plants, done: true
null

JSON.stringify turns the array into one string for storage, and JSON.parse rebuilds real objects from it. The done: true in the output is a genuine boolean rather than the text "true", which is what proves the round trip restored types rather than just characters.

setItem coerces its value with String, exactly as the real API does, which is the reason the stringify step is mandatory rather than stylistic. Passing the array directly would store "[object Object]", and the parse on the way back would throw.

getItem returns null when nothing was ever saved, which is why the lazy initializer needs its fallback to []. That null is the first-visit case for every user, so it is the most common path through the load code rather than an edge case.

Note that the restored objects are new objects, equal in content and not identical to the originals. Nothing in the app depends on that here, and it is the reason a saved-and-reloaded list can never share references with anything still in memory.

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.

A defensive loader

loadTasks(raw) covers all four situations storage can hand you, and the four calls exercise each one.

function loadTasks(raw) {
  if (raw === null) return [];
  try {
    const parsed = JSON.parse(raw);
    return Array.isArray(parsed) ? parsed : [];
  } catch (e) {
    return [];
  }
}

console.log(loadTasks(null).length);
console.log(loadTasks("not json{").length);
console.log(loadTasks("{\"id\":1}").length);
console.log(loadTasks("[{\"id\":1,\"title\":\"Pay rent\",\"done\":false}]")[0].title);

Output

0
0
0
Pay rent

The null check comes first because JSON.parse(null) does not throw, it returns null, and the array check further down would then be the thing saving you. Handling it up front makes the common first-visit path explicit.

JSON.parse wrapped in try/catch handles the second case, where the stored string is not valid JSON at all. That happens more often than it sounds, since a truncated write or a different app writing the same key both produce garbage.

The third case is the sneaky one. {"id":1} parses fine and is not an array, so .length would be undefined and every later .map and .filter would throw, which is why Array.isArray matters as much as the try/catch.

InputPath takenResult
nullfirst guard[]
"not json{"catch[]
{"id":1}shape check[]
a valid arrayhappy paththe parsed array

Every failure route returns the same safe default, so the worst outcome is a user losing a cached list rather than an app that will not open. That tradeoff is the whole argument for writing the loader this way, and it is the same guard-clause habit as validating a form before submitting it.

Why the initializer is a function

useState(JSON.parse(saved)) evaluates the parse on every render and discards the result after the first, while the lazy initializer runs only once, at mount.

Arguments are evaluated before a function is called, so the direct form parses the whole saved list on every single render even though React only reads the value once. Passing a function lets React defer and call it exactly once, on the first render.

The waste scales with the data, which is what makes it worth caring about here. A hundred stored tasks means re-parsing a hundred tasks on every keystroke in the input, and none of that work reaches the screen.

For cheap initial values like 0 or "", either form is fine, and the function form buys nothing. The rule of thumb is to reach for it when the initial value comes from parsing, reading storage, or any computation you would not want repeated.

Note that the lazy initializer is called with no arguments and must return the initial value, so () => [] and [] mean the same thing. Passing a function you meant as the value is the one trap, and React would call it and store the return, which matters if you ever want state that holds a function.

The save effect's dependency array

DEPS should be [tasks], because the effect reads tasks, so it re-runs exactly when tasks change, saving each add, toggle, and delete.

That is the golden rule from lesson 7-1, where every component value the effect reads goes in the array. The effect reads tasks and nothing else, so [tasks] is both necessary and sufficient.

The two wrong choices fail in opposite directions. [] would save the initial list once and never again, so every change would be lost on refresh, and omitting the array entirely would save on unrelated renders such as typing in the input, writing the same string to storage repeatedly.

Because tasks is compared by identity, the immutable helpers from lesson 9-2 are what make this fire correctly. Each one returns a new array, so Object.is sees a change, and a mutating version would leave the reference identical and skip the save.

DEPSBehavior
[tasks]saves after every real change
[]saves the empty list once
omittedsaves on every render, including unrelated ones

One detail is worth expecting on the very first render. The effect runs at mount and writes whatever the initializer loaded, which is a harmless rewrite of the same data, and it is why saving an empty list over a nonexistent key is not a problem.