DEV Community

137Foundry
137Foundry

Posted on

How to Detect Filter-Empty vs Data-Empty States in a Web App

In a web application that displays a list, a table, or a chart with filter controls, the empty state has at least two distinct meanings. The screen can be empty because there is no data at all. The screen can also be empty because there is data, but the filter currently applied excludes all of it. These two cases need different UI, and they need different code paths to render that UI.

Most products treat them as the same case. The empty state says "No results found" whether the data set is empty or the filters are too strict. Users do not know which case they are in, and they cannot tell whether to fix the filter or to check with the team about missing data. Below is a short, practical walkthrough of how to detect each case and render the right empty state.

A code editor with several files open showing TypeScript modules for empty state logic next to a browser preview
Photo by Pixabay on Pexels

Step 1: separate the two queries in the data layer

The first step happens in the data layer, not the UI. To distinguish a filter-empty case from a data-empty case, you need to know two things about the current view: how many records exist for this account total, and how many records match the current filter.

If the first number is zero, the data-empty case is in play. If the first number is greater than zero but the second is zero, the filter-empty case is in play. If both are greater than zero, the view is not empty at all.

In a typical SQL-backed application, this is two queries instead of one. The first is the filtered query that drives the actual view. The second is an unfiltered count for the same account scope. Both queries should be cached together, because they update on the same write events.

In a GraphQL-backed application, the unfiltered count can be exposed as a sibling field on the same query. In a REST application, it is a separate endpoint that returns the count quickly without paginating. Either way, the cost is one extra count query per page load, which is small.

The PostgreSQL documentation on query planning covers the index considerations for keeping these count queries fast. For most account-scoped counts, a partial index on the account id is enough.

Step 2: surface both numbers in the API response

The API response that drives the screen should include both numbers explicitly. The shape that holds up well across products is something like:

{
  "results": [...],
  "total_matching_filter": 0,
  "total_for_account": 247,
  "filter_state": { "date_range": "last_7_days", "segment": "enterprise" }
}
Enter fullscreen mode Exit fullscreen mode

The frontend gets all three pieces in one response. It does not have to do the count separately. It does not have to infer the filter state from URL parameters. The empty state logic on the UI side becomes a simple conditional on the two numbers.

If total_for_account is zero, render the first-time empty state. If total_for_account is greater than zero and total_matching_filter is zero, render the filter-empty state. Otherwise render the list.

This shape also makes the screen testable. The frontend tests can stub the API response with any combination of the three states, and the test suite covers all of them without setting up real data.

Step 3: render the right empty state for each case

With the data structure in place, the rendering logic is small. Three branches in the component, each returning a different empty-state component.

The first-time case is the new-user empty state. It explains what this screen shows, shows a sample or an example, and offers a clear "create" or "connect" action. It is the highest-information empty state because the user has the most to learn.

The filter-empty case is the active-filter empty state. It shows the filters that are currently applied as chips with remove buttons, shows the total number of records that exist for the account when no filters are applied, and offers a "clear all filters" action. It is low-information by design; the user mostly needs to know that the data exists and that the filter is the reason it is not visible.

The "results returned, nothing to render" case should never actually happen in practice. If it does, it is a bug somewhere upstream and the empty state should say so. A small "Something went wrong loading results. Try again?" message with a retry button is the right fallback. The longer piece on how to design empty states that earn trust instead of apologizing covers the surrounding design principles.

Step 4: make the filter-empty case actionable

The filter-empty UI is where the most product wins are. The pattern that holds up across products is to show the active filters as removable chips inside the empty state, so the user can drop one or more filters in a single click and see whether that fixes the empty result.

The chip pattern is documented in most modern design systems. The Material 3 guidance on filter chips covers the visual conventions cleanly. The implementation in any modern UI framework is straightforward: render a chip for each active filter key/value pair, give each chip a remove button, and bind the remove action to the existing filter state setter.

The "clear all filters" button is the larger lever. It sets the filter state back to the default and the next render fetches the unfiltered result. For users who landed on a strange combination of filters from a saved URL or a teammate's bookmark, the clear-all button is the fastest way out.

Step 5: detect and surface the integration-broken case separately

The fourth empty state is the integration-broken case, which is a different bug entirely. If the data source that feeds the screen has not synced in longer than expected, the empty state should reflect that, not the no-data case.

The detection is on the data layer side. Track the last-successful-sync timestamp for any integration that feeds this view. Expose it on the same API response that drives the screen:

{
  "results": [...],
  "total_matching_filter": 0,
  "total_for_account": 247,
  "data_freshness": {
    "last_sync": "2026-06-29T13:42:11Z",
    "expected_interval_minutes": 15,
    "stale": false
  }
}
Enter fullscreen mode Exit fullscreen mode

If stale is true, the empty state renders a different message: "Data source has not synced in 47 minutes. Last successful sync was at 13:42 UTC." A link next to it points at the integration settings page where the user can reconnect.

The Site Reliability Engineering book chapter on monitoring covers the broader principle: a silent failure is worse than a loud one, and the UI is one of the cheapest places to make a failure loud.

A monitoring dashboard with a banner showing a stale data source notice
Photo by Ellie Burgin on Pexels

A worked example of the four-state component

A real component that handles all four cases looks something like this in a typical React or Vue codebase. The state machine is small.

If the API call is still pending, render the loading state. If the API returned an error, render the error state with a retry. If total_for_account === 0, render the first-time empty state. Else if total_matching_filter === 0, render the filter-empty state. Else if data_freshness.stale === true and total_matching_filter > 0, render the list with a banner above it saying "data may be stale." Else render the list.

Six branches. Each one a small piece of UI. Each one handles a real user situation that deserves its own message. The cost is one extra API field per response and one extra branch per render. The benefit is that the screen tells the truth about its state at every moment, which is what users want.

Step 6: validate with a small audit

The validation step is the most often skipped. Take the screen you just changed, and walk it through every state.

Sign in as a new account with no data. The screen should show the first-time empty state.

Sign in as an established account, apply a filter set that excludes everything. The screen should show the filter-empty state with the chips.

Disconnect the integration. The screen should show the stale-data state.

Block the API endpoint at the network level. The screen should show the error state with a retry.

Reconnect the integration and remove the network block. The screen should return to the populated list.

If any of these renders the wrong UI, the conditional logic in the component has a bug. Fix it before shipping. The five-minute audit catches bugs that would otherwise show up in support tickets a month later.

For teams that want to bring this discipline to a product on a larger scale, 137Foundry runs this kind of empty-state and data-freshness audit as part of its standard engagements. The work pays back quickly in support-volume reduction.

The short version

Empty states have at least four distinct causes: no data ever, no data this period, filter excludes everything, integration broken. Each one deserves its own UI. The detection is in the data layer with one extra count query and one freshness timestamp. The rendering is a small conditional in the component. The audit is a five-minute walk through every state. The result is a screen that tells the truth about its current state, which is the foundation of every other UI improvement you can make on it.

Top comments (0)