← Back to Articles Frontend & UX

Empty States Are a Feature: UX Decisions From Building an ERP Frontend

Oselio Candido · Jul 2026 · 6 min read

The problem

Product screenshots always show the dashboard full of data. Real users meet your product the other way around: zero products, zero orders, zero history. What they see in that first moment decides whether the app feels finished or abandoned — and a blank table with the word "Nothing" is the abandoned version.

The problem gets worse once filtering is in the picture. A products table showing zero rows can mean two entirely different things: the user has never created a product, or the user has hundreds of products and just searched for something that doesn't match any of them. A single generic "No data" message answers neither question correctly, and answering it wrong actively misleads the user — it tells someone with 485 products that they have no data, or sends a brand-new user hunting for a filter that isn't the actual problem.

What an empty state is

An empty state is what a view shows when there is nothing to list. It is not an edge case; it is a guaranteed state every single user passes through, at the exact moment they are least invested in the product. Three rules turn it into a feature instead of a dead end:

  • Explain — say why the view is empty, in plain words.
  • Reassure — make it visually clear nothing is broken (an icon and a designed layout, not a bare cell).
  • Offer the next action — a call to action that moves the user forward from precisely here.

And one distinction almost every app misses: there are two different empties, and they deserve different treatment. First use — the user has never created a record — calls for onboarding copy: "No products registered yet" plus a prominent Create product button. No filter match — the user has plenty of data, but the current search or filters exclude all of it — calls for course correction: "Nothing matches your filters" plus a Clear filters button. Same blank table, opposite correct answers.

The bug hiding in most implementations: the component only knows about the rows it received. If the filtered response has zero items, the view cannot tell which empty it is looking at — unless something outside the row list tells it.

The alternatives

A few ways to handle this surfaced once the problem was named explicitly:

  • Do nothing — one generic empty message everywhere. Cheapest to build, and the default most teams ship with. It's wrong roughly half the time by construction, because it can't distinguish the two causes.
  • Infer the cause on the frontend from the active filter state. If any filter or search term is set, assume "no match"; otherwise assume "first use." This avoids touching the API at all, but it's fragile — it breaks the moment a filter is applied by default, or a saved view pre-populates a filter, or the empty result is itself a legitimate first-use state with a filter still active from a previous session.
  • Have the backend report the unfiltered count alongside the filtered results. The API already knows, at query time, both how many rows matched the filters and how many rows exist for the user overall. Surfacing the second number as part of the same response removes the guessing entirely — the frontend doesn't infer anything, it's told.

The first option was the status quo and the reason the problem existed. The second looked cheap but pushed a correctness problem onto client-side heuristics that would drift out of sync with backend filtering logic over time. The third meant a small, deliberate change to a widely-used response shape, but it was the only option that made the distinction structurally correct rather than inferred.

The decision

List endpoints were changed to return two numbers instead of one: the page of results being displayed, and the total count of records that exist for the user regardless of active filters. That one extra field is the entire trick — when a filtered query returns zero items, the unfiltered total still says whether the user has any data at all. Applying this consistently across every list endpoint (products, suppliers, orders, financial entries, employees, attendance, and more) meant every list view in the app could make the distinction for free, without any one screen having to reinvent the logic.

On the frontend, the branching logic — which empty state to render, which icon, which message, which call to action — was written once in a single shared component rather than repeated per screen. Each list view passes in only what's specific to it: the unfiltered total from the API, domain-appropriate copy for each of the two cases, and the handlers for "create" and "clear filters." The component itself decides which branch to render based on whether that total is zero:

  • Icon: an inviting icon in the brand color for first use, a neutral, informational icon for no-match.
  • Message: "nothing registered yet" vs. "nothing matches the applied filters" — each names its own cause.
  • CTA: a primary create button vs. a secondary clear filters button — each is the one action that resolves that specific empty.

A deliberate trade-off came with this design: the unfiltered total means every list request computes a second count alongside the filtered page. At the data volumes an internal ERP-style app deals with, that's negligible; at a much larger scale it would need caching or estimation rather than a live count on every request. The other guardrail built into the same design was keeping "empty," "loading," and "error" as three distinct states rather than collapsing them into one "no data" view — the empty-state branch only ever renders after a successful response with genuinely zero items, so a slow network or a failed request never gets mistaken for an empty list.

The copy itself was treated as part of the design rather than an afterthought. Generic strings like "No results" would have undone the whole point of the exercise, so the shared component takes its messages as parameters instead of hardcoding them, and each screen supplies wording specific to what it lists.

The outcome

Every list screen in the app — products, suppliers, orders, financial entries, employees, attendance, and more — now shows the correct empty state without any screen having to special-case the logic itself: a new user is invited to create their first record, and a user whose filters returned nothing is offered a one-click way back to their data instead of being told, incorrectly, that none exists. Because the decision is made from a single shared component fed by a single backend contract, adding a new list view to the app means supplying two strings and two handlers, not reimplementing a state machine — the correctness of the distinction is now structural rather than something each screen has to get right on its own.