Vue has no HTTP layer of its own; you use the browser's fetch or a library such as axios and connect the result to reactive state. The hard parts are everything around the request: loading and error states, overlapping requests, authentication headers, and configuration that differs between development and production. After this lesson you will handle all of these cleanly.
Every request needs three states. Track them explicitly and render each one:
<script setup>
import { ref, onMounted } from 'vue'
const products = ref([])
const loading = ref(false)
const error = ref(null)
async function load() {
loading.value = true
error.value = null
try {
const res = await fetch('/api/products')
if (!res.ok) throw new Error(`Request failed with status ${res.status}`)
products.value = await res.json()
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<p v-if="loading">Loading...</p>
<p v-else-if="error" role="alert">{{ error }}</p>
<ul v-else>
<li v-for="p in products" :key="p.id">{{ p.name }}</li>
</ul>
</template>fetch only rejects on network failure; a 404 or 500 resolves normally, so always check res.ok. Sending data uses the same call with method: 'POST', a 'Content-Type': 'application/json' header and body: JSON.stringify(payload).
When a search box fires a request per keystroke, responses can arrive out of order. Cancel the previous request with an AbortController inside a watcher cleanup:
import { ref, watch, onWatcherCleanup } from 'vue'
const query = ref('')
const results = ref([])
watch(query, async (q) => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal: controller.signal })
results.value = await res.json()
} catch (e) {
if (e.name !== 'AbortError') throw e
}
})Always encode user input in URLs with encodeURIComponent, and debounce the watcher (for example with VueUse's useDebounceFn) so the request fires only after the user pauses.
axios adds conveniences on top of fetch: automatic JSON parsing, rejection on non-2xx statuses, a params option, timeouts, and interceptors. Create one configured instance and import it everywhere:
// src/api/client.js
import axios from 'axios'
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 10000
})
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) window.location.assign('/login')
return Promise.reject(err)
}
)Usage: const { data } = await api.get('/products', { params: { page: 2 } }) or await api.post('/products', payload). Errors carry err.response.status and err.response.data, so components can show the server's message. Cancellation uses the same AbortController via the signal option.
| | fetch | axios |
| --- | --- | --- |
| Non-2xx handling | Resolves; check res.ok | Rejects automatically |
| JSON | await res.json() | Parsed into data |
| Interceptors | Write a wrapper | Built in |
| Upload progress | Not available | onUploadProgress |
Vite exposes variables prefixed with VITE_ from .env files as import.meta.env.VITE_*. Put VITE_API_URL in .env.development and .env.production; never put secrets there, because the client bundle is public.
During development, a proxy avoids CORS entirely by forwarding /api to the backend from the same origin:
// vite.config.js
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': { target: 'http://localhost:3000', changeOrigin: true }
}
}
})Keep request code out of components: a src/api/ folder with one module per resource (products.js exporting list(), get(id), create(data)), plus composables such as useFetch or a Pinia store for shared data. For caching, deduplication, retries and background refetching, consider TanStack Query (@tanstack/vue-query) with its useQuery and useMutation composables.
res.ok with fetch, so HTTP errors are rendered as data.loading = false outside finally, which leaves the spinner spinning after an error.<script setup> and expecting it to re-run when props change; use a watcher.What does `fetch` do when the server responds with status 500?
loading, error and data explicitly; reset loading in finally.fetch needs a res.ok check; axios rejects non-2xx responses and parses JSON automatically.AbortController in a watcher cleanup and encode user input in URLs.baseURL and interceptors for auth and error handling.VITE_ environment variables and the dev proxy; keep request code in src/api/ modules or composables.Next lesson: Error Handling in Vue Applications — capture render, async and routing errors with errorHandler, onErrorCaptured and error boundaries.