5 reasons why data handling is easier with Redux Toolkit & RTK Query

mattc

Matthew Claffey

Posted on December 8, 2023

5 reasons why data handling is easier with Redux Toolkit & RTK Query

Have you ever come onto a project and you notice there are multiple ways to call apis and you just get confused to which to use? Some ways might be overly complicated for what you need to do, you don't know which one to use and then when you get to a code review you get told to use the other way?

I wanted to find a way we could have a standardised way of calling apis so that the UI was kept simple and had information to use for the UI to display different states without having all that logic built in every time you needed to call an api. You normally get something like this…

import { useState } from 'react';

const Component = () => {
  const [isLoading, setIsLoading] = useState(false);
  const [data, setData] = useState();
  const [isSuccess, setIsSuccess] = useState(false);
  const [isError, setIsError] = useState(false);

  useEffect(() => {
    const fetch = async () => {
      try {
        setIsLoading(true);
        const response = await fetch('/api/users');

        if (response.status === 200) {
          const data = await response.json();
          setIsSuccess(true);
          setData(data);
          setIsLoading(false);
        } else {
          setIsError(true);
        }
      } catch(e) {
        setIsError(true);
      }
    }
  }, []);

  isLoading && return <p>IsLoading</p>;
  isError && return <p>Error</p>;
  return <p>{data ? JSON.stringify(data) : 'Empty'}</p>
}
Enter fullscreen mode Exit fullscreen mode

Overtime, this looks a bit mental. Nothing caters for caching here, it fetches every time the component mounts and it is not reusable in another component.

In the project I was working on we was using redux anyway so I thought how could we use RTK query to make our lives easier.

🤔 What is RTK Query?

RTK Query is a tool you integrate with redux toolkit that makes handling data in your web apps a whole lot easier. It helps you fetch data from a server, manage it in your app, and keeps it up to date without all the usual headaches.

01. 📦 Creating apis

RTK query only takes minutes to setup before you can start setting up API endpoints.

📝 Example: This is our example of use setting up a basic set of APIS which get the user which share an authorisation token.

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

const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ 
    baseUrl: '/api',
    prepareHeaders: (headers) => {
      // Example if you need to apply a header on all apis
      headers.set('Authorization', 'Bearer TOKEN');

      return headers;
    },
  }),
  endpoints: (builder) => ({
    getUsers: builder.query({
      query: () => 'users' // /api/users
    }),
    getUser: builder.query({
      query: (id) => `users/${id}`
    }),
    updateUser: builder.mutation({
      query: (id, body) => `users/${id}`,
      method: 'PATCH',
      body,
    }),
    deleteUser: builder.mutation({
      query: (id) => `users/${id}`,
      method: 'DELETE',
    })
  })
});
Enter fullscreen mode Exit fullscreen mode

👍 Good for: Having a standardised way for setting up endpoints and sharing headers between them.

02.🪝 React Hooks

No more creating your own custom hooks for apis! RTK creates react hooks for you when you add a new endpoint.

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

const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: (builder) => ({
    getUsers: builder.query({
      query: () => 'users' // /api/users
    }),
  })
});

export const {
  useGetUsersQuery,
} = api;
Enter fullscreen mode Exit fullscreen mode

How awesome is that? That would have been about a solid day or two creating a fancy react hook that tries to solve all purposes that is now resolved as soon as you make a new endpoint.

Queries

As soon as the component mounts we fetch off to the api to get the data we need.

📝 Example: Fetching all users showing different visual states.

import { useGetUsersQuery } from '../apis';

const Component = () => {
  const { 
    data, // the response from the api
    isError, // has an error
    isLoading, // first mount loading
    isFetching, // is Fetching again after the first mount
    isSuccess, // is 200
    error // Error message
  } = useGetUserQuery();

  // Purely for demo purposes
  return <p>{JSON.stringify(data)}</p>
}
Enter fullscreen mode Exit fullscreen mode

👍 Good for: Shared pattern across the app making it easier to understand.

Mutations

Mutations allow you to make a change to an endpoint based on an action that has been fired in the app.

📝 Example: Deleting a user when clicking a button.

import { useState } from 'react';
import { useDeleteUserMutation } from '../apis';

const Component = () => {
  const [
    deleteUserPromise, 
    { isSuccess, isError, data }
  ] = useDeleteUserMutation();

  const handleClick = async () => {
    await deleteUserPromise('userOne');
  }

  return (
    <button type="button" onClick={handleClick}>
      Delete user
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

👍 Good for: Shared pattern across the app making it easier to understand.

Lazy Queries

Lazy queries behave like mutations but are used more for get requests that you need to do after an action is called.

📝 Example: Getting a user by id when you click a button.

import { useState } from 'react';
import { useLazyGetUserQuery } from '../apis';

const Component = () => {
  const [user, setUser] = useState();
  const [
    getUserPromise, 
    {} // Same data as a query
  ] = useLazyGetUserQuery();

  const handleClick = async () => {
    const { data } = await getUserPromise('userOne');

    if (data) {
      setUser(data);
    }
  }

  return (
    <>
      <button type="button" onClick={handleClick}>
        Get user
      </button>
      {user && <p>{user.userName}</p>}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

👍 Good for: When you need get data from an action instead of on mount of a component.

👀 Watch out …

Queries/Lazy Queries are cached endpoints by default so when you change some code with a mutation you might get a cached response from the queries ….

03. ⚙️ Let's fix that

Default cache times

The default cache time for a query is 60 seconds.
When multiple components use the same hook, it will prevent the same api from being called multiple times which reduces overhead on your server 💰. When the first one is resolved, it is resolved for everywhere that calls it.

📝 Example: Setting getUser to have 0 second cache.

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

const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: (builder) => ({
    getUser: builder.query({
      query: (id) => `users/${id}`,
      keepUnusedDataFor: 0,
    }),
  })
});
Enter fullscreen mode Exit fullscreen mode

🕵️Tip: keepUnusedDataFor is good for apis that you need to get fresh data from every time. This also can be inherited when set at the top level api.

Invalidating caches with Tags

Sometimes you just need to update the cache when you call a mutation. This is where RTK query provides you with Tags.

📝 Example: Using tagTypes to define what tags I want to define. When deleteUser gets called it will invalidate anything with provideTags: ["Users"].

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

const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Users'],
  endpoints: (builder) => ({
    getUsers: builder.query({
      query: () => 'users',
      provideTags: ['Users']
    }),
    deleteUser: builder.mutation({
      query: (id) => `users/${id}`,
      method: 'DELETE',
      invalidatesTags: ['Users']
    })
  })
});
Enter fullscreen mode Exit fullscreen mode

Refetching

Now we have tags in place, we can use a method that returns back from the query called "refetch" which will get us the latest set of users when the deleteUser method has completed.

📝 Example: When deleteUser is fired, it will invalidate the getUsers cache and then "refetch" will get a new set of users.

import { useEffect } from 'react';
import { useGetUsersQuery, useDeleteUserMutation } from '../apis';

const Component = () => {
  const { data, refetch } = useGetUserQuery(user);
  const [deleteUser, { isSuccess }] = useDeleteUserMutation();

  const handleClick = async () => {
    await deleteUser('userTwo');
  }

  useEffect(() => {
    if (isSuccess) {
      // even though the tag is invalidated, there
      // is nothing telling useGetUser to refetch the
      // the new cache
      refetch();
    }
  }, [isSuccess]);

  return (
    <button type="button" onClick={handleClick}>
      Update user
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

04. 🔄 Other Features

Polling

RTK Query can take secondary params to change how often to re-fetch the data.

📝 Example: Polling for changes to our get all users.

import { useGetUsersQuery } from '../apis';

const Component = () => {
  const { data } = useGetUserQuery(undefined, { polling: 3000 });

  // Purely for demo purposes
  return <p>{JSON.stringify(data)}</p>
}
Enter fullscreen mode Exit fullscreen mode

🟠Be careful: Use this pragmatically as it could become costly on the server side.

Automatic Refetching

When a query takes params to fetch specific data, it will cache that specific request for 60 seconds. By making the query dynamic with query parameters, the data is passed into the hook updates the state returned as it calls a new endpoint path.

📝 Example: Updating the user id when pressing the button which fetches the other user.

import { useState } from 'react';
import { useGetUsersQuery } from '../apis';

const Component = () => {
  const [user, setUser] = useState('userOne');
  const { data } = useGetUserQuery(user);

  const handleClick = () => {
    setUser('userTwo');
  }

  return (
    <button type="button" onClick={handleClick}>
      Update user
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

05. ⛓️ Dependency Management

RTK Query can take secondary parameter called skip which will tell RTK to not call this api until the id is defined.

📝 Example: Using skip parameter to prevent the query from being called.

import { useGetUsersQuery } from '../apis';

const Component = () => {
  const [user, setUser] = useState(undefined);
  const { data } = useGetUserQuery(user, { skip: user === undefined });

  const handleClick = () => {
    setUser('userTwo');
  }

  return (
    <button type="button" onClick={handleClick}>
      Update user
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

💣 What doesn't do well?

If you are like me who is a NextJS fanboy then you will have noticed that in version 13 there is a big buzz around server components and moving all JS code back to the server so we render more on the sever which makes the client faster. RTK is a client-side library so this does not work with server components.

🏁 Conclusion

In summary, the main reason why I like using this tool is purely around DevEx. I like how multiple teams can easily adopt this library and then when you move around teams you can easily understand how these apis work as there is a standardised pattern of implementing endpoints across the app.
Not only that the DevEx is good, it also makes my unit tests a lot nicer as I am mocking the variables that come from the react hooks which makes my confidence in my tests a lot higher as I can test more of the UI.

This is not saying use RTK for everything, as there are many libraries that do this however if you are using toolkit on your project maybe have a look at this?

What are your experiences using it?

💖 💪 🙅 🚩
mattc
Matthew Claffey

Posted on December 8, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related