Sequential Progress Bars
by Pratik Rai
Read the full write-upClicking Add appends a progress bar. The bars fill one at a time, in the order they were created, and each takes about two seconds to reach 100%.
The markup and styling are done — the queueing is what is missing.
Requirements
Clicking Add appends a new bar at 0%.
Only one bar fills at any moment; the next starts when the current one reaches 100%.
Each bar goes from 0 to 100 in roughly 2000ms.
Add can be pressed at any time, including while a bar is filling — new bars queue behind the ones already waiting.
Each bar shows its current percentage.
Notes
The percentage is already rendered from state, so you only have to move the numbers.
Pressing Add ten times should take about twenty seconds in total, not two.
Hints
Sequential Progress Bars (reference solution)
One way to do it, not the only way. Yours passes if it renders.
Solution files
1import { useState, useEffect } from 'react';
2
3const DURATION_MS = 2000;
4const TICK_MS = 100;
5
6export default function App() {
7 const [bars, setBars] = useState([]);
8
9 // Derived, not stored: one boolean the effect can depend on. Depending on
10 // `bars` itself would tear the interval down and rebuild it on every tick.
11 const hasUnfinished = bars.some((percent) => percent < 100);
12
13 // One interval, driving whichever bar is currently unfinished.
14 //
15 // The alternative — a timer per bar, started when the bar is added — is what
16 // makes this problem interesting to get wrong: every bar then fills at once,
17 // because nothing tells a new timer to wait for the ones before it.
18 useEffect(() => {
19 // Nothing to advance, so no timer runs while the component idles.
20 if (!hasUnfinished) return undefined;
21
22 const id = setInterval(() => {
23 setBars((current) => {
24 // The queue is implicit: the first bar below 100 is the active one,
25 // and it is always the oldest unfinished bar because they only ever
26 // get appended.
27 const active = current.findIndex((percent) => percent < 100);
28 if (active === -1) return current;
29
30 const step = (100 * TICK_MS) / DURATION_MS;
31 const next = current.slice();
32 next[active] = Math.min(100, next[active] + step);
33 return next;
34 });
35 }, TICK_MS);
36
37 return () => clearInterval(id);
38 }, [hasUnfinished]);
39
40 return (
41 <div className="panel">
42 <h1>Progress bars</h1>
43
44 <button className="add" onClick={() => setBars((current) => [...current, 0])}>
45 Add
46 </button>
47
48 {bars.length === 0 ? (
49 <p className="empty">No bars yet. Press Add.</p>
50 ) : (
51 <div className="bars">
52 {bars.map((percent, index) => (
53 <div className="track" key={index}>
54 <div className="fill" style={{ width: percent + '%' }} />
55 <span className="label">{Math.round(percent)}%</span>
56 </div>
57 ))}
58 </div>
59 )}
60 </div>
61 );
62}How it works
The instinct is to give each bar its own timer when it is added. That produces bars filling in parallel, and no amount of tweaking the duration fixes it — the bug is that nothing tells a new timer to wait.
Inverting it fixes the problem completely: one interval, and on each tick it finds the active bar and nudges only that one. Because bars are appended and never reordered, "the first bar below 100%" is the front of the queue, so the data structure you already have is the queue. No separate list of pending ids is needed.
The effect returns early when everything is finished, which matters for two reasons: an interval ticking against an idle component is wasted work, and without the guard the effect tears down and rebuilds on every state change forever.
The follow-up interviewers reach for is a concurrency limit — fill three at a time instead of one. That is a small change here (advance the first n unfinished bars per tick) and a rewrite if you went with a timer per bar.