RizTech Academy logo
RizTech Academy
Capstone: a tiffin service order trackerLesson 3 of 445 min

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

  1. Add storage.js and wire it into changed(). Reload and confirm the orders survive.
  2. Remove the Array.isArray check, set the stored value to {} in devtools, and reload. Read the error. Put the check back.
  3. Add data/rates.json and load it with fetchJson. Confirm the dropdown fills.
  4. Work through every row of the step 7 table. This is the lesson.
  5. Corrupt the stored orders and confirm the page still loads.
  6. Set the timeout to 50ms and confirm you get the timeout message rather than the network one.
  7. Remove the finally that clears the timer. Load the rates, wait six seconds, then trigger another request and watch the stale timer abort it.
  8. Move save() from changed() into render(), then type in the search box and watch it save on every keystroke. Explain why that is wrong on two counts.
  9. Add a "last updated" line using the updated field from the rate card, formatted with module 6's formatDate — and make sure it is built from local parts, not toISOString.
  10. 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