Drie staten — altijd

Elke async fetch heeft drie mogelijke staten. Toon ze allemaal goed, en je gebruiker weet altijd wat er aan de hand is:

  • Loading — bezig met laden, toon een spinner of "Laden..."
  • Error — er ging iets fout, toon de melding
  • Success — de data is binnen, toon hem

Beginnersfout: alleen de data tonen, en bij een fetch een leeg scherm laten zien. De gebruiker denkt dan dat de app stuk is. Toon altijd de loading-staat.

Het patroon — drie refs

Maak voor elke staat een eigen ref:

<script setup>
import { ref, onMounted } from 'vue'

const data = ref(null)
const loading = ref(true)
const error = ref(null)

onMounted(async () => {
  try {
    const res = await fetch('https://api.example.com/data')

    if (!res.ok) {
      throw new Error(`Server gaf ${res.status}`)
    }

    data.value = await res.json()
  } catch (e) {
    error.value = e.message
  } finally {
    loading.value = false
  }
})
</script>

Waarom finally? Wat er ook gebeurt — succes of error — de loader moet uit. finally draait áltijd, dus daar zet je loading.value = false.

Weergave in template

Met v-if / v-else-if / v-else kies je welke staat zichtbaar is:

<template>
  <p v-if="loading">Laden...</p>

  <p v-else-if="error" class="error">
    Er ging iets fout: {{ error }}
  </p>

  <ul v-else>
    <li v-for="user in data" :key="user.id">
      {{ user.name }}
    </li>
  </ul>
</template>

Volgorde is belangrijk:

  1. Eerst v-if="loading" — anders zie je even "geen data" voor de fetch start
  2. Dan v-else-if="error"
  3. Pas in v-else de data — alleen als alles goed ging

Compleet voorbeeld

<script setup>
import { ref, onMounted } from 'vue'

const users = ref([])
const loading = ref(true)
const error = ref(null)

const loadUsers = async () => {
  loading.value = true
  error.value = null

  try {
    const res = await fetch('https://jsonplaceholder.typicode.com/users')

    if (!res.ok) {
      throw new Error(`HTTP ${res.status}`)
    }

    users.value = await res.json()
  } catch (e) {
    error.value = e.message
  } finally {
    loading.value = false
  }
}

onMounted(() => {
  loadUsers()
})
</script>

<template>
  <h1>Gebruikers</h1>

  <div v-if="loading" class="loader">
    ⏳ Laden...
  </div>

  <div v-else-if="error" class="error-box">
    <p>Er ging iets fout: {{ error }}</p>
    <button @click="loadUsers">Opnieuw proberen</button>
  </div>

  <ul v-else>
    <li v-for="user in users" :key="user.id">
      {{ user.name }} — {{ user.email }}
    </li>
  </ul>
</template>

<style scoped>
.loader { padding: 24px; text-align: center; color: #666; }
.error-box {
  padding: 16px;
  background: #fee;
  border: 1px solid #f99;
  border-radius: 8px;
}
</style>

Bonus: de "Opnieuw proberen" knop roept gewoon loadUsers() nog een keer aan. Doordat de functie de loading/error refs reset, werkt de retry vanzelf.

Als composable — herbruikbaar

Gebruik je dit patroon in meerdere componenten? Trek het uit in een composable.

// src/composables/useFetch.js
import { ref } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const loading = ref(true)
  const error = ref(null)

  const execute = async () => {
    loading.value = true
    error.value = null

    try {
      const res = await fetch(url)
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      data.value = await res.json()
    } catch (e) {
      error.value = e.message
    } finally {
      loading.value = false
    }
  }

  execute()   // start meteen

  return { data, loading, error, execute }
}

Gebruik in een component

<script setup>
import { useFetch } from '@/composables/useFetch'

const { data: users, loading, error, execute } = useFetch(
  'https://jsonplaceholder.typicode.com/users'
)
</script>

<template>
  <p v-if="loading">Laden...</p>
  <p v-else-if="error">Fout: {{ error }}</p>
  <ul v-else>
    <li v-for="user in users" :key="user.id">{{ user.name }}</li>
  </ul>

  <button @click="execute">Opnieuw laden</button>
</template>

Tip: VueUse heeft een kant-en-klare useFetch die dit (en meer) doet. Voor productie-apps gebruik je die in plaats van zelf bouwen.

Belangrijke Regels

  • Altijd drie refs: data, loading, error
  • loading begint op true — anders zie je heel even een leeg scherm
  • loading.value = false in finally — draait altijd
  • Volgorde in template: loading → error → data
  • Vergeet de retry-knop niet bij errors — gebruikers willen het opnieuw proberen

Veelgemaakte Fouten

Fout — loading = false alleen bij success:

try {
  data.value = await res.json()
  loading.value = false   // ❌ bij error blijft loading true!
} catch (e) {
  error.value = e.message
}

Goed — in finally:

try {
  data.value = await res.json()
} catch (e) {
  error.value = e.message
} finally {
  loading.value = false   // ✅ altijd
}

Fout — verkeerde volgorde in template:

<ul v-if="data">...</ul>
<p v-else-if="loading">Laden...</p>
<!-- ❌ tijdens loading is data nog null → toont eerst "Laden..." — werkt toevallig -->
<!-- maar logica is rommelig -->

Goed:

<p v-if="loading">Laden...</p>
<p v-else-if="error">{{ error }}</p>
<ul v-else>...</ul>