Our Restaurant TV Menu Never Showed Page 5: A Polling Refresh, a Rotating Carousel and Array Identity in React
We build a point-of-sale and QR menu product for cafes and restaurants. One small feature is a TV menu: a public URL that the restaurant types into the browser of the smart TV hanging in the dining room. It shows the who
We build a point-of-sale and QR menu product for cafes and restaurants. One small feature is a TV menu: a public URL that the restaurant types into the browser of the smart TV hanging in the dining room. It shows the whole menu in large type, product names on the left, prices on the right. No ordering, no clicks. Customers look at it and tell the waiter what they want.
Two requirements shaped the screen. First, the TV must never need a human. If the owner changes a price in the admin panel, the TV has to pick it up on its own, so the page polls the menu endpoint every 60 seconds. Second, the menu must never scroll. A cafe with 20 items gets one screen with big letters. A restaurant with 300 items gets split into pages, and the pages rotate every 15 seconds.
Both requirements worked in testing. Together they produced a bug that only shows up with a long menu: the TV never showed anything after page 4.
The setup
Stripped down, the component looked like this:
const [sections, setSections] = useState<Section[]>([]);
const [page, setPage] = useState(0);
// 1. Poll the menu every 60 s
useEffect(() => {
const load = async () => {
const menu = await api.fullMenu(slug);
setSections(menu.sections);
};
load();
const t = setInterval(load, 60_000);
return () => clearInterval(t);
}, [slug]);
// 2. Measure rendered heights, split into pages
const pages = useMemo(() => paginate(sections, columns, measurement),
[sections, columns, measurement]);
// 3. Data or layout changed: go back to the first page
useEffect(() => { setPage(0); }, [sections, columns, measurement]);
// 4. Rotate pages
useEffect(() => {
if (pages.length < 2) return;
const t = setInterval(() => setPage(p => (p + 1) % pages.length), 15_000);
return () => clearInterval(t);
}, [pages.length]);
Effect 3 is reasonable on its own. If the owner deletes a category and the menu shrinks from 9 pages to 6, the current page index might point past the end. Resetting to page 0 when the data changes is the simple, safe answer.
What actually happened
Every 60 seconds, effect 1 called setSections with a freshly parsed array from the API. The content was identical to the previous response, but the array was new. React compares dependencies with Object.is, so to React the sections had changed.
That set off a chain:
-
sectionschanged, so the measurement effect re-ran and produced a newmeasurementobject. -
sectionsandmeasurementchanged, so effect 3 ran and set the page back to 0. -
pages.lengthwas still 9, so the rotation interval was not restarted. It just kept ticking from wherever it was.
With a 15-second page duration and a 60-second refresh, the timeline looked like this:
| Time | What the TV shows |
|---|---|
| 0 s | page 1 |
| 15 s | page 2 |
| 30 s | page 3 |
| 45 s | page 4 |
| 60 s | refresh: back to page 1 |
| 75 s | page 2 |
A full tour of 9 pages takes 135 seconds. The refresh fired every 60. Pages 5 to 9 were never displayed. Ever. Small menus were fine because they fit on 4 pages or fewer, and so did our test data.
The bug was invisible in development for the same reason. Nobody sits and watches a carousel for two minutes. And when you reload the page to check something, you start at page 1 anyway, which looks correct.
Why the usual checks missed it
There was nothing to catch. No error, no warning, no failed request. The network tab showed a successful request every minute. React DevTools showed the component re-rendering once a minute, which is what polling is supposed to do. Each piece did what it was written to do. The bug lived in the interaction between two timers with different periods and a reset rule that treated "new object" as "new data".
For a restaurant, that means whatever sits at the end of the menu, often desserts and drinks, simply does not exist on the TV.
The fix: only touch state when the content changes
The fix was to stop handing React a new array when nothing had changed. We build a signature from the fields that actually affect what the TV draws and compare it with the last one:
const signatureRef = useRef<string>("");
const load = async () => {
const menu = await api.fullMenu(slug);
const next = menu.sections ?? [];
const signature = JSON.stringify(
next.map(s => [
s.id, s.name, s.image,
s.products.map(p => [
p.id, p.name, p.price, p.isAvailable, p.soldOut, p.description,
(p.priceOptions ?? []).map(o => [o.id, o.name, o.price]),
]),
])
);
if (signature !== signatureRef.current) {
signatureRef.current = signature;
setSections(next);
}
};
Now an unchanged response does nothing. No new array, no new measurement, no reset. The carousel keeps rotating through all 9 pages. When the owner changes a price, the signature changes, the menu re-renders with the new price and the reset to page 1 is the correct behaviour, because the layout may have changed.
A few notes on the choice:
- Why not compare the whole response? The API also returns things the TV ignores, such as timestamps and fields used by the ordering screen. Including them would make the signature change for reasons the customer cannot see. The signature lists exactly what is drawn.
-
Why
JSON.stringifyand not a deep-equal library? The menu is a few hundred products at most. Stringifying it once a minute costs less than a millisecond on a TV browser, and the code is obvious to the next person who reads it. - Why a ref and not state? The signature is bookkeeping. Storing it in state would itself cause a render.
The general lesson
The pattern is common: a polling hook or a WebSocket feed sets state on every message, and somewhere downstream an effect treats "the dependency changed" as "the data changed". In most components this costs an extra render and nobody notices. It becomes a bug when the downstream effect does something visible or stateful: resets a page, clears a form, scrolls to the top, restarts an animation, refetches something else.
What we check now when a component both polls and holds UI state:
- Does the fetch set state unconditionally? If so, ask whether an unchanged response should be a no-op. It usually should.
- Which effects depend on the polled data, and do any of them reset something? Reset effects are where identity changes turn into user-visible behaviour.
- Are there two timers with different periods? A refresh interval and a rotation interval, a refresh and a debounce, a refresh and a toast timeout. Write down the timeline for the longest realistic case, like we did in the table above. Ten seconds with a pencil would have found this bug.
- Test with production-sized data. A short test menu fits on a page or two. The bug needed more than four.
Libraries like TanStack Query help here because they keep referential identity for unchanged data through structural sharing. If you write the polling yourself, you get to do that part yourself.
A related design choice
The same screen does not hide sold-out products. They stay in place, dimmed, with a "SOLD OUT" label. Removing them would reflow the whole menu and move items between pages every time stock changed, which is the same kind of surprise from the customer's side: the thing you were reading a moment ago is somewhere else now.
We write about the products behind this, restaurant POS and QR menus among them, at alestaweb.com. The TV screen itself is one URL and a smart TV browser, which is about as low-tech as a feature gets. It still managed to hide more than half of a long menu.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.