Reactive Data Fetching and Updating in Nuxt 3: Automatically Refresh `useFetch()` When Deleting or Updating Data

  You block advertising 😒
Would you like to buy me a β˜•οΈ instead?

Managing data across different components is a common challenge when working with modern web frameworks. Imagine a typical scenario where actions (e.g., deleting or updating data) in one component must reflect changes in anotherβ€”for example, fetching a list of items, like products, and seamlessly reflecting updates like deletions across the app. Let’s dive into a solution that addresses this challenge effectively.

The Challenge with Keeping Data Consistent Across a Nuxt Application

Imagine a Nuxt 3 app where you fetch a list of products using useFetch('/api/products') in a page component. Deep in the component tree lies a ProductButtonDelete component. When a user clicks the button, we trigger a $fetch('/api/products/1') call. After deleting the product, we want the product list to update, removing the deleted product.

One way to tackle this could be to make heavy use of slots to avoid a deeply nested component tree:

<script setup lang="ts">
  const { data: products, refresh } = useFetch('/api/products');

  const remove = async (id: number) => {
    await $fetch(`/api/products/${id}`, { method: 'DELETE' });
    refresh();
  };
</script>

<div>
  <ProductList>
    <ProductListItem v-for="product in products">
      <ProductButtonDelete @remove="remove(product.id)" />
    </ProductListItem>
  </ProductList>
</div>

However, avoiding deeply nested component trees might not always be possible. Another straightforward (yet tedious) way to solve the problem whenever we can’t prevent deeply nested components could be bubbling up events in the entire component tree. But this can be fragile when refactoring.

components/
└── ProductList.vue
    └── ProductListItem.vue
        └── ProductDetail.vue
            β”œβ”€β”€ ProductDescription.vue
            β”œβ”€β”€ ProductPrice.vue
            └── ProductActions.vue
                β”œβ”€β”€ ProductButtonEdit.vue
                └── ProductButtonDelete.vue

Imagine the example above: ProductButtonDelete is nested four levels deep. If we want to update the product list after deleting a product, we must bubble up the event four levels. Using slots also becomes too tedious because we must arrange all the components in the correct slots everywhere we want to render a list of products.

The Solution for Automatically Refreshing useFetch() When Deleting or Updating Data

My proposed solution involves creating a global context for managing refresh handlers keyed by API endpoints. This approach allows any component to trigger updates seamlessly.

1. Creating a Global API Context and Helpers

First, we create a plugin to manage useFetch() refresh handlers:

// plugins/api.ts
type Handler = () => Promise<void>;
type Path = string;

export default defineNuxtPlugin(() => {
  let refreshHandlers: { path: Path; handler: Handler }[] = [];

  const $apiContext = {
    addRefreshHandler(path: Path, handler: Handler) {
      refreshHandlers.push({ path, handler });
    },
    removeRefreshHandler(handler: Handler) {
      refreshHandlers = refreshHandlers.filter((h) => h.handler !== handler);
    },
    async refreshIncludedPaths(path: string) {
      const matchingHandlers = refreshHandlers.filter((r) =>
        path.includes(r.path),
      );
      await Promise.all(matchingHandlers.map((h) => h.handler()));
    },
  };

  const $api = $fetch.create({
    onResponse(context) {
      if (
        context.options.method === undefined ||
        context.options.method === 'GET'
      )
        return;
      if (typeof context.request !== 'string') return;
      $apiContext.refreshIncludedPaths(context.request);
    },
  });

  return {
    provide: {
      api: $api,
      apiContext: $apiContext,
    },
  };
});

Here, we define a Nuxt plugin that abstracts away the complexities of fetching and refreshing data from an API.

  • addRefreshHandler(): Allows registering new refresh handlers we get when calling useFetch(). Handlers are associated with a path.
  • removeRefreshHandler(): Enables cleaning up handlers, ensuring that it’s no longer called during refreshes.
  • refreshIncludedPaths(): Triggers all handlers whose paths are included in the given path. This allows for a selective data refresh based on the context (identified by the path).
  • $api(): Wraps $fetch to trigger refreshIncludedPaths() and therefore refreshes all relevant useFetch() data across the whole app whenever we update or delete data.

The refreshIncludedPaths() function is a key piece of logic. So, let’s take a closer look:

const $apiContext = {
  // ...
  async refreshIncludedPaths(path: string) {
    // `path` might be `/api/products/1` and `r.path` might be `/api/products`
    const matchingHandlers = refreshHandlers.filter((r) =>
      path.includes(r.path),
    );
    await Promise.all(matchingHandlers.map((h) => h.handler()));
  },
  // ...
};

Assume we call addRefreshHandler('/api/products', refresh) with the refresh() handler returned from a useFetch('/api/products') call. Furthermore, when we delete a product by calling $fetch('/api/products/1', { method: 'DELETE' }), we also call refreshIncludedPaths('/api/products/1'). Now the filter() call in refreshIncludedPaths() comes down to '/api/products/1'.includes('/api/products') which ensures all refresh() handlers returned by useFetch('/api/products') calls get executed.

Of course, this might be too broad in some cases. For example, suppose we have a page that fetches a list of products (useFetch('/api/products')) and a component that fetches a filtered list of products (useFetch('/api/products', { query: { category: 'Hardware' } })). In that case, we might refresh one of the two unnecessarily. Depending on your use case, you might need a more sophisticated approach to determine which handlers to execute. Yet, for many use cases, a little overfetching probably is acceptable.

I also thought about using refreshNuxtData(). Yet a problem I’ve faced is that it is either way too broad (if we don’t specify any keys, it refreshes all the data) or too granular when using it with a key as a parameter. For, when fetching data from /api/products and /api/products?category=hardware, we would have to call refreshNuxtData() with two different keys for those two calls or call it without the keys param and refresh all data not only data coming from /api/products*. Another possibility would be to use custom keys in combination with useLazyAsyncData(), but then we have to create unique keys ourselves, which is tedious and affects other things like caching.

2. Using the API Context in a Composable

Next, we integrate this context with the Nuxt’s useFetch:

// composables/api.ts
import type { UseFetchOptions } from 'nuxt/app';

export const useApi = () => {
  const nuxtApp = useNuxtApp();

  return {
    invoke: nuxtApp.$api,
    useGet: <T>(
      url: Parameters<typeof $fetch>[0],
      options: Omit<'method', UseFetchOptions<T>> = {},
    ) => {
      const { refresh, ...rest } = useFetch(url, {
        ...options,
        $fetch: nuxtApp.$api,
      });

      const handler = async () => {
        await refresh();
      };
      if (typeof url === 'string') {
        nuxtApp.$apiContext.addRefreshHandler(url, handler);
      }

      onUnmounted(() => {
        nuxtApp.$apiContext.removeRefreshHandler(handler);
      });

      return {
        ...rest,
        refresh,
      };
    },
  };
};

In this composable, we return invoke() and useGet() functions where invoke() is for triggering updates and deletions and useGet() is a replacement for useFetch(), which we always want to use when loading data.

3. Using the API Composable in Components

Now, we can apply this pattern to the product list scenario:

// composables/product.ts
export const useProduct = () => {
  const { invoke, useGet } = useApi();

  return {
    useList() {
      return useGet('/api/products');
    },
    async remove(id: number) {
      await invoke(`/api/products/${id}`, {
        method: 'DELETE',
      });
    },
  };
};

Finally, we implement the fetching and deletion in the components:

<!-- Fetching products -->
<script setup lang="ts">
  const { useList } = useProduct();
  const { data: products } = await useList();
</script>
<!-- Deleting a product -->
<script setup lang="ts">
  const { remove } = useProduct();

  const props = defineProps<{
    id: number;
  }>();
</script>

<template>
  <BaseButton @click="remove(props.id)">Delete</BaseButton>
</template>

Alternatives

While the global API context approach above effectively is an effective solution to the problem of keeping data in sync, it’s worth considering other alternatives. Here’s a more detailed look at a couple of options:

1. Event Bubbling Through Component Layers

  • Concept: Stick to the classic approach of passing data down through props and bubble events up the component tree.
  • Pros: This method is straightforward and doesn’t require external dependencies.
  • Cons: It can become cumbersome and error-prone when moving around components in deeply nested component trees.

2. Using a Real-time Database Like Supabase or Firebase

  • Concept: Leveraging real-time databases like Supabase or Firebase, which offer built-in subscription functionalities. When data changes in the database, subscribed components automatically receive updates.
  • Pros: Offers real-time updates; no need for a manual refresh logic.
  • Cons: It requires specific database providers and might not fit all project requirements or existing infrastructures.

3. Leveraging GraphQL

  • Concept: Utilizing GraphQL with Vue Apollo.
  • Pros: Efficient data fetching, as you request only the necessary data. Also, some GraphQL clients offer built-in state management and caching mechanisms.
  • Cons: Introduces a new layer of complexity and requires a GraphQL server setup.

4. Managing State with Pinia

  • Concept: Using Pinia, the officially recommended state management library for Vue.js, to centralize and manage application state, including API data.
  • Pros: Offers a single source of truth for application state, which can be reactive and accessible from any component.
  • Cons: Requires careful state management design and might be overkill for simple data fetching scenarios.

5. Integrating TanStack Query

  • Concept: Implementing TanStack Query (formerly React Query), which handles data fetching, caching, synchronization, and updating.
  • Pros: Provides powerful tools for data synchronization, background updates, and caching, reducing the need to handle these aspects manually.
  • Cons: Adds additional complexity and learning curve.

Each of these alternatives offers distinct advantages and potential drawbacks. The choice largely depends on the specific requirements of your project, the existing tech stack and the level of control or simplicity you desire in managing data flows within your application.

Wrapping It Up

The proposed solution offers a robust and scalable way to manage reactive data fetching in Nuxt 3 applications. By creating and integrating a global API context with Nuxt’s useFetch(), we can seamlessly reflect data changes across the application, reducing the need for complex event handling or state management. This approach can significantly simplify data management in large-scale applications.


Do you want to learn how to build advanced Vue.js applications?

Register for the Newsletter of my upcoming book: Advanced Vue.js Application Architecture.



Do you enjoy reading my blog?

You can buy me a β˜•οΈ on Ko-fi!

β˜•οΈ Support Me on Ko-fi