React Fetch and Render a List with Loading, Error and Empty States
Requirements
Render a data-source select ( .mode) with the options ok, empty and flaky, and keep the chosen mode in state.Call the provided fetchTeam(mode)fake API on mount and whenever the mode changes.Hold the request in one TypeScript union: loading, success with the members, empty, or error with a message. Show Loading team…while a request is in flight.Render one .user-rowper member with the name and role on success.Show No team members yet.when the API returns an empty list.Show a readable error line and a .retrybutton when the API rejects; Retry runs the request again with the current mode.Ignore a response that arrives after the mode has changed again.
Expected behavior
On first render the loading state is visible, then six rows appear. Choosing Empty team shows the empty state and no rows. Choosing Flaky network shows the error line with a Retry button; clicking Retry loads the six rows. Switching the source immediately shows the loading state and never shows rows from the previous source. No anytypes: the fake API response, the request union and the select handler are typed.
Implementation notes
Use a discriminated union such as { status: 'loading' } | { status: 'success'; members: TeamMember[] } | { status: 'empty' } | { status: 'error'; message: string }instead of parallel booleans.Put the request in a useEffectthat depends onmodeand a retry counter; set the loading state at the top of the effect.Use a cancelledflag in the effect cleanup so a stale response cannot overwrite a newer state.Convert unknown errors to a message with an instanceof Errorcheck.
Solution idea
Keep mode, a retry attempt counter and one request union in state. A useEffect that depends on [mode, attempt] sets { status: 'loading' }, calls fetchTeam(mode) and maps the result: a non-empty list becomes success, an empty list becomes empty, a rejection becomes error with a readable message. The cleanup flips a cancelled flag so a response from a previous mode is ignored.
Public interview reports
These public, self-reported interview accounts informed this practice version. The exercise is modelled on reported rounds, not copied from them, and the reports are not verified official questions or endorsements.
- Taro: Revolut frontend developer interview, April 2025
One mid-level account describes a one-hour TypeScript live-coding task: fetch data from a source and render it with a loading state, error handling and types; the feedback named loading-state handling. This exercise is modelled on that round, not copied from it.
- Taro: Revolut frontend engineer interview, May 2025
One account describes a pairing session that used a service API with authentication to display data in a React component. This exercise practises the same fetch-and-render loop against a fake API; it is not the reported task.
Solution
Overview
Keep mode, a retry attempt counter and one request union in state. A useEffect that depends on [mode, attempt] sets { status: 'loading' }, calls fetchTeam(mode) and maps the result: a non-empty list becomes success, an empty list becomes empty, a rejection becomes error with a readable message. The cleanup flips a cancelled flag so a response from a previous mode is ignored.
Render from the status alone: one status line, rows only on success, the empty paragraph only on empty, and the Retry button only on error. Retry increments attempt, which re-runs the effect without duplicating the fetch logic.
**Test cases**
- Initial render shows the loading line and then six
.user-rowitems. - Empty team shows
No team members yet.and no rows. - Flaky network shows the error line and a Retry button; Retry loads the rows.
- Switching the source shows the loading line first and never leaks rows from the previous source. Key concepts: react, state, effects.
One status union driven by a mode-and-attempt effect
Keep three pieces of state: mode from the select, an attempt counter for Retry, and request, a discriminated union with loading, success, empty and error members.
In a useEffect that depends on [mode, attempt]:
- Set
requestto{ status: 'loading' }so the previous rows disappear immediately. - Call
fetchTeam(mode). On success, store{ status: 'success', members }when the list has items and{ status: 'empty' }otherwise. On rejection, store{ status: 'error', message }after normalising the unknown error. - Return a cleanup that sets a
cancelledflag; both handlers return early when it is set, so a slow response from the previous mode never overwrites the newer state.
The JSX switches on request.status: one status line, the Retry button only on error, the list only on success, the empty paragraph only on empty. Retry increments attempt, which re-runs the same effect.
Files
{
"name": "react-fetch-list-states",
"version": "0.1.0",
"private": true,
"dependencies": {
"react": "18.x",
"react-dom": "18.x",
"react-scripts": "5.0.1"
},
"scripts": { "start": "react-scripts start" }
}
{
"compilerOptions": {
"jsx": "react-jsx",
"target": "ES2020",
"module": "ESNext",
"strict": true
}
}
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>React SDK • Team Directory</title></head><body><div id="root"></div></body></html>:root {
color-scheme: dark;
}
html, body {
margin: 0;
height: 100%;
background: #020617;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
}
.main {
min-height: 100vh;
background: #020617;
color: #e5e7eb;
display: grid;
place-items: center;
padding: 24px;
}
.card {
width: 100%;
max-width: 640px;
background: #020617;
border-radius: 16px;
border: 1px solid #1f2937;
padding: 18px 18px 16px;
display: grid;
gap: 16px;
}
.header {
display: grid;
gap: 4px;
}
.title {
margin: 0;
font-size: 20px;
font-weight: 700;
}
.subtitle {
margin: 0;
font-size: 13px;
color: #9ca3af;
}
.field {
display: grid;
gap: 6px;
}
.label {
font-size: 13px;
color: #9ca3af;
}
.mode {
width: 100%;
border-radius: 10px;
border: 1px solid #374151;
background: #020617;
color: #e5e7eb;
padding: 8px 12px;
font-size: 14px;
outline: none;
}
.mode:focus {
border-color: #a3e635;
box-shadow: 0 0 0 1px #a3e63533;
}
.status-row {
min-height: 18px;
font-size: 12px;
display: flex;
align-items: center;
}
.status {
color: #9ca3af;
}
.status--loading {
color: #e5e7eb;
}
.status--muted {
color: #6b7280;
}
.status--error {
color: #f97316;
}
.retry {
justify-self: start;
border-radius: 999px;
border: 1px solid #a3e635;
background: transparent;
color: #a3e635;
padding: 6px 14px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.retry:focus-visible {
outline: 2px solid #a3e635;
outline-offset: 2px;
}
.results {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
}
.user-row {
padding: 8px 10px;
border-radius: 10px;
border: 1px solid #1f2937;
background: #020617;
display: grid;
gap: 2px;
}
.user-name {
font-size: 14px;
font-weight: 600;
}
.user-role {
font-size: 12px;
color: #9ca3af;
}
.empty {
margin: 0;
font-size: 13px;
color: #6b7280;
}
import React, { useEffect, useState } from 'react';
import './App.css';
export interface TeamMember {
id: number;
name: string;
role: string;
}
export type SourceMode = 'ok' | 'empty' | 'flaky';
type RequestState =
| { status: 'loading' }
| { status: 'success'; members: TeamMember[] }
| { status: 'empty' }
| { status: 'error'; message: string };
const TEAM: TeamMember[] = [
{ id: 1, name: 'Alice Johnson', role: 'Frontend engineer' },
{ id: 2, name: 'Bob Smith', role: 'Product designer' },
{ id: 3, name: 'Charlie Kim', role: 'Backend engineer' },
{ id: 4, name: 'Diana Lopez', role: 'Engineering manager' },
{ id: 5, name: 'Ethan Brown', role: 'QA engineer' },
{ id: 6, name: 'Fiona Patel', role: 'Data analyst' }
];
let flakyRequests = 0;
// Fake API: responds after 400ms.
// - 'ok' resolves with the whole team
// - 'empty' resolves with an empty list
// - 'flaky' rejects every odd request, so the first request fails and a retry succeeds
export function fetchTeam(mode: SourceMode): Promise<TeamMember[]> {
return new Promise((resolve, reject) => {
window.setTimeout(() => {
if (mode === 'empty') {
resolve([]);
return;
}
if (mode === 'flaky') {
flakyRequests += 1;
if (flakyRequests % 2 === 1) {
reject(new Error('Network timeout'));
return;
}
}
resolve(TEAM.map((member) => ({ ...member })));
}, 400);
});
}
function toErrorMessage(error: unknown): string {
return error instanceof Error && error.message ? error.message : 'Unknown error';
}
export default function App() {
const [mode, setMode] = useState<SourceMode>('ok');
const [attempt, setAttempt] = useState(0);
const [request, setRequest] = useState<RequestState>({ status: 'loading' });
useEffect(() => {
let cancelled = false;
setRequest({ status: 'loading' });
fetchTeam(mode)
.then((members) => {
if (cancelled) return;
setRequest(members.length ? { status: 'success', members } : { status: 'empty' });
})
.catch((error: unknown) => {
if (cancelled) return;
setRequest({ status: 'error', message: toErrorMessage(error) });
});
return () => {
cancelled = true;
};
}, [mode, attempt]);
function handleModeChange(event: React.ChangeEvent<HTMLSelectElement>) {
setMode(event.target.value as SourceMode);
}
function handleRetry() {
setAttempt((current) => current + 1);
}
return (
<main className="main">
<section className="card">
<header className="header">
<h1 className="title">Team Directory (Solution)</h1>
<p className="subtitle">Load the team from a fake API and render every request state.</p>
</header>
<div className="field">
<label className="label" htmlFor="mode-select">Data source</label>
<select id="mode-select" className="mode" value={mode} onChange={handleModeChange}>
<option value="ok">Team (6 members)</option>
<option value="empty">Empty team</option>
<option value="flaky">Flaky network</option>
</select>
</div>
<div className="status-row">
{request.status === 'loading' && <span className="status status--loading">Loading team…</span>}
{request.status === 'success' && (
<span className="status status--muted">{request.members.length} members</span>
)}
{request.status === 'empty' && <span className="status status--muted">0 members</span>}
{request.status === 'error' && (
<span className="status status--error" role="alert">
Could not load the team: {request.message}
</span>
)}
</div>
{request.status === 'error' && (
<button type="button" className="retry" onClick={handleRetry}>
Retry
</button>
)}
{request.status === 'success' && (
<ul className="results">
{request.members.map((member) => (
<li key={member.id} className="user-row">
<div className="user-name">{member.name}</div>
<div className="user-role">{member.role}</div>
</li>
))}
</ul>
)}
{request.status === 'empty' && <p className="empty">No team members yet.</p>}
</section>
</main>
);
}
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')!).render(<App />);
Notes & Pitfalls
- Tracking isLoading, error and members as separate booleans, which allows impossible combinations such as loading and error at once.
- Forgetting to reset to the loading state at the start of a new request, so rows from the previous source stay visible.
- Letting a slow response from the previous mode overwrite the newer state because the effect has no cleanup guard.
- Implementing Retry by calling the fake API from the click handler, which duplicates the state transitions already written in the effect.
- Rendering `error.message` without checking that the rejection is an `Error` instance.
- Discriminated unions for request state in TypeScript.
- useEffect with a cancelled flag in the cleanup.
- Retry through a counter in the dependency array.
- Conditional rendering keyed on a single status value.
Common mistakes on this challenge
Drive every branch from one request status
The empty, error and loading checks each expect exactly one status to be rendered, so parallel booleans or a missing reset leave the previous branch on screen.
- Hold the request in one union: `{ status: 'loading' } | { status: 'success'; members } | { status: 'empty' } | { status: 'error'; message }`.
- Set `{ status: 'loading' }` at the top of the effect and map an empty list to `empty`, a non-empty list to `success` and a rejection to `error`.
- Render the Retry button only on `error` and make it bump a counter that the effect depends on.
Similar questions
Guides
Preparing for interviews? Use Frontend Coding Challenges first, then move into a concrete Study Plan before targeted Company Prep.
Built in public
The code behind FrontendAtlas lives on GitHub. A star helps other frontend engineers find it.