How would you build concurrent progress bars for multiple tasks running in parallel?
Each task has its own progress (0–100). Track as `tasks: Task[]` with status (idle, running, done, error). Start all in parallel; update progress via callbacks or intervals; render a row per task with a progress bar. Cancel via AbortController. Cap concurrency with a scheduler if needed. Each progress bar memoized so unrelated updates don't re-render others.
Spec
- Show N tasks.
- Each runs in parallel.
- Each has its own progress 0–100% updated independently.
- Each can be canceled.
- Show overall completion.
Data shape
type Task = {
id: string;
label: string;
progress: number; // 0..100
status: 'idle' | 'running' | 'done' | 'error';
abort?: AbortController;
};Implementation
import { useState, useCallback } from 'react';
function ParallelTasks({ tasks: defs }: { tasks: { id: string; label: string; run: (signal: AbortSignal, onProgress: (p: number) => void) => Promise<void> }[] }) {
const [tasks, setTasks] = useState<Task[]>(() =>
defs.map((d) => ({ id: d.id, label: d.label, progress: 0, status: 'idle' })),
);
const updateTask = useCallback((id: string, patch: Partial<Task>) => {
setTasks((prev) => prev.map((t) => (t.id === id ? { ...t, ...patch } : t)));
}, []);
async function startAll() {
await Promise.all(defs.map(async (d) => {
const ac = new AbortController();
updateTask(d.id, { status: 'running', abort: ac, progress: 0 });
try {
await d.run(ac.signal, (p) => updateTask(d.id, { progress: p }));
updateTask(d.id, { status: 'done', progress: 100, abort: undefined });
} catch (err) {
if (err.name !== 'AbortError') updateTask(d.id, { status: 'error', abort: undefined });
}
}));
}
function cancel(id: string) {
const t = tasks.find((x) => x.id === id);
t?.abort?.abort();
updateTask(id, { status: 'idle', progress: 0, abort: undefined });
}
const overall = tasks.reduce((sum, t) => sum + t.progress, 0) / tasks.length;
return (
<div>
<button onClick={startAll}>Start all</button>
<p>Overall: {overall.toFixed(0)}%</p>
<div style={{ background: '#eee', height: 4 }}>
<div style={{ background: '#3b82f6', height: 4, width: `${overall}%` }} />
</div>
{tasks.map((t) => (
<TaskRow key={t.id} task={t} onCancel={() => cancel(t.id)} />
))}
</div>
);
}
const TaskRow = React.memo(({ task, onCancel }: { task: Task; onCancel: () => void }) => (
<div>
<span>{task.label} — {task.progress}% — {task.status}</span>
<div style={{ background: '#eee', height: 8 }}>
<div style={{ background: 'green', height: 8, width: `${task.progress}%` }} />
</div>
{task.status === 'running' && <button onClick={onCancel}>Cancel</button>}
</div>
));A sample task runner
function fakeUpload(signal: AbortSignal, onProgress: (p: number) => void) {
return new Promise<void>((resolve, reject) => {
let p = 0;
const id = setInterval(() => {
if (signal.aborted) { clearInterval(id); return reject(new DOMException('aborted', 'AbortError')); }
p += 10;
onProgress(p);
if (p >= 100) { clearInterval(id); resolve(); }
}, 200);
});
}Memoization
Each TaskRow is React.memo'd. When task A's progress updates, only task A's row re-renders — the others see the same reference (we updated a different element in the tasks array, but their per-task reference changes because we map. Refine by passing a stable reference: use a Map keyed on id.
Better:
const taskMap = useMemo(() => new Map(tasks.map(t => [t.id, t])), [tasks]);
// Or split each task into its own piece of state via useReducer.For real fine-grained control: each task gets its own component that owns its progress as local state, signaled by callbacks.
Concurrency cap
If N is huge, cap with a scheduler (see [[implement-an-async-scheduler-with-max-concurrency]]).
Edge cases
- Cancel mid-run — propagate
AbortSignalto the task; task must honor it. - Restart — preserve or reset progress per UX.
- One task errors — others continue; show per-row error.
- Unmount mid-run — abort all in cleanup.
Interview framing
"State is an array of tasks each with progress + status + AbortController. startAll kicks off Promise.all over the runners; each runner receives an AbortSignal and a progress callback. The callback updates only that task's row in state. Memo each TaskRow so unrelated progress updates don't re-render every row. Compute overall as the average of progresses. Cancel forwards ac.abort() to the task; the runner honors the signal and throws AbortError, which we treat as 'not really an error'. Cap concurrency with a scheduler if N is huge. Cleanup aborts all on unmount."
Follow-up questions
- •How do you cap concurrency?
- •What happens on unmount mid-run?
- •Why does each task row need to be memoized?
Common mistakes
- •Setting all task state in one big useState that re-renders everything.
- •Not propagating AbortSignal.
- •Treating AbortError as a failure.
Performance considerations
- •Memo + stable refs keep per-row re-render scoped. For thousands of tasks, virtualize.
Edge cases
- •Cancel during near-completion.
- •Concurrent restart.
- •Tasks with very different durations.
Real-world examples
- •Upload UIs (Dropbox, Google Drive), data import progress, build dashboards.