Markus Oberlehner

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


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.

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

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

3. Leveraging GraphQL

4. Managing State with Pinia

5. Integrating TanStack Query

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.