The data layer, and surviving a reload
The page works and forgets everything when you close it. This lesson gives it a memory and a rate card — and handles every way both can fail, which is most of the work and all of the difference.
Step 1: storage that cannot crash the page
src/storage.js:
export function save(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch {
return false;
}
}
export function load(key, fallback) {
try {
const text = localStorage.getItem(key);
if (text === null) return fallback;
const parsed = JSON.parse(text);
return Array.isArray(parsed) ? parsed : fallback;
} catch {
return fallback;
}
}
Four failures, one small function.
The key was never set — getItem gives null, so return the fallback before
parsing.
The text is not JSON — JSON.parse throws, and the catch turns it into the
fallback.
It is valid JSON of the wrong shape — {} parses perfectly and then breaks
every .filter downstream. Array.isArray is the guard, and it is the one
people leave out.
localStorage itself throws — the quota is full, or a privacy setting blocks
it. Reading can throw, not just writing.
The page must work when load returns the fallback. Storage is an
optimisation, not a foundation.
Step 2: hook it into state
Two changes in src/state.js:
import { load, save } from './storage.js';
const STORAGE_KEY = 'tiffin-orders';
let orders = load(STORAGE_KEY, []);
function changed() {
save(STORAGE_KEY, orders);
for (const listener of listeners) listener();
}
That is the entire persistence layer. Because every update already went
through changed(), saving happens in exactly one place — and it is impossible
to add a new update that forgets to save.
That is what the discipline from the last lesson bought you. If addOrder and
removeOrder had each written to the DOM directly, you would now be adding a
save call to each of them and missing one.
Reload. The orders are there.
Step 3: the rate card
Prices should not be buried in code. data/rates.json:
{
"updated": "2026-09-28",
"meals": [
{ "id": "veg", "name": "Veg thali", "paise": 8000 },
{ "id": "jain", "name": "Jain thali", "paise": 9000 },
{ "id": "special", "name": "Special thali", "paise": 12000 }
]
}
Prices in paise, as integers. ₹80.00, not 80.0 — no decimal ever enters the
system.
Step 4: fetchJson
src/api.js — module 8's lesson as one function:
export async function fetchJson(url, { timeout = 5000 } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
let response;
try {
response = await fetch(url, { signal: controller.signal });
} catch (error) {
if (error.name === 'AbortError') {
throw new TimeoutError('The server took too long.');
}
throw new NetworkError('Could not reach the server.');
} finally {
clearTimeout(timer);
}
if (!response.ok) {
throw new HttpError(response.status, `Request failed with ${response.status}`);
}
try {
return await response.json();
} catch {
throw new HttpError(response.status, 'The server sent something that was not JSON.');
}
}
Only the fetch call is in the first try — wrapping the whole function
would catch the errors it throws itself and relabel them as network failures.
finally clears the timer on every path, or a fast response leaves a timer
running that aborts a later request. That is a genuinely baffling bug when it
happens.
ok is checked before reading the body, because a 404 usually returns HTML
and you would get Unexpected token '<' instead of the real problem.
response.json() gets its own try — a 200 with a truncated body is real.
Then the specific loader:
export async function loadRates(url = 'data/rates.json') {
const data = await fetchJson(url);
const meals = Array.isArray(data?.meals) ? data.meals : [];
if (meals.length === 0) {
throw new HttpError(200, 'The rate card has no meals in it.');
}
return meals;
}
A 200 with an unusable body is still a failure. data?.meals guards the
shape; an empty rate card means no orders can be taken, so it throws rather than
letting the page look fine with an empty dropdown.
Step 5: messages
src/errors.js, alongside the three error classes:
export function messageFor(error) {
if (error instanceof TimeoutError) return 'The rate card took too long to load.';
if (error instanceof NetworkError) return 'Could not reach the server. Check your connection.';
if (error instanceof HttpError) {
if (error.status === 404) return 'Today’s rate card is missing.';
if (error.status >= 500) return 'The server is having trouble. Try again shortly.';
return 'Something was wrong with that request.';
}
return 'Something went wrong loading the rates.';
}
Each says what happened in the user's terms. The last one catches the error you did not predict — which is the one that will happen.
Step 6: startup
async function start(url) {
ratesStatus.textContent = 'Loading today’s rates…';
submitButton.disabled = true;
try {
setMeals(await loadRates(url));
renderMealOptions();
ratesStatus.textContent = '';
submitButton.disabled = false;
} catch (error) {
console.error(error);
ratesStatus.textContent = messageFor(error);
}
render();
}
start().catch(console.error);
This is the most important decision in the lesson.
When the rate card fails, the page does not break. Existing orders still render, from storage, with their meal names resolved as far as they can be. Only adding is disabled, and the message says why.
That is graceful degradation: Anna can still see and mark off today's deliveries on a train with no signal. A page that showed an error and nothing else would be useless at exactly the moment she needed it.
console.error(error) logs the real one; the user sees the friendly one.
.catch(console.error) on the call because start is async and nothing
else is watching its promise — module 8's third escape route.
Step 7: break it, deliberately
This is the step that makes the previous six worth writing. Point start at
each of these and check the page.
| Rate card URL | Expect | Orders still shown? |
|---|---|---|
data/missing.json |
"Today's rate card is missing." | Yes |
index.html |
"Something was wrong with that request." | Yes |
http://localhost:9/nope |
"Could not reach the server. Check your connection." | Yes |
A file with "meals": [] |
"The rate card has no meals in it." | Yes |
| Normal, with a 50ms timeout | "The rate card took too long to load." | Yes |
Then storage:
Open the Application panel, replace tiffin-orders with {not an array, and
reload. The page must load, empty and usable, with the form working. If it
shows a blank screen, load is wrong — and note that a crash on load is
uniquely bad, because the user cannot even clear the broken data.
Verified on this code: every row above gives its own message and the orders keep rendering, and the corrupted value loads as an empty list with the form still usable.
Step 8: what localStorage is not
Worth saying in the project, not just in module 7.
It is one browser on one device. Anna's phone and her laptop have separate data. There is no sync, and clearing site data wipes it.
It is not secure. Anyone with the phone can read and edit it in devtools. For a tiffin list that is fine. For anything with money or identity in it, it is not — and this is where the honest limit of a front-end-only application sits.
It is synchronous, so it blocks. Saving the whole array on every keystroke of
a search box would stutter on a mid-range phone. We save on change, not on
render, which is why changed() is the right place and render() would not be.
About 5MB. Thousands of orders would need a real backend.
The next step for this application is a server — which is the Full-Stack course, and you now know exactly which problems it would solve.
Check your work
load handles four failures: never set, not JSON, valid JSON of the wrong
shape, and localStorage throwing. Array.isArray is the one people omit.
Persistence is two lines because every update already funnels through
changed() — so a new update cannot forget to save.
Prices are integers in paise in the JSON, never decimals.
Only the fetch call is inside the first try; finally clears the timer
on every path; ok is checked before the body; json() gets its own try.
A 200 with an empty meals array is a failure, because no orders can be
taken.
When the rate card fails, existing orders still render and only adding is disabled. That is the difference between degraded and broken.
console.error for you, messageFor for the user.
start().catch(console.error), because nothing else watches an async
function's promise.
Corrupted storage loads as an empty list with a working form — never a blank screen.
localStorage is one device, not secure, synchronous, and about 5MB.
Practice
- Add
storage.jsand wire it intochanged(). Reload and confirm the orders survive. - Remove the
Array.isArraycheck, set the stored value to{}in devtools, and reload. Read the error. Put the check back. - Add
data/rates.jsonand load it withfetchJson. Confirm the dropdown fills. - Work through every row of the step 7 table. This is the lesson.
- Corrupt the stored orders and confirm the page still loads.
- Set the timeout to 50ms and confirm you get the timeout message rather than the network one.
- Remove the
finallythat clears the timer. Load the rates, wait six seconds, then trigger another request and watch the stale timer abort it. - Move
save()fromchanged()intorender(), then type in the search box and watch it save on every keystroke. Explain why that is wrong on two counts. - Add a "last updated" line using the
updatedfield from the rate card, formatted with module 6'sformatDate— and make sure it is built from local parts, nottoISOString. - Harder. Add a Retry button that appears only for retryable failures — network, timeout and 5xx, but not 404 or an empty rate card. Then add a single automatic retry with a short backoff before the button appears, and confirm in the Network panel that a 404 is tried exactly once.
Next: polish and deploy — validation messages, keyboard and screen-reader access, the mobile layout, and a live URL.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship