A lot of empty-state bugs begin with one convenient assumption: an empty array means there is nothing to show.
It does not. An empty array might be a new account, a search with no matches, a response that has not arrived, or a request that failed. If those conditions share one UI, the application will eventually tell the user something false.
The most damaging example is rendering “No enquiries yet” after a failed request. That copy describes the customer’s data. The application only knows that it could not retrieve the data.
Model the state before styling it
For a small service catalogue, a pure function can make the distinctions explicit:
function viewFor({ status, items = [], query = '' }) { if (status === 'loading') { return { kind: 'loading', heading: 'Loading services', action: null }; } if (status === 'error') { return { kind: 'error', heading: 'We couldn’t load your services', body: 'Check your connection and try again. Your filters have been kept.', action: 'retry' }; } if (items.length > 0) { return { kind: 'success', heading: `${items.length} services`, action: null }; } if (query.trim()) { return { kind: 'no-results', heading: `No services match “${query.trim()}”`, body: 'Try a different word or clear the search to see every service.', action: 'clear-search' }; } return { kind: 'first-use', heading: 'No services yet', body: 'Add your first service to start building your catalogue.', action: 'add-service' }; } The ordering matters. Loading and failure are resolved before the code looks at items.length. A failed request cannot accidentally fall through to the first-use message. A non-empty query turns a successful empty result into no-results, not first-use.
In a larger application, this can be a reducer, a state machine or a framework-specific view model. The important part is that the distinction lives in application logic rather than being inferred inside a template.
Test the decision table, not just the component snapshot
The pure function gives us a small test surface. These checks were executed with Node’s assert/strict before publication:
assert.equal(viewFor({ status: 'loading' }).kind, 'loading'); assert.equal(viewFor({ status: 'error', query: 'logo' }).action, 'retry'); assert.equal(viewFor({ status: 'success', items: [{ id: 1 }] }).kind, 'success'); assert.equal(viewFor({ status: 'success', items: [], query: 'logo' }).kind, 'no-results'); assert.equal(viewFor({ status: 'success', items: [], query: 'logo' }).action, 'clear-search'); assert.equal(viewFor({ status: 'success', items: [], query: ' ' }).kind, 'first-use'); assert.equal(viewFor({ status: 'success', items: [], query: '' }).action, 'add-service'); assert.equal( viewFor({ status: 'success', items: [], query: 'logo' }).heading, 'No services match “logo”' ); Output:
PASS: loading, error, success, no-results, first-use, actions and query copy The whitespace-only query case is deliberate. Without trim(), a space can turn a first-use view into a no-results view with an apparently blank search term.
Render user text without creating an HTML injection path
The query belongs in text, not in an HTML string. Using innerHTML for No services match “${query}” would make the search field an injection source. DOM APIs keep the content and the markup separate:
const panel = document.querySelector('#services-panel'); const statusMessage = document.querySelector('#services-status'); const actionLabels = { retry: 'Try again', 'clear-search': 'Clear search', 'add-service': 'Add service' }; function render(view) { panel.replaceChildren(); const heading = document.createElement('h2'); heading.textContent = view.heading; panel.append(heading); if (view.body) { const body = document.createElement('p'); body.textContent = view.body; panel.append(body); } if (view.action) { const button = document.createElement('button'); button.type = 'button'; button.dataset.action = view.action; button.textContent = actionLabels[view.action]; panel.append(button); } statusMessage.textContent = view.kind === 'loading' ? '' : view.heading; } The accompanying HTML can contain one persistent status element:
<div id="services-status" role="status" aria-atomic="true"></div> <section id="services-panel" aria-label="Services"></section> MDN’s status role documentation notes that status has an implicit aria-live="polite". It is intended for advisory information that does not receive focus. Keep the status element separate from the interactive panel; replacing a large live region containing headings, buttons and lists can create noisy repeated announcements.
A status announcement does not replace visible copy, keyboard access or sensible focus management. If activating “Clear search” moves the user back to a populated list, decide whether focus should remain on the search field or move to the results heading. Do not move it unexpectedly on every background update.
Preserve context when a request fails
A retry should not erase the user’s query. The request path also needs to handle non-2xx responses explicitly; fetch() does not reject merely because the server returned HTTP 404 or 500. MDN’s Using Fetch guide documents that behaviour.
async function loadServices() { render(viewFor({ status: 'loading' })); try { const response = await fetch('/api/services'); if (!response.ok) throw new Error(`HTTP ${response.status}`); const items = await response.json(); render(viewFor({ status: 'success', items, query: document.querySelector('#service-search').value })); } catch { render(viewFor({ status: 'error', query: document.querySelector('#service-search').value })); } } This short example is intentionally not a complete data layer. Production code should also protect against stale responses: if request A starts, request B starts later, and B finishes first, A must not overwrite B’s newer state. An AbortController, a request sequence number, or the framework’s cancellation mechanism can enforce that boundary.
The same rule applies to actions. “Retry” should repeat the failed operation with the current context. “Clear search” should clear the search, not every saved filter. “Add service” should only appear for users who hold that permission.
Test failure without deleting real data
A useful test matrix includes:
- a new test account with zero records;
- a populated account with a no-match query;
- a delayed response so loading is observable;
- HTTP error responses and malformed JSON;
- an offline transition;
- a slow earlier response arriving after a newer response;
- a read-only user who cannot perform the primary action;
- keyboard-only navigation and a screen-reader pass;
- long copy, enlarged text and a narrow viewport.
Use fixtures, mocks or a controlled test environment. Do not delete production records to make an empty state appear.
The design handoff is part of the implementation
For each dynamic view, specify the trigger, exact copy, available action, permission requirement, preserved input, focus destination and live-region behaviour. A polished success-state mockup plus a decorative empty-state illustration is not enough to implement from.
Empty states are where the interface explains uncertainty. Treat them as states in the application, with tested transitions and truthful copy, rather than as blank panels waiting for artwork.
Prepared with AI assistance and reviewed for accuracy by Design Den Solutions. The cover is a conceptual AI-generated illustration with the official company logo composited unchanged. The state-model assertions above were executed locally before publication.
Design Den Solutions plans and builds browser-based products, portals and dashboards. See app and web product development.


