Interview question
Render complete data-page states
Uses an explicit state model for initial loading, success, empty, recoverable error, and refetching.
TL;DR
Uses an explicit state model for initial loading, success, empty, recoverable error, and refetching.
Async state modeling, empty states, retries, stale-data refetching, and layout stability.
Practice the problem like a real interview: restate, reason, implement, and test.
Build a DrillListPage around a useDrills() query result. The page must represent initial loading, initial failure, successful empty data, populated data, background refresh, and refresh failure as distinct user-visible states. A retry command must work after either kind of failure, and previously loaded rows must remain usable while a refresh is running or fails.
On the first request the page reserves the list area with a skeleton. If three drills load, those rows appear. Pressing Refresh keeps all three rows visible, adds a small Refreshing drills status, and disables only the refresh command. If that request fails, the rows remain and an inline notice says Refresh failed; showing the last successful result with a Retry command. A successful response containing [] renders the real empty state, never a spinner or error placeholder.
data === undefined means no successful snapshot exists; [] is successful empty data.aria-busy and a polite status message.refetch operation and must not create duplicate requests while one is active.data.length; neither branch is replaced during isFetching.refetch command for initial and background recovery.type Drill = { id: string; title: string };
type DrillQuery = {
data: Drill[] | undefined;
error: Error | null;
isLoading: boolean;
isFetching: boolean;
refetch: () => Promise<unknown>;
};
function DrillListPage({ query }: { query: DrillQuery }) {
const { data, error, isLoading, isFetching, refetch } = query;
const hasSnapshot = data !== undefined;
if (!hasSnapshot && isLoading) {
return (
<main aria-busy="true" aria-label="Drills">
<h1>Drills</h1>
<DrillListSkeleton rows={5} />
<span className="sr-only" role="status">Loading drills</span>
</main>
);
}
if (!hasSnapshot) {
return (
<main aria-label="Drills">
<h1>Drills</h1>
<ErrorState
title="Drills could not be loaded"
message={error?.message ?? "Try the request again."}
onRetry={() => void refetch()}
retryDisabled={isFetching}
/>
</main>
);
}
const isEmpty = data.length === 0;
return (
<main aria-label="Drills" aria-busy={isFetching}>
<header className="list-header">
<h1>Drills</h1>
<button
type="button"
disabled={isFetching}
onClick={() => void refetch()}
>
{isFetching ? "Refreshing..." : "Refresh"}
</button>
</header>
<div className="list-status" aria-live="polite">
{isFetching ? <span role="status">Refreshing drills</span> : null}
{error ? (
<InlineNotice>
Refresh failed; showing the last successful result.
<button type="button" onClick={() => void refetch()}>
Retry
</button>
</InlineNotice>
) : null}
</div>
{isEmpty ? (
<EmptyState title="No drills match this view" />
) : (
<ul>
{data.map((drill) => (
<li key={drill.id}><DrillRow drill={drill} /></li>
))}
</ul>
)}
</main>
);
}
The query library may expose different names, but the important contract is unchanged: the absence of a successful snapshot controls the blocking states, while fetch activity and refresh errors decorate an existing snapshot.
Rendering the rows is O(n) time and O(n) UI space. The state derivation itself is constant work. Keeping stale data visible favors continuity and is usually appropriate for browse screens, but it is not correct when old data would authorize an action, display a critical balance, or violate a consistency requirement. In those cases the product may deliberately block interaction or label the snapshot with its age. Skeleton dimensions improve visual stability, but they should approximate the real layout rather than imitate every pixel.
data: undefined, isLoading: true and assert the skeleton and loading status, not the empty state.[] and assert the genuine empty message.aria-busy becomes true, and Refresh is disabled.refetch is called once while the current request is active.undefined and [] were treated as the same state.isFetching was used as a full-page loading condition instead of distinguishing initial load.Next in Practical UI Labs: Search Race Control