Skip to content

Handle async/await operations in Vue.js

Async operations are essential in Vue.js for dynamic UIs. This article explores using async/await within both the Composition API and Options API's lifecycle hooks to manage tasks like data fetching, while demonstrating how to effectively handle loading states and errors for a seamless user experience.

Using async/await in Vue components

You can use async/await in your Vue components to handle tasks like fetching data when a component loads or when a user clicks a button.

For more details on Vue's component lifecycle, refer to the official documentation.

With the Composition API

If you're using Vue 3's Composition API, you can use async/await directly within the setup() function or inside lifecycle hooks like onMounted.

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

const posts = ref([]);
const isLoading = ref(true);
const error = ref(null);

onMounted(async () => {
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/posts');
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    posts.value = await response.json();
  } catch (err) {
    error.value = 'Failed to load posts: ' + err.message;
  } finally {
    isLoading.value = false;
  }
});
</script>

<template>
  <div>
    <h2>Blog Posts</h2>
    <div v-if="isLoading">Loading posts...</div>
    <div v-else-if="error">{{ error }}</div>
    <ul v-else>
      <li v-for="post in posts" :key="post.id">{{ post.title }}</li>
    </ul>
  </div>
</template>

In this example, onMounted is an async function. We use await to wait for the data to be fetched and parsed. We also manage isLoading and error states to give feedback to the user.

With the Options API

If you're using Vue 2 or Vue 3's Options API, you can use async/await within your methods or lifecycle hooks like created or mounted.

vue
<script>
export default {
  data() {
    return {
      users: [],
      isLoading: true,
      error: null
    };
  },
  async created() {
    try {
      const response = await fetch('https://jsonplaceholder.typicode.com/users');
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      this.users = await response.json();
    } catch (err) {
      this.error = 'Failed to load users: ' + err.message;
    } finally {
      this.isLoading = false;
    }
  }
};
</script>

<template>
  <div>
    <h2>User List</h2>
    <div v-if="isLoading">Loading users...</div>
    <div v-else-if="error">{{ error }}</div>
    <ul v-else>
      <li v-for="user in users" :key="user.id">{{ user.name }}</li>
    </ul>
  </div>
</template>

Here, the created hook is made async, allowing us to await the network requests for user data.

Managing loading states

Providing visual feedback to your users is very important. When you fetch data, there's a short period where the data hasn't arrived yet. During this time, you should show a loading indicator (like a spinner or a "Loading..." message).

You can achieve this by using a simple isLoading boolean variable.

javascript
// In Composition API
const isLoading = ref(true); // Start as true

// In Options API
data() {
  return {
    isLoading: true, // Start as true
  };
},

// ... inside your async function
try {
  // ... fetch data
} catch (error) {
  // ... handle error
} finally {
  isLoading.value = false; // Set to false when done (whether success or error)
}

This way, your template can show different content based on the isLoading state.

vue
<template>
  <div v-if="isLoading">
    <p>Loading data, please wait...</p>
    </div>
  <div v-else>
    </div>
</template>

Error handling

Even with async/await, things can go wrong. Network requests might fail, or the server might return an error. It's crucial to handle these errors gracefully to prevent your application from crashing and to inform the user.

The best way to handle errors with async/await is by using a try...catch block:

javascript
async function fetchDataWithErrorHandling() {
  try {
    const response = await fetch('https://api.example.com/nonexistent-data');
    if (!response.ok) {
      // If the server response was not successful (e.g., 404, 500)
      throw new Error(`HTTP error! Status: ${response.status}`);
    }
    const data = await response.json();
    console.log("Data:", data);
  } catch (error) {
    // This block runs if any error occurs in the try block
    console.error("Failed to fetch data:", error.message);
    // You can also update a data property to show an error message in your UI
    // this.errorMessage = 'Could not load data. Please try again.';
  }
}

fetchDataWithErrorHandling();

The finally block (optional) is useful if you have code that should always run, regardless of whether the try block completed successfully or encountered an error. A common use for finally is to hide a loading indicator.

Example: Fetching data from an external API

Let's combine what we've learned to create a simple Vue component that fetches a list of todos from a public API and displays them. It will include loading and error handling.

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

const todos = ref([]);
const isLoading = ref(true);
const errorMessage = ref(null);

onMounted(async () => {
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5');
    if (!response.ok) {
      // Check for HTTP errors (e.g., 404 Not Found, 500 Internal Server Error)
      throw new Error(`Failed to fetch todos: ${response.status} ${response.statusText}`);
    }
    todos.value = await response.json();
  } catch (error) {
    // Catch any network errors or errors thrown above
    errorMessage.value = `Error: ${error.message}. Please try again later.`;
    console.error(error);
  } finally {
    // This code runs whether the fetch was successful or not
    isLoading.value = false;
  }
});
</script>

<template>
  <div class="todo-list-container">
    <h2>My To-Do List</h2>

    <div v-if="isLoading" class="loading-state">
      <p>Loading tasks...</p>
      </div>

    <div v-else-if="errorMessage" class="error-state">
      <p>{{ errorMessage }}</p>
      <p>Check your internet connection or try refreshing the page.</p>
    </div>

    <ul v-else class="todo-items">
      <li v-for="todo in todos" :key="todo.id" :class="{ completed: todo.completed }">
        {{ todo.title }}
      </li>
    </ul>
  </div>
</template>

<style scoped>
.todo-list-container {
  max-width: 600px;
  margin: 20px auto;
  padding: 20px;
  border: 1px solid #eee;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

h2 {
  color: #333;
  text-align: center;
  margin-bottom: 20px;
}

.loading-state, .error-state {
  text-align: center;
  padding: 20px;
  color: #555;
}

.error-state {
  color: #d9534f; /* Red for errors */
}

.todo-items {
  list-style: none;
  padding: 0;
}

.todo-items li {
  background-color: #f9f9f9;
  border: 1px solid #ddd;
  padding: 10px 15px;
  margin-bottom: 8px;
  border-radius: 4px;
  display: flex;
  align-items: center;
}

.todo-items li.completed {
  text-decoration: line-through;
  color: #888;
  background-color: #e6ffe6; /* Light green for completed */
  border-color: #c3e6cb;
}
</style>

This complete example demonstrates how async/await simplifies the process of fetching data, showing a loading state, and handling potential errors.

Best practices and important notes

  • Avoid unnecessary await: Only await when you actually need the result of a Promise before continuing. If you can perform operations in parallel, do so. For example, if you need to fetch two independent sets of data, you can start both fetches without awaiting the first one, then await both results using Promise.all().

    javascript
    // Bad (sequential)
    const data1 = await fetchData1();
    const data2 = await fetchData2();
    
    // Good (parallel)
    const [data1, data2] = await Promise.all([fetchData1(), fetchData2()]);
  • Consider component lifecycle: Make sure your asynchronous operations are cancelled or cleaned up if the component is unmounted before the operation finishes. This prevents memory leaks. For simple data fetching in onMounted, Vue often handles this gracefully, but for long-running operations like WebSockets, you might need onBeforeUnmount.