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.