Mehedi Hassan Piash | Senior Software Engineer | Android | iOS | KMP | Ktor | Jetpack Compose | React-Native.

Showing posts with label RTK Query. Show all posts
Showing posts with label RTK Query. Show all posts

January 17, 2025

Authenticator in Axios with RTK Query in a React Native Application

January 17, 2025 Posted by Mehedi Hassan Piash , , , No comments

 In modern applications, implementing secure and seamless authentication mechanisms is crucial. One common approach is using access tokens with a refresh token mechanism. This article demonstrates how to integrate an automatic token refresh workflow using Axios, RTK Query, and Zustand for state management in a React Native app.

Axios authenticator with RTK Query

Overview of the Implementation

• Axios Interceptors: Intercepts requests and responses to add authentication tokens and handle token expiration.

• Zustand: Manages the authentication state, including storing and updating tokens.

• RTK Query: Handles API calls, including a mutation for refreshing the token.

Let’s start step by step…

1. Prerequisites

Before we dive into the code, ensure you have the required dependencies installed in your project. Use the following commands to install the necessary packages:

npm install @reduxjs/toolkit react-redux axios axios-logger @react-native-async-storage/async-storage zustand

2. Configuring Axios with Token Handling

Axios interceptors are used to add the token to outgoing requests and handle token expiration by refreshing it.

AxiosInstance.ts

import axios from 'axios';
import * as AxiosLogger from 'axios-logger';
import { expoMuseumApi } from '@/src/redux/RTKQuery';
import { useAppStore } from '@/src/zustand/useAppStore';
import configureAppStore from '@/src/store/Store';

const axiosInstance = axios.create();
axiosInstance.interceptors.request.use((config) => {
const token = useAppStore.getState().token;
// Add bearer token to headers
config.headers.Authorization = `Bearer ${token}`;
// Keep the original logger
AxiosLogger.requestLogger(config);
console.log('Authorization header', config.headers);
return config;
}, AxiosLogger.errorLogger);

axiosInstance.interceptors.response.use(
AxiosLogger.responseLogger,
AxiosLogger.errorLogger
);

axiosInstance.interceptors.response.use(
AxiosLogger.responseLogger, // Log response
async (error) => {
const originalRequest = error.config;

// Handle 401 (Unauthorized) errors
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true; // Prevent infinite retry loops

try {
// Trigger the RTK Query `postRefreshToken` mutation
const { data } = await configureAppStore().dispatch(
expoMuseumApi.endpoints.postRefreshToken.initiate()
);
// Update token in Zustand store
useAppStore.getState().saveToken(data as string);

// Update Authorization header with the new token
originalRequest.headers.Authorization = `Bearer ${useAppStore.getState().getToken()}`;

// Retry the original request
return axiosInstance(originalRequest);
} catch (refreshError) {
console.error('Failed to refresh token:', refreshError);
// Clear user data and token in Zustand store on failure
useAppStore.getState().clearToken();
//TODO [Navigate to login screen]

return Promise.reject(refreshError);
}
}

return Promise.reject(error);
}
);

export default axiosInstance;

3. Setting Up Zustand for Token Management

We use Zustand to manage the token state. The token is persisted locally using AsyncStorage to ensure it’s retained across app sessions.

useAppStore.ts

import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';

interface zustandConfig {
token: string;
saveToken: (token: string) => void;
getToken: () => string;
clearToken: () => void;
}

export const useAppStore = create<zustandConfig>()(
persist(
(set, get) => ({
token: '',
saveToken: (token: string) => set({ token }),
getToken: () => get().token,
clearToken: () => set({ token: '' }),
}),
{
name: '@expoMuseum',
storage: createJSONStorage(() => AsyncStorage),
}
)
);

4. RTK Query with Axios Base Query

Define a base query using Axios to integrate it with RTK Query.

BaseQuery.ts

import { AxiosError, AxiosRequestConfig } from 'axios';
import { BaseQueryFn } from '@reduxjs/toolkit/query/react';
import axiosInstance from '@/src/redux/AxiosInstance';
import { AppConstants } from '@/src/constants/AppConstant';

type AxiosBaseQueryArgs = {
baseUrl?: string;
};

type AxiosQueryParams = {
url: string;
method?: AxiosRequestConfig['method'];
data?: AxiosRequestConfig['data'];
params?: AxiosRequestConfig['params'];
headers?: AxiosRequestConfig['headers'];
};

const axiosBaseQuery =
(
{ baseUrl }: AxiosBaseQueryArgs = { baseUrl: AppConstants.BASE_URL }
): BaseQueryFn<AxiosQueryParams, unknown, unknown> =>
async ({ url, method = 'GET', data, params, headers }) => {
try {
const result = await axiosInstance({
url: baseUrl + url,
method,
data,
params,
headers,
});
return { data: result.data };
} catch (axiosError) {
const error = axiosError as AxiosError;
return {
error: {
status: error.response?.status || 500,
data: error.response?.data || error.message,
},
};
}
};

export default axiosBaseQuery;

5. RTK Query API Definition

Define an API slice that includes a mutation for refreshing tokens and other queries.

RTKQuery.ts

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { useAppStore } from '@/src/zustand/useAppStore';
import axiosBaseQuery from '@/src/redux/BaseQuery';

export const expoMuseumApi = createApi({
reducerPath: 'expoMuseumApi',
baseQuery: axiosBaseQuery(),
endpoints: (builder) => ({
postRefreshToken: builder.mutation<string, void>({
query: () => ({
url: 'auth/refreshToken',
method: 'POST',
headers: {
// Pass the refreshToken in the header while calling refresh token api
Authorization: `Bearer ${useAppStore().token}`,
},
}),
transformResponse: (response: string) => response,
}),
}),
});

export const { usePostRefreshTokenMutation } = expoMuseumApi;

6. Setting Up Redux Store

To integrate RTK Query into your app, you need to configure the Redux store and attach the API middleware. This step ensures that RTK Query manages the caching and lifecycle of API calls.

Store.ts

import { configureStore } from '@reduxjs/toolkit';
import { setupListeners } from '@reduxjs/toolkit/query';
import { expoMuseumApi } from '@/src/redux/RTKQuery';

const configureAppStore = () => {
const store = configureStore({
reducer: {
[expoMuseumApi.reducerPath]: expoMuseumApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
immutableCheck: false,
}).concat([expoMuseumApi.middleware]),
devTools: process.env.NODE_ENV === 'development',
});
setupListeners(store.dispatch);
return store;
};
export default configureAppStore;

Ref: https://github.com/piashcse/expo-museum
Medium: https://piashcse.medium.com/authenticator-in-axios-with-rtk-query-in-a-react-native-application-c9610c8e288d

September 28, 2024

RTK Query example code in react-native

September 28, 2024 Posted by Mehedi Hassan Piash , , , No comments

RTK Query is one of the best ways to handle data fetching efficiently, a data-fetching and caching tool that comes with the Redux Toolkit. In this article, we’ll walk through an example of using RTK Query in a React Native application.

We’ll demonstrate how to set up RTK Query to fetch movie data from TheMovieDB API and display it in your React Native app.

Movie with RTK Query

Prerequisites

Before we dive in, ensure you have the following set up:
• A React Native project
• Redux Toolkit installed
• RTK Query integrated into your Redux setup
• TheMovieDB API Key (Sign up at themoviedb.org)

Step 1: Install Dependencies
First, you need to install the required dependencies if you haven’t already:

npm install @reduxjs/toolkit react-redux
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import {Constants} from "../../appconstants/AppConstants.ts";
import {MovieResult} from "../../types/MovieResult.ts";
import {MovieItem} from "../../types/MovieItem.ts";
import {MovieDetail} from "../../types/MovieDetail.ts";
import {CastAndCrew} from "../../types/ArtistAndCrew.ts";
import {ArtistDetail} from "../../types/ArtistDetail.ts";

export const nowPlayingMovieApi = createApi({
reducerPath: 'nowPlayingMovieApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://api.themoviedb.org/3/' }),
endpoints: (builder) => ({
getNowPlayingMovie: builder.query<MovieItem[], number>({
query: (page) => `movie/now_playing?api_key=${Constants.API_KEY}&language=en-US?page=${page}`,
transformResponse: (response: MovieResult) => response.results
}),
}),
})

export const { useGetNowPlayingMovieQuery } = nowPlayingMovieApi;

export const popularMovieApi = createApi({
reducerPath: 'popularMovieApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://api.themoviedb.org/3/' }),
endpoints: (builder) => ({
getPopularMovie: builder.query<MovieItem[], number>({
query: (page ) => `movie/popular?api_key=${Constants.API_KEY}&language=en-US?page=${page}`,
transformResponse: (response: MovieResult) => response.results
}),
}),
})


export const {useGetPopularMovieQuery} = popularMovieApi;

export const topRatedMovieApi = createApi({
reducerPath: 'topRatedMovieApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://api.themoviedb.org/3/' }),
endpoints: (builder) => ({
getTopRatedMovie: builder.query<MovieItem[], number>({
query: (page) => `movie/top_rated?api_key=${Constants.API_KEY}&language=en-US?page=${page}`,
transformResponse: (response: MovieResult) => response.results
}),
}),
})
export const {useGetTopRatedMovieQuery} = topRatedMovieApi;


export const upcomingMovieApi = createApi({
reducerPath: 'upcomingMovieApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://api.themoviedb.org/3/' }),
endpoints: (builder) => ({
getUpcomingMovie: builder.query<MovieItem[], number>({
query: (page) => `movie/upcoming?api_key=${Constants.API_KEY}&language=en-US?page=${page}`,
transformResponse: (response: MovieResult) => response.results
}),
}),
})

export const {useGetUpcomingMovieQuery} = upcomingMovieApi;

export const movieDetailApi = createApi({
reducerPath: 'movieDetailApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://api.themoviedb.org/3/' }),
endpoints: (builder) => ({
getMovieDetail: builder.query<MovieDetail, number>({
query: (movieId) => `movie/${movieId}?api_key=${Constants.API_KEY}&language=en-US`,
transformResponse: (response: MovieDetail) => response
}),
}),
})

export const {useGetMovieDetailQuery} = movieDetailApi;


export const similarMovieApi = createApi({
reducerPath: 'similarMovieApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://api.themoviedb.org/3/' }),
endpoints: (builder) => ({
getSimilarMovie: builder.query<MovieItem[], number>({
query: (movieId) => `movie/${movieId}/recommendations?api_key=${Constants.API_KEY}&language=en-US`,
transformResponse: (response: MovieResult) => response.results
}),
}),
})
export const {useGetSimilarMovieQuery} = similarMovieApi;

export const artistAndCrewApi = createApi({
reducerPath: 'artistAndCrewApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://api.themoviedb.org/3/' }),
endpoints: (builder) => ({
getArtistAndCrew: builder.query<CastAndCrew, number>({
query: (movieId) => `movie/${movieId}/credits?api_key=${Constants.API_KEY}&language=en-US`,
transformResponse: (response: CastAndCrew) => response
}),
}),
})

export const {useGetArtistAndCrewQuery} = artistAndCrewApi;

export const artistDetailApi = createApi({
reducerPath: 'artistDetailApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://api.themoviedb.org/3/' }),
endpoints: (builder) => ({
getAristDetail: builder.query<ArtistDetail, number>({
query: (personId) => `person/${personId}?api_key=${Constants.API_KEY}&language=en-US`,
transformResponse: (response: ArtistDetail) => response
}),
}),
})

export const {useGetAristDetailQuery} = artistDetailApi;
import {configureStore} from '@reduxjs/toolkit'
import createSagaMiddleware from 'redux-saga';
import rootSaga from './sagas';
import logger from 'redux-logger';
import {
artistAndCrewApi,
artistDetailApi,
movieDetailApi,
nowPlayingMovieApi,
popularMovieApi,
similarMovieApi,
topRatedMovieApi,
upcomingMovieApi
} from './query/RTKQuery.ts'
import {setupListeners} from "@reduxjs/toolkit/query";

const sagaMiddleware = createSagaMiddleware();
const middleware = [sagaMiddleware];

const configurationAppStore = () => {
const store = configureStore({
reducer: {
[nowPlayingMovieApi.reducerPath]: nowPlayingMovieApi.reducer,
[popularMovieApi.reducerPath]: popularMovieApi.reducer,
[topRatedMovieApi.reducerPath]: topRatedMovieApi.reducer,
[upcomingMovieApi.reducerPath]: upcomingMovieApi.reducer,
[movieDetailApi.reducerPath]: movieDetailApi.reducer,
[similarMovieApi.reducerPath]: similarMovieApi.reducer,
[artistAndCrewApi.reducerPath]: artistAndCrewApi.reducer,
[artistDetailApi.reducerPath]: artistDetailApi.reducer,
},
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat([...middleware, logger,
nowPlayingMovieApi.middleware,
popularMovieApi.middleware,
topRatedMovieApi.middleware,
upcomingMovieApi.middleware,
movieDetailApi.middleware,
similarMovieApi.middleware,
artistAndCrewApi.middleware,
artistDetailApi.middleware
]),
devTools: process.env.NODE_ENV === 'development'
})
setupListeners(store.dispatch)
sagaMiddleware.run(rootSaga);
return store
}
export default configurationAppStore

Types

import {MovieItem} from "./MovieItem";
import {Dates} from "./Dates";

export interface MovieResult {
dates: Dates
page: number
results: MovieItem[]
total_pages: number
total_results: number
}
export interface MovieItem {
adult: boolean
backdrop_path: string
genre_ids: number[]
id: number
original_language: string
original_title: string
overview: string
popularity: number
poster_path: string
release_date: string
title: string
video: boolean
vote_average: number
vote_count: number
}
export interface Dates {
maximum: string
minimum: string
}

MovieComponent.tsx

import React, {useState} from 'react';
import {FlatList, Image, View, TouchableOpacity, ImageBackground} from "react-native";
import styles from "./MovieListStyle";
import {Constants} from "../../appconstants/AppConstants";
import {MovieItem} from "../../types/MovieItem";

interface MovieItemProps {
movies: Array<MovieItem>;
onPress: (item: MovieItem) => void;
loadMoreData: () => void
}

const MovieComponent = (props: MovieItemProps) => {
const {movies, onPress, loadMoreData} = props;
const [isLoading, setIsLoading] = useState(true)
const movieItem = ({item}: { item: MovieItem }) => {
return (<TouchableOpacity style={styles.movieItemContainer} onPress={() => onPress(item)}>
<ImageBackground
imageStyle={{borderRadius: 18}}
source={isLoading ? require('../../assets/placeholder.jpeg') : {uri: `${Constants.IMAGE_URL}${item.poster_path}`}}
>
<Image
style={styles.imageView}
source={{
uri: `${Constants.IMAGE_URL}${item.poster_path}`,
}}
onLoadEnd={() => {
setIsLoading(false)
}}
/>
</ImageBackground>
</TouchableOpacity>)
};

return (<View style={styles.mainView}>
<FlatList
style={styles.flatListContainer}
data={movies}
renderItem={movieItem}
numColumns={2}
keyExtractor={(item, index) => index.toString()}
onEndReachedThreshold={0.5}
onEndReached={loadMoreData}
/>
</View>);
}

export default MovieComponent

Home.tsx

import React, {useEffect, useState} from 'react';
import Loading from '../../components/loading/Loading';
import MovieComponent from '../../components/movielist/MovieComponent.tsx';
import {View} from 'react-native';
import styles from './HomeStyle'
import {useGetNowPlayingMovieQuery} from "../../redux/query/RTKQuery.ts";
import {useNavigation} from "@react-navigation/native";
import {MovieItem} from "../../types/MovieItem.ts";


const Home = () => {
const navigation = useNavigation();
const [page, setPage] = useState(1);
const [movies, setMovies] = useState<Array<MovieItem>>([]);
const {data, error, isLoading, isFetching} = useGetNowPlayingMovieQuery(page)
useEffect(() => {
if (data && page > 1) {
setMovies((prevMovies) => [...prevMovies, ...data]);
}else {
setMovies(data ?? []);
}
}, [page, data?.length]);

const loadMoreMovies = () => {
if (!isFetching && !isLoading && !error) {
setPage( page + 1);
}
};

if (isLoading) return <Loading/>;

return (<View style={styles.mainView}>
<MovieComponent
movies={movies}
onPress={(item) => navigation.navigate('MovieDetail', {movieId: item.id})}
loadMoreData={loadMoreMovies}/>
</View>);
}
export default Home;

GitHub: https://github.com/piashcse/react-native-movie
Medium: https://piashcse.medium.com/rtk-query-example-code-in-react-native-e9a0bd402d8f