Loading Service in Vue/Nuxt 3

A few approaches to a global loading service using Vue/Nuxt and PrimeVue

Catarina Roseta • September 10, 2024

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:

  1. The setLoading function emits an event when called.
  2. The LoadingOverlay listens for these events and updates the loading state accordingly.
  3. 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.


Latest related articles