I'm building a Nuxt 3 app where I have a custom $fetch plugin to handle API requests, refresh expired tokens, and retry failed requests.
The fetch and retry mechanism work fine, but the state in my Pinia store and components does not update after a request is retried with a new token.
[1] In my component "transactions.vue" i am fetching the data on component creation
// fetch onCreate
transactionsStore.fetchTransactionsList({
page: 1,
});
[2] In my Pina Store i have this action to fetch the transactions and load them in the "transactions.vue"
async fetchTransactionsList(query) {
const api = useApiRepository();
const { status, data, error } = await useAsyncData(
"transactions_list",
() => api.getTransactionsList({
...query,
per_page: 20,
}),
);
this.transactionsData = data.value?.data ?? {};
this.transactionsStatus = status;
this.transactionsError = error;
return { status, data, error };
},
[3] I have a nuxt plugin to customize the default $fetch
import { defineNuxtPlugin } from "#app";
import { useLocalStorage } from "@vueuse/core";
import { useAuthStore } from "~/stores/AuthStore";
let api;
// Create a list to hold the Unauthorized 401 request queue
const refreshAndRetryQueue = [];
// Flag to prevent multiple token refresh requests
let isRefreshingToken = false;
// Counter for tracking the number of consecutive refresh attempts
let refreshAttempts = 0;
// Maximum number of consecutive refresh attempts allowed in Single
// Session {{ Without Refreshing the page }}
const MAX_REFRESH_ATTEMPTS = 25;
// Main function to handle response errors
function handleResponseError(nuxtApp, authStore) {
return async ({ request: requestURL, options, response }) => {
// Check if the current route is an auth route
const isAuthenticated = authStore?.isAuthenticated;
// Handle Unauthorized 401 error when the user is already logged-in
if (response.status === 401 && isAuthenticated) {
// If we're not already refreshing the token
if (!isRefreshingToken) {
isRefreshingToken = true;
// Increment the refresh attempts counter
refreshAttempts++;
// Check if we've exceeded the maximum number of refresh attempts
if (refreshAttempts > MAX_REFRESH_ATTEMPTS) {
// Too many refresh attempts, force logout
handleLogout(nuxtApp, authStore);
return;
}
// Call Refresh API and store the new token in the localStorage
const refreshResponse = await authStore.requestNewAccessToken();
if (refreshResponse.success) {
// Refresh successful. Retry all requests in the queue with the new token
refreshAndRetryQueue.forEach(({ requestURL, options }) => {
api(requestURL, {
...options,
});
});
// Clear the queue after retrying all requests
refreshAndRetryQueue.length = 0;
// Reset the refreshing flag
isRefreshingToken = false;
// Retry the original request that triggered the refresh
return api(requestURL, {
...options,
});
}
else {
// Refresh failed, log out the user
handleLogout(nuxtApp, authStore);
}
// Reset the refreshing flag
isRefreshingToken = false;
}
// If we're already refreshing (isRefreshingToken = true),
// add the request to the queue to be called after we finish the refreshing
return refreshAndRetryQueue.push({
requestURL,
options,
});
}
};
}
// Function to handle user logout
async function handleLogout(nuxtApp, authStore) {
// Clear local authentication data
authStore.clearLocalAuthData();
// Redirect to session-expired page
await nuxtApp.runWithContext(() => navigateTo("/auth/session-expired"));
// Reset States on Logout
refreshAttempts = 0;
isRefreshingToken = false;
}
// Main plugin function
export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig();
const authStore = useAuthStore();
// Create a custom instance of ofetch with global configurations
api = $fetch.create({
baseURL: config.public.apiBaseUrl,
// Add hooks for request and response
onRequest: handleRequest, //Already implemented but the function is not added here for simplicity
onResponseError: handleResponseError(nuxtApp, authStore),
});
// Provide the custom fetch instance to the app
return {
provide: {
api, // This can be used in the app as $api
},
};
});
The Issue:
I call transactionsStore.fetchTransactionsList({ page: 1 })
in my transactions.vue
component.
The fetchTransactionsList
action in my Pinia store (transactionsStore.js
) uses useAsyncData
to fetch transactions and update state.
When a 401
Unauthorized error occurs, the plugin refreshes the token, retries the request, and successfully fetches the data.
However, the new data does not reflect in my components, not in the Pinia states also
I'm building a Nuxt 3 app where I have a custom $fetch plugin to handle API requests, refresh expired tokens, and retry failed requests.
The fetch and retry mechanism work fine, but the state in my Pinia store and components does not update after a request is retried with a new token.
[1] In my component "transactions.vue" i am fetching the data on component creation
// fetch onCreate
transactionsStore.fetchTransactionsList({
page: 1,
});
[2] In my Pina Store i have this action to fetch the transactions and load them in the "transactions.vue"
async fetchTransactionsList(query) {
const api = useApiRepository();
const { status, data, error } = await useAsyncData(
"transactions_list",
() => api.getTransactionsList({
...query,
per_page: 20,
}),
);
this.transactionsData = data.value?.data ?? {};
this.transactionsStatus = status;
this.transactionsError = error;
return { status, data, error };
},
[3] I have a nuxt plugin to customize the default $fetch
import { defineNuxtPlugin } from "#app";
import { useLocalStorage } from "@vueuse/core";
import { useAuthStore } from "~/stores/AuthStore";
let api;
// Create a list to hold the Unauthorized 401 request queue
const refreshAndRetryQueue = [];
// Flag to prevent multiple token refresh requests
let isRefreshingToken = false;
// Counter for tracking the number of consecutive refresh attempts
let refreshAttempts = 0;
// Maximum number of consecutive refresh attempts allowed in Single
// Session {{ Without Refreshing the page }}
const MAX_REFRESH_ATTEMPTS = 25;
// Main function to handle response errors
function handleResponseError(nuxtApp, authStore) {
return async ({ request: requestURL, options, response }) => {
// Check if the current route is an auth route
const isAuthenticated = authStore?.isAuthenticated;
// Handle Unauthorized 401 error when the user is already logged-in
if (response.status === 401 && isAuthenticated) {
// If we're not already refreshing the token
if (!isRefreshingToken) {
isRefreshingToken = true;
// Increment the refresh attempts counter
refreshAttempts++;
// Check if we've exceeded the maximum number of refresh attempts
if (refreshAttempts > MAX_REFRESH_ATTEMPTS) {
// Too many refresh attempts, force logout
handleLogout(nuxtApp, authStore);
return;
}
// Call Refresh API and store the new token in the localStorage
const refreshResponse = await authStore.requestNewAccessToken();
if (refreshResponse.success) {
// Refresh successful. Retry all requests in the queue with the new token
refreshAndRetryQueue.forEach(({ requestURL, options }) => {
api(requestURL, {
...options,
});
});
// Clear the queue after retrying all requests
refreshAndRetryQueue.length = 0;
// Reset the refreshing flag
isRefreshingToken = false;
// Retry the original request that triggered the refresh
return api(requestURL, {
...options,
});
}
else {
// Refresh failed, log out the user
handleLogout(nuxtApp, authStore);
}
// Reset the refreshing flag
isRefreshingToken = false;
}
// If we're already refreshing (isRefreshingToken = true),
// add the request to the queue to be called after we finish the refreshing
return refreshAndRetryQueue.push({
requestURL,
options,
});
}
};
}
// Function to handle user logout
async function handleLogout(nuxtApp, authStore) {
// Clear local authentication data
authStore.clearLocalAuthData();
// Redirect to session-expired page
await nuxtApp.runWithContext(() => navigateTo("/auth/session-expired"));
// Reset States on Logout
refreshAttempts = 0;
isRefreshingToken = false;
}
// Main plugin function
export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig();
const authStore = useAuthStore();
// Create a custom instance of ofetch with global configurations
api = $fetch.create({
baseURL: config.public.apiBaseUrl,
// Add hooks for request and response
onRequest: handleRequest, //Already implemented but the function is not added here for simplicity
onResponseError: handleResponseError(nuxtApp, authStore),
});
// Provide the custom fetch instance to the app
return {
provide: {
api, // This can be used in the app as $api
},
};
});
The Issue:
I call transactionsStore.fetchTransactionsList({ page: 1 })
in my transactions.vue
component.
The fetchTransactionsList
action in my Pinia store (transactionsStore.js
) uses useAsyncData
to fetch transactions and update state.
When a 401
Unauthorized error occurs, the plugin refreshes the token, retries the request, and successfully fetches the data.
However, the new data does not reflect in my components, not in the Pinia states also
1 Answer
Reset to default 0I have a guess in what is wrong with your code.
Using useAsyncData
inside a Pinia store is not very good because it's designed for page-level data fetching and comes with built-in caching and reactivity behaviors that can interfere with how you manage state in a store. When you use useAsyncData
, especially with a static key, the returned data might be cached, meaning that even after a token refresh or a retried request, the store and components may not reflect the latest data.
By using plain async/await
within your store, you have full control over the asynchronous flow, error handling, and state updates. This approach avoids the caching issues and allows the store to update immediately once the new data is retrieved, ensuring that your components always display the latest information.
I'd change the code for something like this:
async fetchTransactionsList(query) {
const api = useApiRepository();
try {
// Make the API call using your custom $fetch instance
const response = await api.getTransactionsList({
...query,
per_page: 20,
});
// Update the store with the retrieved data.
// Adjust the property names based on your API response structure.
this.transactionsData = response.data?.data || {};
this.transactionsStatus = response.status || 200;
this.transactionsError = null;
return response;
} catch (error) {
// In case of an error, update the store accordingly.
this.transactionsData = {};
this.transactionsStatus = error.response?.status || error.status || 500;
this.transactionsError = error;
// Optionally, re-throw the error so the calling component is aware of the failure.
throw error;
}
}
By doing so, you prevent any side effects that useAsyncData
might be causing in your store.