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

Building the interface and rendering from state

The markup, the rendering, the form and the list. By the end of this lesson the page works — in memory. Nothing survives a reload yet; that is the next lesson.

Run it after every step.

Step 1: the markup

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Tiffin order tracker</title>
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <header>
      <h1>Tiffin order tracker</h1>
      <p id="rates-status" role="status">Loading today's rates…</p>
    </header>

    <main>
      <section aria-labelledby="new-order-heading">
        <h2 id="new-order-heading">New order</h2>

        <form id="order-form" novalidate>
          <div class="field">
            <label for="customer">Customer name</label>
            <input type="text" id="customer" name="customer" autocomplete="name" />
            <p class="error" id="customer-error"></p>
          </div>

          <div class="field">
            <label for="plates">Plates</label>
            <input type="number" id="plates" name="plates" min="1" max="50" value="1" />
            <p class="error" id="plates-error"></p>
          </div>

          <div class="field">
            <label for="meal">Meal</label>
            <select id="meal" name="meal"></select>
            <p class="error" id="meal-error"></p>
          </div>

          <button type="submit">Add order</button>
        </form>
      </section>

      <section aria-labelledby="orders-heading">
        <h2 id="orders-heading">Orders</h2>
        <p id="list-status" role="status"></p>
        <ul id="orders"></ul>
        <p id="summary"></p>
      </section>
    </main>

    <script type="module" src="src/app.js"></script>
  </body>
</html>

Add the phone and pincode fields the same way — they are identical in shape and the lesson is shorter without them repeated.

Five things here are deliberate:

<meta name="viewport"> or the phone renders it as a zoomed-out desktop page. Without it none of the mobile work matters.

Every input has a <label for="...">. Not placeholder text — a real label. A placeholder disappears the moment you type, which is exactly when you need to check what the field was.

An empty <p class="error"> per field, ready to fill. The error appears beside the field it belongs to, not in an alert at the top.

novalidate turns off the browser's own popups while leaving the rules readable from JavaScript — module 7's forms lesson.

type="module", so imports work and the script defers automatically. Which means you must serve this over HTTP; double-clicking the file will not work.

The <select> is empty. It is filled from the rate card.

Step 2: formatting

src/format.js:

const rupees = new Intl.NumberFormat('en-IN', {
  style: 'currency',
  currency: 'INR',
});

export function formatPaise(paise) {
  return rupees.format(paise / 100);
}

Created once, at module level, not inside the function. A module is evaluated once, so the formatter is built once however many rows you render — building one per row is measurably slower.

Everything inside the application is paise. This is the only place that divides by 100, and it divides only to display.

Step 3: state

src/state.js — the whole of the application's memory:

let orders = [];
let meals = [];
let filter = 'all';
let query = '';

const listeners = new Set();

function changed() {
  for (const listener of listeners) listener();
}

export function subscribe(listener) {
  listeners.add(listener);
  return () => listeners.delete(listener);
}

export function getOrders() {
  return [...orders];
}

export function getMeals() {
  return [...meals];
}

export function setMeals(next) {
  meals = next;
  changed();
}

export function addOrder(order) {
  orders = [...orders, { ...order, id: crypto.randomUUID(), delivered: false }];
  changed();
}

export function toggleDelivered(id) {
  orders = orders.map((order) =>
    order.id === id ? { ...order, delivered: !order.delivered } : order,
  );
  changed();
}

export function removeOrder(id) {
  orders = orders.filter((order) => order.id !== id);
  changed();
}

Three things worth stopping on.

Every getter returns a copy. return orders would hand a caller your actual array, and getOrders().push(...) would then change state behind your back — module 3's rule, and the same defensive copy the closure counter needed.

Every update builds a new array rather than mutating: spread, map with a spread, filter.

changed() notifies subscribers instead of calling render directly. That is why state.js does not import render.js — the dependency points one way, and the store knows nothing about the DOM. It could be tested with no page at all.

Then the derived values — things you could store and should not, because a stored total goes stale:

export function mealById(id) {
  return meals.find((meal) => meal.id === id) ?? null;
}

export function orderTotalPaise(order) {
  const meal = mealById(order.meal);
  return (meal?.paise ?? 0) * order.plates;
}

export function visibleOrders() {
  const q = query.trim().toLowerCase();

  return orders.filter((order) => {
    if (filter === 'pending' && order.delivered) return false;
    if (filter === 'delivered' && !order.delivered) return false;
    if (q === '') return true;

    return (
      order.customer.toLowerCase().includes(q) ||
      order.pincode.includes(q) ||
      order.phone.includes(q)
    );
  });
}

meal?.paise ?? 0, not meal.paise || 0. ?. because the meal may have been removed from the rate card since the order was taken; ?? rather than || so a genuinely free meal priced at 0 paise is not replaced by the fallback.

Step 4: render

src/render.js:

function orderRow(order) {
  const item = document.createElement('li');
  item.className = 'order';
  item.dataset.id = order.id;
  if (order.delivered) item.classList.add('is-delivered');

  const toggle = document.createElement('input');
  toggle.type = 'checkbox';
  toggle.checked = order.delivered;
  toggle.dataset.action = 'toggle';

  const name = document.createElement('span');
  name.className = 'name';
  name.textContent = order.customer;

  const meal = mealById(order.meal);
  const detail = document.createElement('span');
  detail.className = 'detail';
  detail.textContent = [
    meal ? meal.name : 'Unknown meal',
    `${order.plates} plate${order.plates === 1 ? '' : 's'}`,
    order.pincode,
    formatPaise(orderTotalPaise(order)),
  ].join(' · ');

  const remove = document.createElement('button');
  remove.type = 'button';
  remove.dataset.action = 'remove';
  remove.textContent = 'Remove';

  item.append(toggle, name, detail, remove);
  return item;
}

textContent on every one. order.customer was typed by a person and meal.name came over the network. Module 7 established what innerHTML would cost here, and the last lesson of this module has you prove it.

The plate${order.plates === 1 ? '' : 's'} is module 2's ternary earning its place — "1 plates" is the kind of detail that makes a page feel unfinished.

Then the whole list:

export function render() {
  listEl.textContent = '';

  const shown = visibleOrders();
  const all = getOrders();

  for (const order of shown) {
    listEl.append(orderRow(order));
  }

  if (all.length === 0) {
    statusEl.textContent = 'No orders yet. Add the first one above.';
  } else if (shown.length === 0) {
    statusEl.textContent = 'No orders match that search.';
  } else {
    statusEl.textContent = '';
  }

  const { revenuePaise, pending, count } = totals();
  summaryEl.textContent =
    count === 0
      ? ''
      : `${shown.length} of ${count} shown · ${pending} pending · ${formatPaise(revenuePaise)} total`;
}

Two empty states, not one. "No orders yet" and "nothing matched your search" are different situations needing different words — and a reader who sees "no orders yet" while a filter is active will think their data is gone.

The revenue is over all, the count over shown. Filtering the view must not change the day's takings.

Step 5: the form

src/app.js:

form.addEventListener('submit', (event) => {
  event.preventDefault();
  clearErrors();

  const raw = Object.fromEntries(new FormData(form));
  const mealIds = getMeals().map((meal) => meal.id);

  const result = validateOrder(raw, mealIds);

  if (!result.ok) {
    showErrors(result.errors);
    return;
  }

  addOrder(result.value);
  form.reset();
  document.querySelector('#customer').focus();
});

preventDefault first. Then validate, and either show errors or add — never both, and never neither.

validateOrder returns { ok, errors, value } rather than throwing, because a mistyped pincode is an expected outcome and not an exceptional one — module 5's distinction. The value it returns has plates as a number; that is the boundary, and everything after it can do arithmetic without thinking.

form.reset() then focus(), so Anna can type the next order without reaching for the mouse.

Step 6: the list, with one listener

listEl.addEventListener('click', (event) => {
  const control = event.target.closest('[data-action]');
  if (!control) return;

  const row = control.closest('.order');
  if (!row) return;

  if (control.dataset.action === 'toggle') toggleDelivered(row.dataset.id);
  if (control.dataset.action === 'remove') removeOrder(row.dataset.id);
});

One listener for every row, including rows that do not exist yet. Which matters more here than in module 7, because render destroys and rebuilds every row on every change — per-row listeners would be re-attached constantly.

Two closest calls with two guards: which control, and which row. A click on the list's padding does nothing.

Step 7: wire it up

subscribe(render);

One line, and it is the architecture. Anything that changes state calls changed(), which calls render. No update function touches the DOM, so the page can never disagree with the data.

Give it some meals to render with, temporarily:

setMeals([
  { id: 'veg', name: 'Veg thali', paise: 8000 },
  { id: 'special', name: 'Special thali', paise: 12000 },
]);

The next lesson replaces that with a real fetch.

What you should see

Serve it and add an order for 3 veg thalis:

Priya Sharma
Veg thali · 3 plates · 411014 · ₹240.00
1 of 1 shown · 1 pending · ₹240.00 total

Add a second for 2 specials and the total is ₹480.00. Tick the first and the pending count drops to 1 while the total stays the same — delivered orders are still revenue.

Check your work

The <select> starts empty and is filled from the rate card.

novalidate keeps the browser's popups away but leaves the rules readable.

The Intl formatter is created once at module level, not per row.

Every state getter returns a copy, or a caller could mutate your array.

state.js does not import render.js. It notifies subscribers, so the store knows nothing about the DOM and the imports point one way.

meal?.paise ?? 0 — ?. for a meal no longer on the rate card, ?? so a free meal at 0 paise survives.

Two empty states: no orders at all, and no orders matching the search.

Revenue is over all orders; the count is over the visible ones.

Validation returns { ok, errors, value } rather than throwing, and value has plates as a number.

One delegated listener, because render rebuilds every row.

subscribe(render) is the whole architecture — nothing else writes to the page.

Three veg thalis is ₹240.00; adding two specials makes ₹480.00. Marking one delivered changes the pending count and not the total.

Practice

  1. Build it step by step, serving it over npx serve, running after each step.
  2. Open it by double-clicking index.html once, read the error, and go back to the server.
  3. Add the phone and pincode fields following the same pattern.
  4. Add two orders and check the totals by hand.
  5. Break the architecture on purpose: make removeOrder call row.remove() directly instead of updating state. It looks right — until you change a filter and the row comes back. Put it back and say what the rule bought you.
  6. Make getOrders return orders instead of a copy, then call getOrders().push({}) from the console and watch the summary go wrong.
  7. Delete one of the two closest guards and click the list's padding.
  8. Change the ternary so it always says "plates" and add a one-plate order. Note how much cheaper it is to fix than to notice.
  9. Remove subscribe(render) and add an order. The state updates and the page does not — check with getOrders() in the console. This is the clearest possible demonstration that the two are separate.
  10. Harder. Add a sort: by customer name, or by value, highest first. Keep the sort in state, sort a copy rather than mutating, and use localeCompare from module 2 for names so capitals do not sort first.

Next: the data layer — fetching the rate card, surviving a reload, and handling every way both can fail.

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