Open this lesson in your favourite AI. It'll walk you through the why, explain the demo, and quiz you on the try-it list.
Not every async operation needs the same feedback treatment. The research-backed guidance from Jakob Nielsen is a three-band model: under 100ms feels instant and needs nothing; 100–500ms can proceed silently; 500ms–2s warrants a subtle signal (a spinner, a pulsing border) so the user knows something is happening; over 2 seconds requires explicit progress indication with an estimated timeline if possible. In optimistic UI, operations under 500ms usually need no feedback at all because the visual update is already showing. Operations that take longer — a file upload, a complex server-side operation — need the isPending pattern: a way to signal that the mutation is still in flight without blocking the UI.
A component that uses useEffect to progressively disclose feedback based on elapsed time. Under 500ms: nothing. 500ms–2s: a subtle spinner appears. Over 2s: a full progress message.
300ms and verify that neither the spinner nor the progress message appears — confirm the operation completes before the 500ms threshold fires.1200ms and verify only feedbackLevel 1 (the spinner) appears, not the full progress message.6000ms.finally block prevents the spinner from briefly flashing when a fast (200ms) operation completes just before the 500ms timer fires.Use these three in order. Each builds on the one before.
In one paragraph, explain the three time-bands that Nielsen identified for UI feedback and what each band implies for how you should respond to an in-progress operation.
Walk me through how `useRef` prevents stale closures when clearing `setTimeout` IDs inside a `finally` block in an async React handler.
Design a reusable `useProgressiveFeedback(isPending)` hook that encapsulates the three-band timer logic and returns a `feedbackLevel` value — describe its API and internal implementation.
import { useState, useEffect, useRef } from "react";
export function ProgressiveButton() {
const [isPending, setIsPending] = useState(false);
const [feedbackLevel, setFeedbackLevel] = useState<0 | 1 | 2>(0);
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
function clearTimers() {
timers.current.forEach(clearTimeout);
timers.current = [];
}
async function handleAction() {
setIsPending(true);
setFeedbackLevel(0);
// after 500ms: show subtle spinner
timers.current.push(setTimeout(() => setFeedbackLevel(1), 500));
// after 2000ms: show explicit progress
timers.current.push(setTimeout(() => setFeedbackLevel(2), 2000));
try {
await new Promise((r) => setTimeout(r, 2500)); // simulate slow op
} finally {
clearTimers();
setIsPending(false);
setFeedbackLevel(0);
}
}
useEffect(() => () => clearTimers(), []);
return (
<div>
<button onClick={handleAction} disabled={isPending}>
Save
</button>
{feedbackLevel === 1 && <span style={{ marginLeft: 8 }}>Saving...</span>}
{feedbackLevel === 2 && <p>Still saving, almost done...</p>}
</div>
);
}