Handling Loading and Error States
Any component that fetches data asynchronously has at minimum four states to represent: idle/not-yet-started, loading, error, and success (which itself can further split into 'success but empty' and 'success with data'). A surprisingly common bug source is representing these states implicitly through combinations of nullable refs — for example inferring 'loading' from data.value === null && error.value === null — which becomes ambiguous and fragile as soon as a component needs to distinguish 'never fetched' from 'fetched and got null back'. Modeling status explicitly, rather than inferring it, keeps templates predictable and easy to reason about.
Cricket analogy: Before a match, the scoreboard shows "yet to bat", "batting", "all out", and "innings complete with score" as distinct states — a commentator conflating "yet to bat" with "all out" because both show zero runs would mislead viewers, just as inferring loading from null refs misleads the UI.
Three Booleans vs. a Single Status Ref
A common beginner pattern uses three independent refs — isLoading, isError, data — but nothing stops all three from being simultaneously inconsistent (e.g. isLoading and isError both true) unless the code is disciplined about resetting them. A more robust alternative is a single status ref constrained to a small set of string literals: 'idle' | 'loading' | 'success' | 'error'. This makes invalid states unrepresentable — the template can switch on one value instead of checking several booleans in combination — and pairs naturally with TypeScript's literal union types for compile-time safety.
Cricket analogy: Instead of separately tracking "isBatting", "isBowling", "isFielding" flags that could all be true at once, a scorer uses one state field per player — "batting", "bowling", or "fielding" — like Virat Kohli's status card showing exactly one value, never a contradictory combination.
<script setup>
import { ref, computed } from 'vue'
const status = ref('idle') // 'idle' | 'loading' | 'success' | 'error'
const data = ref(null)
const error = ref(null)
async function loadReport(id) {
status.value = 'loading'
try {
const res = await fetch(`/api/reports/${id}`)
if (!res.ok) throw new Error('Failed to load report')
data.value = await res.json()
status.value = 'success'
} catch (err) {
error.value = err
status.value = 'error'
}
}
const isEmpty = computed(
() => status.value === 'success' && Array.isArray(data.value) && data.value.length === 0
)
</script>
<template>
<p v-if="status === 'loading'">Loading report…</p>
<p v-else-if="status === 'error'">{{ error.message }}</p>
<p v-else-if="isEmpty">No report data found.</p>
<ReportView v-else-if="status === 'success'" :data="data" />
</template>Distinguishing Empty From Loading
Templates should never render a 'success' view with an empty list as if it were still loading, and they should never show a spinner forever if a request legitimately returns zero results. Treat 'empty but successful' as its own renderable branch — usually a friendly message rather than a blank area — since to a user an unexplained blank screen looks indistinguishable from a bug.
Cricket analogy: A "no results found" screen after a player-stats search should show "No centuries recorded for this player" rather than a blank scoreboard — a blank scoreboard looks exactly like the match hasn't started, confusing the viewer.
React developers building custom hooks around fetch usually converge on this same idle/loading/success/error shape (sometimes literally called a 'status' or 'fetchState'), and libraries like TanStack Query expose exactly these fields out of the box — Vue's ecosystem equivalents mirror the same design because the underlying problem is framework-agnostic.
Forgetting to reset error.value = null at the start of a new fetch attempt is a frequent bug: a user retries after a failed request, the new request succeeds, but a stale error message from the previous attempt still renders because nothing cleared it before the retry began.
Surfacing Errors Usefully
Catching an error and setting error.value = err is only half the job — the template needs to translate that error into something a user can act on. Distinguish network failures (offer a retry button) from validation errors (show the specific field problem) from 404s (show a 'not found' state) rather than collapsing everything into one generic 'Something went wrong' message, which frustrates users who can't tell if retrying will help.
Cricket analogy: A rain-delay notice tells fans "match suspended, resuming at 3pm" while a rescheduled-venue notice says "shifted to a different ground" — collapsing both into "match status unclear" leaves fans unable to act, unlike distinguishing network failure from a 404.
- Model async state explicitly (loading/error/success/idle) rather than inferring it from combinations of nullable refs.
- A single
statusref with a small string-literal union avoids impossible/inconsistent state combinations. - Treat 'successful but empty' as its own template branch, distinct from both loading and error.
- Always reset stale error state at the start of a new fetch attempt, especially on retry.
- Differentiate error types (network, validation, not-found) in the UI rather than showing one generic message.
- This four-state pattern (idle/loading/success/error) is framework-agnostic and mirrored by data-fetching libraries.
Practice what you learned
1. Why is inferring loading state from `data.value === null && error.value === null` considered fragile?
2. What is the main advantage of a single `status` ref with string-literal values over three separate booleans?
3. Why should 'successful but empty' be a distinct template branch rather than falling through to the loading or default view?
4. What common bug occurs when `error.value` is not reset before a retry attempt?
5. Why is differentiating error types (network, validation, not-found) preferable to one generic error message?
Was this page helpful?
You May Also Like
Fetching Data in Vue
Learn common patterns for fetching data in Vue components and composables, including lifecycle timing, reactivity pitfalls, and race conditions.
Computed Properties
How Vue's computed() function derives cached, reactive values from other state, and why it's preferred over methods for derived data in templates.
Conditional Rendering: v-if and v-show
Explains Vue's two conditional rendering directives, how they differ at the DOM level, and how to choose the right one for performance and correctness.
Composables Explained
Understand what composables are, why they replace mixins as Vue's primary reuse pattern, and the conventions they follow.
Related Reading
Related Study Notes in Web Development
Browse all study notesWebSockets Study Notes
Web Development · 30 topics
Web DevelopmentWebAssembly Study Notes
WebAssembly · 30 topics
Web DevelopmentgRPC Study Notes
Protocol Buffers · 30 topics
Web DevelopmentSpring Boot Study Notes
Java · 30 topics
Web DevelopmentFlask Study Notes
Python · 30 topics
Web DevelopmentDjango Study Notes
Python · 30 topics