Back to articles
Vue 3TypeScriptJune 20, 2026

Stop modeling UI state with booleans

isLoading, isError, hasData - three booleans describe eight possible combinations, and only four of them are states your component should ever be in. The other four are bugs waiting for a network condition to trigger them.

Bad - independent flags that can contradict each other

const isLoading = ref(false);
const isError = ref(false);
const data = ref<Invoice[] | null>(null);

async function load() {
  isLoading.value = true;
  isError.value = false;
  try {
    data.value = await api.get<Invoice[]>("/invoices");
  } catch {
    isError.value = true;
  } finally {
    isLoading.value = false;
  }
}

Nothing stops isLoading and isError from being true at the same time after a race between two calls to load(). The template ends up with v-if chains that check flags in a specific order to paper over states that should never have been representable.

Good - one status, one shape per status

type RequestState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "error"; message: string }
  | { status: "success"; data: T };

const state = ref<RequestState<Invoice[]>>({ status: "idle" });

async function load() {
  state.value = { status: "loading" };
  try {
    state.value = { status: "success", data: await api.get<Invoice[]>("/invoices") };
  } catch (e) {
    state.value = { status: "error", message: toMessage(e) };
  }
}

The template switches on state.value.status, and TypeScript narrows data and message into existence only where they're valid - there's no branch where you can read data while status is "error". The impossible combinations aren't handled, they're unrepresentable.

The tradeoff: a discriminated union is more to read than three ref(false) lines, and every consumer has to switch on status instead of checking a flag. Worth it the moment a component's loading and error states can actually overlap in practice, which is most of the time a network call is involved.