DEV Community

Cover image for Efficient Data Handling in React Native with RTK Query
Ajmal Hasan
Ajmal Hasan

Posted on

Efficient Data Handling in React Native with RTK Query

In this guide, we’ll cover:

  • CRUD Operations
  • Pagination
  • Redux Persist with RTK Query
  • Multiple Base URL Usage
  • Protected and Public Routes
  • Cache Management and Invalidation

Image description

RTK Query is an advanced data-fetching and caching tool built into Redux Toolkit (RTK). It streamlines API interactions by generating Redux slices and hooks for common tasks like fetching, caching, and updating data. Key features include:

  1. Automatic Caching: RTK Query caches data and automatically re-fetches it when data is invalidated, ensuring the UI always has the latest data.
  2. Cache Invalidation: Using tags, RTK Query lets you define when certain data should be re-fetched. This helps keep your cache fresh without manually updating data.
  3. Auto-Generated Hooks: RTK Query creates hooks for each API endpoint, allowing you to call APIs using simple React hooks (useGetPostsQuery, useCreatePostMutation, etc.).
  4. Error Handling: Includes custom error handling through middleware, making it easy to catch and display errors.
  5. Simplified Redux Integration: RTK Query integrates directly with Redux, so you don’t need extra libraries for global state management or caching.

RTK Query vs. React Query

Both React Query and RTK Query provide solutions for data fetching and caching in React applications, but they have different strengths and use cases:

Feature RTK Query React Query
Purpose Integrated within Redux for managing server data in Redux state. Best for apps already using Redux or requiring centralized global state. Dedicated to managing server state with no Redux dependency. Great for apps focused on server state without Redux.
Caching Automatic caching with fine-grained cache invalidation through tags. Caches data globally within the Redux store. Automatic caching with flexible cache control policies. Maintains a separate cache independent of Redux.
Generated Hooks Auto-generates hooks for endpoints, allowing mutations and queries using useQuery and useMutation hooks. Provides hooks (useQuery, useMutation) that work independently from Redux, but require manual configuration of queries and mutations.
DevTools Integrated into Redux DevTools, making debugging seamless for Redux users. Provides its own React Query DevTools, with detailed insight into query states and cache.
Error Handling Centralized error handling using Redux middleware. Error handling within individual queries, with some centralized error-handling options.
Redux Integration Built directly into Redux, simplifying usage for Redux-based apps. Not integrated with Redux by default, although Redux and React Query can be combined if needed.

Choosing Between RTK Query and React Query:

  • Use RTK Query if:

    • You’re already using Redux and want an integrated, streamlined solution for data fetching.
    • You need centralized error handling and devtools integration within Redux.
  • Use React Query if:

    • You want a more lightweight setup without Redux dependency.
    • You prefer separate server-state management and don’t need global app state.

In essence, RTK Query excels for Redux-centric applications, while React Query provides flexibility and simplicity for projects without Redux or those with a more localized server state management focus.


Image description



1. Store Configuration and Setup

// src/store/store.js
import AsyncStorage from '@react-native-async-storage/async-storage';
import { combineReducers, configureStore, isRejectedWithValue } from '@reduxjs/toolkit';
import { setupListeners } from '@reduxjs/toolkit/query';
import { FLUSH, PAUSE, PERSIST, persistReducer, PURGE, REGISTER, REHYDRATE } from 'redux-persist';
import { authApi } from '../api/authApi';
import { postsApi } from '../api/postsApi';
import { usersApi } from '../api/usersApi';
import authSlice from '../features/auth/authSlice';

const persistConfig = {
  key: 'root',
  version: 1,
  storage: AsyncStorage,
  blacklist: ['auth', postsApi.middleware, usersApi.middleware, authApi.middleware], // these reduce will not persist data (NOTE: blacklist rtk api slices so that to use tags)
  // whitelist: ['users'], //these reduce will persist data
};

const getEnhancers = (getDefaultEnhancers) => {
  if (process.env.NODE_ENV === 'development') {
    const reactotron = require('../reactotronConfig/ReactotronConfig').default;
    return getDefaultEnhancers().concat(reactotron.createEnhancer());
  }
  return getDefaultEnhancers();
};

/**
 * On api error this will be called
 */
export const rtkQueryErrorLogger = (api) => (next) => (action) => {
  // RTK Query uses `createAsyncThunk` from redux-toolkit under the hood, so we're able to utilize these matchers!
  if (isRejectedWithValue(action)) {
    console.log('isRejectedWithValue', action.error, action.payload);
    alert(JSON.stringify(action)); // This is just an example. You can replace it with your preferred method for displaying notifications.
  }

  return next(action);
};

const reducer = combineReducers({
  auth: authSlice,
  [postsApi.reducerPath]: postsApi.reducer,
  [usersApi.reducerPath]: usersApi.reducer,
  [authApi.reducerPath]: authApi.reducer,
});
const persistedReducer = persistReducer(persistConfig, reducer);

const store = configureStore({
  reducer: persistedReducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
      },
    }).concat(postsApi.middleware, usersApi.middleware, authApi.middleware, rtkQueryErrorLogger),
  enhancers: getEnhancers,
});

setupListeners(store.dispatch);

export default store;

Enter fullscreen mode Exit fullscreen mode
  • Redux Store (src/store/store.js): The Redux store is the main structure holding the application's state. In your setup, it’s enhanced with redux-persist to save certain parts of the Redux state locally, so they persist even when the app restarts.

  • redux-persist:

    • Purpose: Helps keep parts of the Redux state persistent across app sessions.
    • Configuration: A persistConfig object specifies that auth, postsApi, and usersApi should not be persisted (blacklisted), meaning their data resets on app restart.
    • persistReducer combines the reducer configuration with persistence functionality.
  • Enhancers: Custom enhancers are used to integrate Reactotron in development mode, a helpful tool for debugging Redux actions, state, and network requests. This only activates in development, making debugging easier without affecting production.

  • Middleware:

    • RTK Query middlewares (postsApi.middleware, usersApi.middleware, authApi.middleware) add functionality for automatic cache management, making data fetching efficient.
    • rtkQueryErrorLogger: A custom middleware logs errors when API calls fail. It uses RTK Query’s isRejectedWithValue function to catch and handle errors, allowing you to alert users about issues or take other actions.
  • setupListeners: This function enables automatic re-fetching of data when certain events occur, like when the app regains focus or resumes from the background, providing users with fresh data without manual refresh.



2. API Definitions with RTK Query

RTK Query simplifies API calls by auto-generating Redux slices, hooks, and caching. Here’s a breakdown of the APIs you defined:

// src/api/authApi.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { setToken } from '../features/auth/authSlice';

export const authApi = createApi({
  reducerPath: 'authApi',
  baseQuery: fetchBaseQuery({
    baseUrl: 'https://dummyjson.com/auth/',
  }),
  endpoints: (builder) => ({
    login: builder.mutation({
      query: (credentials) => ({
        url: 'login',
        method: 'POST',
        body: credentials,
      }),
      async onQueryStarted(arg, { dispatch, queryFulfilled }) {
        try {
          const { data } = await queryFulfilled;
          dispatch(setToken(data.accessToken)); // Store the token in Redux
        } catch (error) {
          console.error('Login error:', error);
        }
      },
    }),
  }),
});

export const { useLoginMutation } = authApi;
Enter fullscreen mode Exit fullscreen mode
  • authApi (src/api/authApi.js):
    • Defines a login mutation that sends user credentials (e.g., username, password) to the server to authenticate.
    • onQueryStarted: Once login is successful, it stores the returned token in Redux using the setToken action. This enables secure, authenticated requests to other endpoints.

// src/api/postsApi.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

// Define the postsApi slice with RTK Query
export const postsApi = createApi({
  // Unique key for the API slice in Redux state
  reducerPath: 'postsApi',

  // Configure base query settings, including the base URL for all requests
  baseQuery: fetchBaseQuery({
    baseUrl: 'https://jsonplaceholder.typicode.com',
  }),

  // Define cache tag types for automatic cache invalidation
  tagTypes: ['Posts'],

  // Define API endpoints (queries and mutations)
  endpoints: (builder) => ({
    // Query to fetch a paginated list of posts
    getPosts: builder.query({
      // URL and parameters for paginated posts
      query: ({ page = 1, limit = 10 }) => `/posts?_page=${page}&_limit=${limit}`,

      // Tagging posts to automatically refresh this cache when needed
      providesTags: (result) =>
        result
          ? [...result.map(({ id }) => ({ type: 'Posts', id })), { type: 'Posts', id: 'LIST' }]
          : [{ type: 'Posts', id: 'LIST' }],
    }),

    // Query to fetch a single post by its ID
    getPostById: builder.query({
      // Define query with post ID in the URL path
      query: (id) => `/posts/${id}`,

      // Tag individual post by ID for selective cache invalidation
      providesTags: (result, error, id) => [{ type: 'Posts', id }],
    }),

    // Mutation to create a new post
    createPost: builder.mutation({
      // Configure the POST request details and payload
      query: (newPost) => ({
        url: '/posts',
        method: 'POST',
        body: newPost,
      }),

      // Invalidate all posts (paginated list) to refresh after creating a post
      invalidatesTags: [{ type: 'Posts', id: 'LIST' }],
    }),

    // Mutation to update an existing post by its ID
    updatePost: builder.mutation({
      // Define the PUT request with post ID and updated data in the payload
      query: ({ id, ...updatedData }) => ({
        url: `/posts/${id}`,
        method: 'PUT',
        body: updatedData,
      }),

      // Invalidate cache for both the updated post and the paginated list
      invalidatesTags: (result, error, { id }) => [
        { type: 'Posts', id },
        { type: 'Posts', id: 'LIST' },
      ],
    }),

    // Mutation to delete a post by its ID
    deletePost: builder.mutation({
      // Define the DELETE request with post ID in the URL path
      query: (id) => ({
        url: `/posts/${id}`,
        method: 'DELETE',
      }),

      // Invalidate cache for the deleted post and the paginated list
      invalidatesTags: (result, error, id) => [
        { type: 'Posts', id },
        { type: 'Posts', id: 'LIST' },
      ],
    }),
  }),
});

// Export generated hooks for each endpoint to use them in components
export const {
  useGetPostsQuery, // Use this when you want data to be fetched automatically as the component mounts or when the query parameters change.
  useLazyGetPostsQuery, // Use this when you need more control over when the query runs, such as in response to a user action (e.g., clicking a button), conditional fetching, or specific events.
  useGetPostByIdQuery,
  useCreatePostMutation,
  useUpdatePostMutation,
  useDeletePostMutation,
} = postsApi;
Enter fullscreen mode Exit fullscreen mode
  • postsApi (src/api/postsApi.js):
    • CRUD Operations: The posts API contains multiple endpoints to interact with posts (fetch, create, update, delete).
      • getPosts: Fetches paginated posts, meaning it retrieves data in smaller chunks (pages), improving performance and load times.
      • createPost, updatePost, and deletePost: Each of these performs a different action (create, update, or delete a post).
    • Tags for Caching: Each endpoint uses tags (e.g., { type: 'Posts', id }) to automatically manage cache invalidation and refreshing. For instance, creating or deleting a post invalidates the cache, prompting getPosts to fetch fresh data without manual intervention.

// src/api/usersApi.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const usersApi = createApi({
  reducerPath: 'usersApi',
  baseQuery: fetchBaseQuery({
    baseUrl: 'https://dummyjson.com',
    prepareHeaders: (headers, { getState }) => {
      // Get the token from the Redux auth state
      const { token } = getState().auth;

      // If the token exists, set it in the Authorization header
      if (token) {
        headers.set('Authorization', `Bearer ${token}`);
      }

      // Optional: include credentials if needed by the API
      headers.set('credentials', 'include');

      return headers;
    },
  }),
  endpoints: (builder) => ({
    // Fetch user profile with token in Authorization header
    getUserProfile: builder.query({
      query: () => '/auth/me',
    }),
  }),
});

export const { useGetUserProfileQuery } = usersApi;
Enter fullscreen mode Exit fullscreen mode
  • usersApi (src/api/usersApi.js):
    • This API fetches the authenticated user’s profile, setting up Authorization headers based on the token from Redux.
    • Headers: prepareHeaders dynamically attaches the token to every request if available, allowing secure and authorized API requests.


3. Auth Slice (src/features/auth/authSlice.js)

// src/features/auth/authSlice.js
import { createSlice } from '@reduxjs/toolkit';

const authSlice = createSlice({
  name: 'auth',
  initialState: {
    token: null,
  },
  reducers: {
    setToken: (state, action) => {
      console.log('first action', action);
      state.token = action.payload;
    },
    logout: (state) => {
      state.token = null;
    },
  },
});

export const { setToken, logout } = authSlice.actions;

export default authSlice.reducer;
Enter fullscreen mode Exit fullscreen mode
  • authSlice: A Redux slice manages a specific piece of state, in this case, user authentication.
  • State Management: The authSlice keeps the user’s token, which is used to access protected API endpoints.
  • Actions:
    • setToken: Stores the authentication token in the Redux state.
    • logout: Clears the token from Redux, effectively logging the user out.


4. Reactotron for Debugging (src/reactotronConfig/ReactotronConfig.js)

import Reactotron, { networking } from 'reactotron-react-native';
import { reactotronRedux } from 'reactotron-redux';

Reactotron.configure({ name: 'app_name' })
  .useReactNative()
  .use(reactotronRedux()) //  <- here i am!
  .use(networking())
  .connect();

// patch console.log to send log to reactotron
const yeOldeConsoleLog = console.log;
console.log = (...args) => {
  yeOldeConsoleLog(...args);
  Reactotron.display({
    name: 'CONSOLE.LOG',
    value: args,
    preview: args.length > 0 && typeof args[0] === 'string' ? args[0] : null,
  });
};

export default Reactotron;
Enter fullscreen mode Exit fullscreen mode
  • Reactotron: Reactotron is a debugging tool that helps track Redux state changes, monitor API requests, and inspect logs.
  • Setup: Configured to capture console.log outputs and Redux actions. In development mode, this setup provides a powerful way to debug without adding extra code or altering production performance.


5. Main Application Components

// src/App.js
import React from 'react';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { Provider } from 'react-redux';
import { persistStore } from 'redux-persist';
import { PersistGate } from 'redux-persist/integration/react';
import MainApp from './MainApp';
import rtkStore from './store/store';

const persistor = persistStore(rtkStore);

const App = () => (
  <Provider store={rtkStore}>
    <PersistGate loading={null} persistor={persistor}>
      <SafeAreaProvider>
        <MainApp />
      </SafeAreaProvider>
    </PersistGate>
  </Provider>
);

export default App;
Enter fullscreen mode Exit fullscreen mode
  • App Component (src/App.js):
    • The App component wraps the entire application in Provider (to make Redux available) and PersistGate (to delay rendering until persisted state has been retrieved).
    • PersistGate ensures persisted data loads before the app displays, reducing load time inconsistencies.

// src/MainApp.js
import React, { useEffect, useState } from 'react';
import {
  ActivityIndicator,
  Button,
  FlatList,
  Modal,
  RefreshControl,
  StyleSheet,
  Text,
  TextInput,
  View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useDispatch, useSelector } from 'react-redux';
import { useLoginMutation } from './api/authApi';
import {
  useCreatePostMutation,
  useDeletePostMutation,
  useGetPostsQuery,
  useLazyGetPostsQuery,
  useUpdatePostMutation,
} from './api/postsApi';
import { useGetUserProfileQuery } from './api/usersApi';
import { logout } from './features/auth/authSlice';

const MainApp = () => {
  const [newPostTitle, setNewPostTitle] = useState('');
  const [page, setPage] = useState(1);
  const [postsData, setPostsData] = useState([]);
  const [refreshing, setRefreshing] = useState(false);
  const [isModalVisible, setModalVisible] = useState(false);

  const dispatch = useDispatch();
  const token = useSelector((state) => state.auth.token);

  // Login mutation
  const [login, { isLoading: isLoggingIn }] = useLoginMutation();

  // Fetch user profile when token is available
  const { data: userProfile, refetch: refetchUserProfile } = useGetUserProfileQuery(undefined, {
    skip: !token,
  });

  // Fetch paginated posts
  const {
    data: posts,
    isLoading,
    isFetching,
    isError,
    refetch,
  } = useGetPostsQuery({ page, limit: 10 }); // The useQuery hook is used when you want to fetch data on screen load. For example fetch userprofile on profile screen.
  // Use the lazy query for refresh to directly fetch page 1
  const [triggerFetchFirstPage, { data: lazyData }] = useLazyGetPostsQuery(); // useLazyquery is used when you want to control over the api calling, like on button click.

  const [createPost] = useCreatePostMutation();
  const [updatePost] = useUpdatePostMutation();
  const [deletePost] = useDeletePostMutation();

  useEffect(() => {
    if (posts) {
      setPostsData((prevData) => (page === 1 ? posts : [...prevData, ...posts]));
    }
  }, [posts, page]);

  // Login handler
  const handleLogin = async () => {
    try {
      const credentials = { username: 'emilys', password: 'emilyspass' };
      await login(credentials);
      console.log('userProfile', userProfile);
      refetchUserProfile();
    } catch (error) {
      console.error('Login failed:', error);
    }
  };

  const handleRefresh = async () => {
    setRefreshing(true);
    setPage(1); // Reset the page to 1 for the next scrolls
    setPostsData([]); // Clear the data to avoid duplications

    // Trigger the first page fetch explicitly
    const { data } = await triggerFetchFirstPage({ page: 1, limit: 10 });

    if (data) {
      setPostsData(data); // Set the posts data to the first page's results
    }

    setRefreshing(false);
  };

  // Create a new post, add it to the top, and refetch the list
  const handleCreatePost = async () => {
    if (newPostTitle) {
      const { data: newPost } = await createPost({ title: newPostTitle, body: 'New post content' });
      setNewPostTitle('');
      setPostsData((prevData) => [newPost, ...prevData]);
      refetch();
    }
  };

  // Update an existing post and add "HASAN" to its title
  const handleUpdatePost = async (post) => {
    const { data: updatedPost } = await updatePost({
      id: post.id,
      title: `${post.title} HASAN`,
    });
    setPostsData((prevData) =>
      prevData.map((item) => (item?.id === updatedPost?.id ? updatedPost : item))
    );
  };

  // Delete a post and remove it from the UI immediately
  const handleDeletePost = async (id) => {
    await deletePost(id);
    setPostsData((prevData) => prevData.filter((post) => post.id !== id));
  };

  // Load more posts for infinite scrolling
  const loadMorePosts = () => {
    if (!isFetching) {
      setPage((prevPage) => prevPage + 1);
    }
  };

  // Toggle modal visibility
  const toggleModal = () => {
    setModalVisible(!isModalVisible);
  };

  if (isLoading && page === 1) return <Text>Loading...</Text>;
  if (isError) return <Text>Error fetching posts.</Text>;

  return (
    <SafeAreaView style={styles.container}>
      <View style={{ flexDirection: 'row', alignSelf: 'center' }}>
        <Button title="Login" onPress={handleLogin} disabled={isLoggingIn} />
        {userProfile && <Button title="Show Profile" onPress={toggleModal} />}
      </View>
      <TextInput
        placeholder="New post title"
        value={newPostTitle}
        onChangeText={setNewPostTitle}
        style={styles.input}
      />
      <Button title="Add Post" onPress={handleCreatePost} />

      <FlatList
        data={postsData}
        keyExtractor={(item, index) => index.toString()}
        renderItem={({ item }) => (
          <View style={styles.post}>
            <Text>{item.title}</Text>
            <Button title="Update" onPress={() => handleUpdatePost(item)} />
            <Button title="Delete" onPress={() => handleDeletePost(item.id)} />
          </View>
        )}
        onEndReached={loadMorePosts}
        onEndReachedThreshold={0.5}
        ListFooterComponent={isFetching ? <ActivityIndicator color="red" /> : null}
        refreshControl={<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />}
      />

      {/* Profile Modal */}
      <Modal
        visible={isModalVisible}
        animationType="slide"
        onRequestClose={toggleModal}
        transparent
      >
        <View style={styles.modalContainer}>
          <View style={styles.modalContent}>
            {userProfile ? (
              <>
                <Text style={styles.modalTitle}>User Profile</Text>
                <Text>Username: {userProfile.username}</Text>
                <Text>Email: {userProfile.email}</Text>
                <Button title="Close" onPress={toggleModal} />
                <Button
                  title="Logout"
                  onPress={() => {
                    toggleModal();
                    dispatch(logout());
                  }}
                />
              </>
            ) : (
              <Text>Loading Profile...</Text>
            )}
          </View>
        </View>
      </Modal>
    </SafeAreaView>
  );
};

const styles = StyleSheet.create({
  container: { flex: 1, padding: 16, backgroundColor: 'cyan' },
  input: { height: 40, borderColor: 'gray', borderWidth: 1, marginBottom: 8, paddingHorizontal: 8 },
  post: {
    padding: 10,
    borderWidth: 1,
    borderColor: '#ddd',
    backgroundColor: 'pink',
    marginBottom: 8,
  },
  modalContainer: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: 'rgba(0, 0, 0, 0.5)',
  },
  modalContent: {
    width: 300,
    padding: 20,
    backgroundColor: '#fff',
    borderRadius: 10,
  },
  modalTitle: { fontSize: 18, fontWeight: 'bold', marginBottom: 10 },
});

export default MainApp;
Enter fullscreen mode Exit fullscreen mode
  • MainApp Component (src/MainApp.js):
    • State and Hooks: Manages local states (e.g., for posts pagination) and hooks like useLoginMutation to trigger actions on specific events.
    • Login:
      • Uses useLoginMutation to log the user in and then triggers refetchUserProfile to load the user profile data.
      • Conditional Querying: Only fetches the user profile when a valid token exists (skip: !token), reducing unnecessary API calls.
    • Fetching Posts:
      • Uses useGetPostsQuery to fetch paginated posts, supporting infinite scrolling by fetching more data as the user scrolls.
      • Refresh Control: Allows users to refresh the posts list, useful for pull-to-refresh functionality on mobile.
    • Create, Update, Delete Posts:
      • Create: Calls createPost, immediately updating the posts list with the new post at the top.
      • Update: Appends "HASAN" to a post’s title upon updating.
      • Delete: Removes a post and updates the UI without needing a page reload, thanks to cache invalidation from deletePost.
    • UI Elements:
      • A modal displays the user profile. The profile button only appears if userProfile data is loaded, enhancing the user experience.
    • FlatList: Displays posts in a scrollable, paginated format, enhancing usability.

Summary:

Your React Native app uses Redux Toolkit (RTK) Query for efficient data management and API interactions. The setup includes:

  1. Store Configuration: Redux store with redux-persist to save specific data across app sessions, a custom middleware for error logging, and Reactotron for debugging in development mode.

  2. APIs with RTK Query:

    • authApi handles authentication with a login mutation, storing the token in Redux.
    • postsApi provides CRUD operations for posts, using cache tags to automatically refresh data when posts are added, updated, or deleted.
    • usersApi fetches the user profile with dynamic token-based authorization headers.
  3. Auth Slice: Manages the auth token and provides actions for setting or clearing the token on login/logout.

  4. App and MainApp Components:

    • The main app wraps components in Provider and PersistGate, ensuring state is loaded before rendering.
    • MainApp manages posts fetching, creating, updating, and deleting. It conditionally loads data (e.g., fetching user profile only when a token exists), supports pagination and infinite scrolling
    • Uses FlatList for a paginated post list, modals for the profile, and basic styles for a clean, organized layout.

FULL CODE->

Top comments (0)