T
TanStack14h ago
deep-jade

Removed data with AsyncStoragePersister + PersistQueryClientProvider

We are currently facing a strange behaviour of React Query used in React Native. More often it happens on Android phones. For authentication we are using /refresh endpoint which consumes a refresh token and return a new access + refresh token pair. The TTL of the refresh token is 20 days, access token 15 mins. The refreshing works fine for a few hours, but usually after more than 12 hours (over night) the cache seems empty as the request is being sent withou the refresh token, fails, and the user needs to re-login. Looks like the garbage collection kicks in or something. The docs say
IMPORTANT - for persist to work properly, you probably want to pass QueryClient a gcTime value to override the default during hydration (as shown above). If it is not set when creating the QueryClient instance, it will default to 300000 (5 minutes) for hydration, and the stored cache will be discarded after 5 minutes of inactivity. This is the default garbage collection behavior.
does that mean the gcTime in useQuery is not enough and it needs to be set up in the QueryClient? But why would it work after 10 hours but not the next day?
1 Reply
deep-jade
deep-jadeOP14h ago
Setup: query client: export const queryClient = new QueryClient(); persister:
const prefixedStorage = (prefix: string) => ({
getItem: (key: string) => AsyncStorage.getItem(prefix + key),
setItem: (key: string, value: string) => AsyncStorage.setItem(prefix + key, value),
removeItem: (key: string) => AsyncStorage.removeItem(prefix + key),
});

export const queryPersister = createAsyncStoragePersister({
storage: prefixedStorage('query.'),
key: 'queryStorage',
});
const prefixedStorage = (prefix: string) => ({
getItem: (key: string) => AsyncStorage.getItem(prefix + key),
setItem: (key: string, value: string) => AsyncStorage.setItem(prefix + key, value),
removeItem: (key: string) => AsyncStorage.removeItem(prefix + key),
});

export const queryPersister = createAsyncStoragePersister({
storage: prefixedStorage('query.'),
key: 'queryStorage',
});
provider:
export function QueryClientProvider(props: WrapperProps) {
return (
<PersistQueryClientProvider client={queryClient} persistOptions={{ persister: queryPersister }}>
{props.children}
</PersistQueryClientProvider>
);
}
export function QueryClientProvider(props: WrapperProps) {
return (
<PersistQueryClientProvider client={queryClient} persistOptions={{ persister: queryPersister }}>
{props.children}
</PersistQueryClientProvider>
);
}
refresh hook:
export const useRefresh = () => {
return useQuery<AuthQueryDataType>({
queryKey: authQueryKey,
queryFn: useRefreshCall(),
retry: false,
retryOnMount: true,
refetchOnWindowFocus: false,
// Refresh 5 seconds before expiration
refetchInterval: ({ state }) => (state.data ? state.data.accessTokenExpiration - Date.now() - 5000 : false),
staleTime: ({ state }) => (state.data ? state.data.accessTokenExpiration - Date.now() : 0),
gcTime: 20 * 24 * 60 * 60 * 1000
});
};
export const useRefresh = () => {
return useQuery<AuthQueryDataType>({
queryKey: authQueryKey,
queryFn: useRefreshCall(),
retry: false,
retryOnMount: true,
refetchOnWindowFocus: false,
// Refresh 5 seconds before expiration
refetchInterval: ({ state }) => (state.data ? state.data.accessTokenExpiration - Date.now() - 5000 : false),
staleTime: ({ state }) => (state.data ? state.data.accessTokenExpiration - Date.now() : 0),
gcTime: 20 * 24 * 60 * 60 * 1000
});
};
axios interceptor:
const onBeforeRequest = useCallback(
async (config: InternalAxiosRequestConfig): Promise<InternalAxiosRequestConfig> => {
if (refreshCall.current && !config.isRefreshingCall) {
return refreshCall.current.then(() => onBeforeRequest(config));
}

if (Platform.OS === 'web') {
// Web uses cookies for authentication, so no need to attach tokens in headers
return config;
}

if (!isAxiosHeaders(config.headers)) {
config.headers = new AxiosHeaders();
}

const isUsingRefreshToken = config.useRefreshToken ?? false;
const tokens = queryClient.getQueryData<AuthQueryDataType>(authQueryKey);
const token = isUsingRefreshToken ? tokens?.refreshToken : tokens?.accessToken;
if (token) { // THERE IS THE PROBLEM - tokens?.refreshToken is falsy
config.headers.set(KnownHeaders.Authorization, createBearerValue(token));
}

return config;
},
[],
);
const onBeforeRequest = useCallback(
async (config: InternalAxiosRequestConfig): Promise<InternalAxiosRequestConfig> => {
if (refreshCall.current && !config.isRefreshingCall) {
return refreshCall.current.then(() => onBeforeRequest(config));
}

if (Platform.OS === 'web') {
// Web uses cookies for authentication, so no need to attach tokens in headers
return config;
}

if (!isAxiosHeaders(config.headers)) {
config.headers = new AxiosHeaders();
}

const isUsingRefreshToken = config.useRefreshToken ?? false;
const tokens = queryClient.getQueryData<AuthQueryDataType>(authQueryKey);
const token = isUsingRefreshToken ? tokens?.refreshToken : tokens?.accessToken;
if (token) { // THERE IS THE PROBLEM - tokens?.refreshToken is falsy
config.headers.set(KnownHeaders.Authorization, createBearerValue(token));
}

return config;
},
[],
);
Reading the docs there is the part with maxAge option, we are completely missing that part in PersistQueryClientProvider. That could be the culprit I guess?

Did you find this page helpful?