Scaling Your React App with RTK-Query

Scaling Your React App with RTK-Query

Achieving High Availability and Scalability with RTK-Query: A Robust Guide to Manage Complex Data Flows with Ease

In my previous blog, I talked about the SWR 2.0 library for fetching data from API and performing CRUD Operations on that data. I also talked about Data Revalidation, Optimistic UI Updates, Mutations etc. If you haven't already read that article, here's a link to it.

Why RTK-Query?

Now the question arises, what is RTK-Query and why should we prefer RTK-Query Over SWR?

RTK-Query also known as the Redux Toolkit Query is a part of the Redux Toolkit library used for fetching data from API into a store created by Redux. It's more of an abstraction of the middleware(like async thunk) and redux combined.

The reasons to prefer RTK-Query Over SWR are:-

  1. Integration with Redux: RTK-Query is built on top of Redux, so it integrates well with Redux-based applications. So, if your application already has a redux store, then it's preferred to use RTK-Query rather than shifting to SWR.

  2. Query caching: Both libraries provide caching functionality to help improve performance by reducing the number of network requests. However, RTK Query's caching system is more robust and allows for more customization.

  3. Data normalization: RTK Query also includes a built-in data normalization system, which can help keep your data consistent and organized. This can be a very necessary feature if we are building a scalable app or writing production-ready code for an organization.

  4. API support: Both libraries can work with any API, but RTK Query includes pre-built middleware for popular APIs like REST and GraphQL, which can make implementation easier.

To summarize, both libraries are great choices for state management in React, and the "better" option depends on your specific needs. If you are already using Redux and need more advanced caching and data normalization, RTK-Query might be the better choice. On the other hand, if you need built-in SSR support or are not using Redux, SWR might be the way to go.

Performing CRUD Operations Using RTK-Query

Now, the question arises of how to perform CRUD Operations using RTK-Query in our React App. So, I am going to describe a very easy process to use RTK-Query.

Step 1 - We need to install Redux-Toolkit and React-Redux first.

npm install @reduxjs/toolkit react-redux

Step 2 - Next, we need to create a file for example testApi.js. In this file, we are going to deal with all the API fetching and mutation-related stuff. After creating that file we need to import the createApi hook from RTK-Query. This hook is used to create the API Slice in the file. We also need to import the fetchBaseQuery hook from RTK-Query. This hook stores the Base URL of an API while creating the API Slice.

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

Step 3 - Now, we need to create an API Slice as follows.

export const testApi = createApi({
  reducerPath: "testApi",
  baseQuery: fetchBaseQuery({ baseUrl: "http://localhost:3500" }),
  endpoints: (builder) => ({
    getTodos: builder.query({
      query: () => "/todos",
      transformResponse: (res) => res.sort((a, b) => b.id - a.id),
    }),
  }),
});

In the above code, we created an API Slice named it testApi in the reducerPath. We also defined the baseUrl in the slice. For now, we have added a single endpoint which is getTodos for fetching a list of todos. The query function is used to give the endpoint. It contains a set of round brackets which helps to give the query params that we are going to see in the next steps. The transformResponse function does a simple task of sorting all the to-do items based on their id and showing the newly added item at the top.

Step 4 - Now, let's add some more endpoints to the testApi.

addTodo: builder.mutation({
      query: (todo) => ({
        url: "/todos",
        method: "POST",
        body: todo,
      }),
    }),
    updateTodo: builder.mutation({
      query: (todo) => ({
        url: `/todos/${todo.id}`,
        method: "PATCH",
        body: todo,
      }),
    }),
    deleteTodo: builder.mutation({
      query: ({ id }) => ({
        url: `/todos/${id}`,
        method: "DELETE",
        body: id,
      }),
    }),

There are a few differences between the getTodos endpoint and the addTodo, updateTodo and deleteTodo endpoints. Let's see talk about these differences one by one.

The getTodos endpoint had a builder.query object whereas the other endpoints have builder.mutation object. So, we use builder.query object wherever we need to fetch data from API and we need builder.mutation object wherever we need to add, update or delete data from the API.

We also have to send method function as a property to the builder.mutation object to specify the method used to perform that request(like post, put, patch, delete).

We take params in query function of add, delete and update endpoints from the UI and add it to the URL for data mutations.

Step 5 - Now, we need to export the endpoints using premade hooks of RTK-Query and make a store.js file and add reducerPath to that file. So, both the testApi.js file and the store.js file will look like the one below.

//testApi.js

import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";

export const testApi = createApi({
  reducerPath: "testApi",
  baseQuery: fetchBaseQuery({ baseUrl: "http://localhost:3500" }),
  tagTypes: ["Todos"],
  endpoints: (builder) => ({
    getTodos: builder.query({
      query: () => "/todos",
      transformResponse: (res) => res.sort((a, b) => b.id - a.id),
      providesTags: ["Todos"],
    }),
    addTodo: builder.mutation({
      query: (todo) => ({
        url: "/todos",
        method: "POST",
        body: todo,
      }),
      invalidatesTags: ["Todos"],
    }),
    updateTodo: builder.mutation({
      query: (todo) => ({
        url: `/todos/${todo.id}`,
        method: "PATCH",
        body: todo,
      }),
      invalidatesTags: ["Todos"],
    }),
    deleteTodo: builder.mutation({
      query: ({ id }) => ({
        url: `/todos/${id}`,
        method: "DELETE",
        body: id,
      }),
      invalidatesTags: ["Todos"],
    }),
  }),
});

export const {
  useGetTodosQuery,
  useAddTodoMutation,
  useUpdateTodoMutation,
  useDeleteTodoMutation,
} = testApi;
//store.js

import { configureStore } from "@reduxjs/toolkit";
import { setupListeners } from "@reduxjs/toolkit/dist/query";
import { testApi } from "./testApi";

export const store = configureStore({
  reducer: {
    [testApi.reducerPath]: testApi.reducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(testApi.middleware),
});

setupListeners(store.dispatch);

Step 6 - Now let's add the store to the React file. For this, we use the React-Redux library.

//index.jsx

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
import { store } from "./app/store";
import { Provider } from "react-redux";

ReactDOM.createRoot(document.getElementById("root")).render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>
);

Step 7 - Now we will use the exported functions from the testApi.js file in our React Components.

import {
  useGetTodosQuery,
  useAddTodoMutation,
  useUpdateTodoMutation,
  useDeleteTodoMutation,
} from "../api/apiSlice";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrash, faUpload } from "@fortawesome/free-solid-svg-icons";
import React, { useState } from "react";

const TodoList = () => {
  const [newTodo, setNewTodo] = useState("");

  const {
    data: todos,
    isLoading,
    isSuccess,
    isError,
    error,
  } = useGetTodosQuery();

  const [addTodo] = useAddTodoMutation();
  const [updateTodo] = useUpdateTodoMutation();
  const [deleteTodo] = useDeleteTodoMutation();

  const handleSubmit = (e) => {
    e.preventDefault();
    addTodo({ userId: 1, title: newTodo, completed: false });
    setNewTodo("");
  };

  const newItemSection = (
    <form onSubmit={handleSubmit}>
      <label htmlFor="new-todo">Enter a new todo item</label>
      <div className="new-todo">
        <input
          type="text"
          id="new-todo"
          value={newTodo}
          onChange={(e) => setNewTodo(e.target.value)}
          placeholder="Enter new todo"
        />
      </div>
      <button className="submit">
        <FontAwesomeIcon icon={faUpload} />
      </button>
    </form>
  );

  let content;
  if (isLoading) {
    content = <div>Loading...</div>;
  } else if (isSuccess) {
    content = todos.map((todo) => {
      return (
        <article key={todo.id}>
          <div className="todo">
            <input
              type="checkbox"
              checked={todo.completed}
              id={todo.id}
              onChange={() =>
                updateTodo({ ...todo, completed: !todo.completed })
              }
            />
            <label htmlFor={todo.id}>{todo.title}</label>
          </div>
          <button className="trash" onClick={() => deleteTodo({ id: todo.id })}>
            <FontAwesomeIcon icon={faTrash} />
          </button>
        </article>
      );
    });
  } else if (isError) {
    content = <p>{error}</p>;
  }

  return (
    <main>
      <h1>Todo List</h1>
      {newItemSection}
      {content}
    </main>
  );
};
export default TodoList;

Note: We see that we can only get the update, delete and create todo changes to reflect after refreshing the page. We can get rid of it using a simple way of providing a tag to the getTodo endpoint and invalidating the tag on every mutation.

export const testApi = createApi({
  reducerPath: "testApi",
  baseQuery: fetchBaseQuery({ baseUrl: "http://localhost:3500" }),
  tagTypes: ["Todos"],
  endpoints: (builder) => ({
    getTodos: builder.query({
      query: () => "/todos",
      transformResponse: (res) => res.sort((a, b) => b.id - a.id),
      providesTags: ["Todos"],
    }),
  }),
});

In the above code, we mentioned tagTypes inside createApi function and used a providesTags property inside a getTodos' builder.query object. Next whenever we need to refetch/revalidate data, we just use a invalidatesTags property inside the builder.mutation object.

 addTodo: builder.mutation({
      query: (todo) => ({
        url: "/todos",
        method: "POST",
        body: todo,
      }),
      invalidatesTags: ["Todos"],
    }),