Loading, Error and Empty

Model the four outcomes of a request as one status value instead of three booleans, and render the right thing for each.

Syntaxconst status = ref<'loading' | 'ready' | 'failed'>('loading')

A request has four outcomes, not two: in flight, succeeded with data, succeeded with nothing, or failed. Three independent booleans describe eight combinations and most of them are impossible. loading and error both true is a state you can accidentally render.

One value instead

Hold a single status ref typed as a union of the states that actually exist — 'loading' | 'ready' | 'failed' — and keep the error message beside it. Now the component cannot be in two states at once, and the template is one v-if / v-else-if chain in the order you want them checked.

Empty is not a status

An empty list is a success. Keep it out of status and decide it in the template with items.length === 0. The branch matters: "No results for that search" and "You have no notes yet" are different sentences, and both beat an empty box.

First load against a refresh

The spinner belongs to the first load. On a refresh you already have data, so keep showing it and mark the component busy separately. Clearing the array before refetching makes a loaded list vanish and reappear.

What to show

Every failure branch needs a retry the user can click. @click is shorthand for v-on:click; they are the same directive.

Example

Example · vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'

interface Note {
  id: number
  title: string
}

type Status = 'loading' | 'ready' | 'failed'

const status = ref<Status>('loading')
const notes = ref<Note[]>([])
const message = ref('')

async function load(): Promise<void> {
  status.value = 'loading'
  try {
    const res = await fetch('/api/notes')
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    notes.value = (await res.json()) as Note[]
    status.value = 'ready'
  } catch (err) {
    message.value = err instanceof Error ? err.message : 'Request failed'
    status.value = 'failed'
  }
}

onMounted(load)
</script>

<template>
  <p v-if="status === 'loading'">Loading your notes</p>
  <div v-else-if="status === 'failed'">
    <p>Could not load your notes: {{ message }}</p>
    <button type="button" @click="load">Try again</button>
  </div>
  <p v-else-if="notes.length === 0">You have no notes yet.</p>
  <ul v-else>
    <li v-for="note in notes" :key="note.id">{{ note.title }}</li>
  </ul>
</template>

<style scoped>
button {
  cursor: pointer;
}
</style>

When to use it

  • Replace a tangle of isLoading, hasError and isEmpty booleans with one status value.
  • Tell a user whose search returned nothing apart from a user who has no records at all.
  • Keep a dashboard table on screen while it refreshes in the background.
  • Give every failure branch a retry button instead of a dead end.

More examples

The four outcomes as one type

A discriminated union: the kind field decides which other fields exist, so message is only reachable in the failed branch and items only in the ready branch. Wrap it in a computed and the template reads state.kind. Reach for this when several components show the same four outcomes, or when you want the type checker to stop you rendering an error message that is not there. The precedence is encoded once, in the order of the ifs, rather than repeated in every template.

Example · ts
// src/lib/request-state.ts
export type RequestState<T> =
  | { kind: 'loading' }
  | { kind: 'failed'; message: string }
  | { kind: 'empty' }
  | { kind: 'ready'; items: T[] }

export function toState<T>(
  loading: boolean,
  error: Error | null,
  items: T[] | null,
): RequestState<T> {
  if (loading) return { kind: 'loading' }
  if (error) return { kind: 'failed', message: error.message }
  if (items === null || items.length === 0) return { kind: 'empty' }
  return { kind: 'ready', items }
}

// toState<string>(true, null, null).kind
// Output: loading

// toState<string>(false, new Error('HTTP 500'), null).kind
// Output: failed

// toState<string>(false, null, []).kind
// Output: empty

// toState<string>(false, null, ['a']).kind
// Output: ready

Refreshing without the list blinking

Two flags with different jobs: isFirstLoad owns the spinner, isRefetching owns the button. The list is never cleared, so a refresh does not empty the screen, and a failed refresh keeps the stale data visible with a note above it rather than throwing it away. This is the behaviour a caching library gives you by default, and it is worth seeing done by hand first.

Example · vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'

interface Rate {
  code: string
  value: number
}

const rates = ref<Rate[]>([])
const isFirstLoad = ref(true)
const isRefetching = ref(false)
const message = ref('')

async function load(): Promise<void> {
  if (!isFirstLoad.value) isRefetching.value = true
  message.value = ''
  try {
    const res = await fetch('/api/rates')
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    rates.value = (await res.json()) as Rate[]
  } catch (err) {
    message.value = err instanceof Error ? err.message : 'Request failed'
  } finally {
    isFirstLoad.value = false
    isRefetching.value = false
  }
}

onMounted(load)
</script>

<template>
  <p v-if="isFirstLoad">Loading rates</p>
  <div v-else>
    <p v-if="message">Showing the last known rates. {{ message }}</p>
    <button type="button" :disabled="isRefetching" @click="load">
      {{ isRefetching ? 'Refreshing' : 'Refresh' }}
    </button>
    <ul>
      <li v-for="rate in rates" :key="rate.code">{{ rate.code }} {{ rate.value }}</li>
    </ul>
  </div>
</template>

<style scoped>
li {
  font-variant-numeric: tabular-nums;
}
</style>

An empty state that is specific

A presentational component: it takes the outcome as props and owns nothing but the wording. Splitting empty into filtered-empty and truly-empty is the difference between a user clearing a search box and a user deciding the feature is broken. Note that v-if sits on siblings of the v-for element, never on the same element: v-if has the higher priority, so it would run before the loop variable exists.

Example · vue
<script setup lang="ts">
import { computed } from 'vue'

interface Task {
  id: number
  title: string
}

const props = withDefaults(
  defineProps<{ tasks: Task[]; query: string; failed?: boolean }>(),
  { failed: false },
)

const isEmpty = computed(() => props.tasks.length === 0)
const isFiltered = computed(() => props.query.trim().length > 0)
</script>

<template>
  <p v-if="props.failed">We could not load your tasks.</p>
  <p v-else-if="isEmpty && isFiltered">
    Nothing matches "{{ props.query }}". Clear the search to see every task.
  </p>
  <p v-else-if="isEmpty">No tasks yet. Add one to get started.</p>
  <ul v-else>
    <li v-for="task in props.tasks" :key="task.id">{{ task.title }}</li>
  </ul>
</template>

<style scoped>
p {
  color: inherit;
}
</style>

Discussion

  • Be the first to comment on this lesson.