Loading Service in Vue/Nuxt 3
A few approaches to a global loading service using Vue/Nuxt and PrimeVue
To create a global loading service that allows you to easily manage loading states and control a loading spinner, we’ll explore different approaches using Vue/Nuxt v3 with
the Composition API, the <script setup> syntax, and the PrimeVue v3 library's ProgressSpinner component.
First Approach: Composable
The first approach involves creating a composable that exports a basic loading service. We'll start by creating a composable with simple loading logic: a reactive
variable isLoading to manage the global loading state, and two methods, startLoading() and stopLoading().
//useLoading.ts
import { ref } from 'vue';
export function useLoading() {
const isLoading = ref(false);
const startLoading = () => {
isLoading.value = true;
};
const stopLoading = () => {
isLoading.value = false;
};
return {
isLoading,
startLoading,
stopLoading,
};
}
Implementation Example in a Component:
//example.vue
<template>
<div>
<ProgressSpinner v-if="isLoading" aria-label="Loading" />
<Home v-if="!isLoading" :products="products" />
</div>
</template>
<script setup>
import { useLoading } from '~/composables/useLoading';
import { onMounted } from 'vue';
import Home from '~/components/Home.vue'; // Assuming Home component exists
const { startLoading, stopLoading, isLoading } = useLoading();
onMounted(async () => {
startLoading();
try {
// Simulating fetching data
const products = await fetchProducts();
} catch (error) {
console.error('Error fetching products:', error);
} finally {
stopLoading();
}
});
async function fetchProducts() {
// Simulating API call
return new Promise((resolve) => {
setTimeout(() => {
resolve(['Product1', 'Product2']);
}, 2000);
});
}
</script>
This approach directly manages the loading state within the component. While it's a bit more verbose, it encapsulates the core logic, separates concerns, and is globally accessible. It’s a good starting point, but we can optimize it.
Second Approach: Using provide/inject Pattern
The provide/inject pattern in Vue 3 allows you to share state between a parent component and its descendants, passing data and methods down the component tree without explicit
prop drilling.
We'll create a useLoading.ts composable using this pattern, alongside a LoadingOverlay component to represent the loading UI.
//composables/useLoading.ts
import { ref, provide, inject } from 'vue';
interface LoadingContext {
isLoading: Ref<boolean>;
setLoading: (value: boolean) => void;
}
const LoadingSymbol = Symbol('loading');
export function provideLoading(): void {
const isLoading = ref(false);
function setLoading(value: boolean) {
isLoading.value = value;
}
provide(LoadingSymbol, {
isLoading,
setLoading,
});
}
export function useLoading(): LoadingContext {
const loading = inject(LoadingSymbol);
if (!loading) {
throw new Error('No loading provider found');
}
return loading as LoadingContext;
}
This creates a globally shared loading state accessible by components lower in the tree.
Creating the LoadingOverlay Component
<template>
<div v-if="isLoading" class="flex justify-content-center>
<ProgressSpinner />
</div>
</template>
<script setup>
import { useLoading } from '~/composables/useLoading';
const { isLoading } = useLoading();
</script>
Implementing the useLoading in a Child Component:
<template>
<Home v-if="!isLoading && products.length" :products="products" />
</template>
<script setup>
import { useLoading } from '~/composables/useLoading';
const { setLoading, isLoading } = useLoading();
// Simulating fetch logic
onMounted(async () => {
try {
setLoading(true);
const products = await fetchProducts();
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoading(false);
}
});
async function fetchProducts() {
// Simulating API call
return new Promise((resolve) => {
setTimeout(() => {
resolve(['Product1', 'Product2']);
}, 2000);
});
}
</script>
Providing the Loading State in a High-Level Component (e.g., app.vue):
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
<script setup>
provideLoading();
</script>
This ensures any component can access the global loading state using useLoading().
Third Approach: Using Event Broadcasting
If you need more flexibility to broadcast the loading state across components that aren't parent/child, you can use an event bus
like mitt. This allows unrelated components to listen for loading state changes without passing data through the
component tree.
Start by installing the mitt library:
`npm install mitt`
`yarn add mitt`
`pnpm add mitt`
Create a Global Loading Service (useLoadingBroadcast.ts):
//composables/useLoadingBroadcast.ts
import { ref } from 'vue';
import mitt from 'mitt';
type LoadingEvents = {
'loading:change': boolean;
};
const emitter = mitt<LoadingEvents>();
const isLoading = ref(false);
export function useLoadingBroadcast() {
function setLoading(value: boolean) {
isLoading.value = value;
emitter.emit('loading:change', value);
}
function onLoadingChange(callback: (value: boolean) => void) {
emitter.on('loading:change', callback);
}
function offLoadingChange(callback: (value: boolean) => void) {
emitter.off('loading:change', callback);
}
return {
isLoading,
setLoading,
onLoadingChange,
offLoadingChange,
};
}
Create a 'LoadingOverlay` Component:
//loading-overlay.vue
<template>
<div class="loading-overlay" v-if="isLoading">
<ProgressSpinner />
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { useLoadingBroadcast } from '~/composables/useLoadingBroadcast';
const { onLoadingChange, offLoadingChange } = useLoadingBroadcast();
const isLoading = ref(false);
const handleLoadingChange = (value) => {
isLoading.value = value;
};
onMounted(() => {
onLoadingChange(handleLoadingChange);
});
onUnmounted(() => {
offLoadingChange(handleLoadingChange);
});
</script>
Use LoadingOverlay in a High-Level Component (e.g.default.vue):
//default.vue
<template>
<LoadingOverlay />
<slot />
</template>
Implementing in a child component:
//example.vue
<template>
<div>
<Home v-if="!isLoading" :products="products" />
</div>
</template>
<script setup>
import { useLoadingBroadcast } from '~/composables/useLoadingBroadcast';
import { onMounted } from 'vue';
import Home from '~/components/Home.vue'; // Assuming Home component exists
const { setLoading, isLoading } = useLoadingBroadcast();
onMounted(async () => {
try {
setLoading(true);
const products = await fetchProducts();
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoading(false);
}
});
async function fetchProducts() {
// Simulating API call
return new Promise((resolve) => {
setTimeout(() => {
resolve(['Product1', 'Product2']);
}, 2000);
});
}
</script>
This setup ensures:
- The
setLoadingfunction emits an event when called. - The
LoadingOverlaylistens for these events and updates the loading state accordingly. - Any component can control the global loading state.
Conclusion
Implementing a global loading service in Vue/Nuxt 3 can be approached in several ways, depending on the complexity and structure of your application. The Composable approach is
ideal for small-scale use with minimal overhead, while the Provide/Inject pattern offers a more scalable solution for parent-child component trees. For greater flexibility,
especially when dealing with unrelated components, the Event Broadcasting approach using mitt provides a clean way to manage the loading state across your app. Each method
has its strengths, allowing you to tailor your solution to your project’s needs.