All posts
EngineeringSeptember 28, 2026 · The Ubriot team · 5 min read

The false empty state in release tooling

When a dashboard cannot load something and shows 'nothing here' instead, it gives a confident wrong answer. Three we found in our own release screens, and the states a list view actually needs.

Open a build in a release dashboard and look at the artifacts panel. It says there are no artifacts yet. What do you do next?

Most people start another build. The first one presumably failed to upload, or the worker never got that far. That is a reasonable reaction to what the screen said. It is the wrong reaction if the screen was lying, and a panel that shows an empty list after its request failed is lying. The artifact is sitting there. The browser simply never received the list.

We went through our own dashboard this week looking for that pattern and found it in three places. This post is about why it matters more in release tooling than almost anywhere else, and the small set of states we now require from every list view.

Empty is an answer, an error is not

An empty list is a factual claim about the world: we asked, and there is nothing. A failed request is the absence of a claim: we asked, and did not hear back. Collapsing the second into the first is the most common shortcut in front-end code, because the list starts as an empty array and the error handler does nothing. The component then renders exactly what it would render if the answer really were nothing.

In a to-do app this is an annoyance. In release tooling people act on what they see, and the actions are expensive or hard to undo. No artifacts suggests a rebuild, which costs build minutes and produces a second candidate that now has to be told apart from the first. No logs suggests the worker never started, which sends someone to investigate infrastructure that is fine. No apps suggests the account is new, and invites someone to create an app that already exists.

Those were our three. The build logs panel swallowed its error and fell through to its quiet message, so a failed request read as either waiting for logs or no logs yet. The artifacts panel caught its error with an empty handler and showed no artifacts yet. The OTA updates page started with an empty app list, so for a moment on every load, and permanently if the request failed, it showed the empty state for a brand new account, complete with the prompt to create your first release app.

The states a list view needs

The fix is not clever. Each panel now tracks what it actually knows instead of inferring it from the length of an array. We think of it as five states, and a list that cannot distinguish all five is carrying a bug that has not been noticed yet.

type ListState<T> =
  | { kind: "loading" }
  | { kind: "empty" }                        // we asked, the answer was nothing
  | { kind: "items"; items: T[] }
  | { kind: "failed"; message: string }      // we asked, no answer
  | { kind: "stale"; items: T[]; message: string } // old answer, latest refresh failed

Loading is not empty, so nothing that looks like an empty state should appear before the first response. That alone fixed the flash on our updates page. Failed is not empty, so it gets its own message, said plainly, with a retry button beside it. Stale matters most for anything that polls. A running build's logs refresh every few seconds, and one refresh will occasionally fail. Wiping the logs someone is reading because of a single failed poll would be worse than the original bug. We keep the last lines received on screen and add a quiet note above them: these are the last logs received, the latest refresh failed, retry.

Make the retry retry the right thing

One detail caught us out. The updates page already had a refresh button. It reloaded the channels and updates for the selected app. When the request for the app list itself had failed, there was no selected app, so the button did nothing at all. The page looked broken and the one control offered to fix it was inert.

A retry control should re-run whichever request failed, not the one the page usually needs. On that page, refresh now reloads the app list when that is what failed, and the error state carries its own try again button that does the same. It sounds obvious written down. It is easy to miss when the retry was written before the failure path existed.

What we did not do

We did not turn every failure into a red banner across the top of the dashboard. The error appears in the panel that failed, next to the data it concerns, and leaves the rest of the page working. A build whose artifacts failed to load can still show its logs and its status. Global error banners teach people to ignore them, and they separate the message from the thing it describes.

We also did not try to guess why the request failed and phrase the message accordingly. The message says what could not be loaded and uses the reason from the server where there is one. Guessing wrong about a cause is its own kind of false answer.

A check you can run on your own tools

Open your release dashboard, or any internal tool your team makes decisions from, with the browser's developer tools open. Block requests to the API, or switch the network to offline, and reload each page. Then read what each screen claims. Anything that says none, no results, all clear, or get started is making a statement it cannot support. Do the same with a page already loaded and a poll in progress, and check that the data you were looking at survives a failed refresh.

It takes a few minutes and it tends to find something. In our case it found three screens that would have sent a careful person off to do unnecessary work. The shipped fix is live in the Ubriot dashboard now. The habit of checking what a screen claims when the network is gone is the more durable part.

Try Ubriot AI

React Native CI/CD from one CLI.

Get started