Beliebte Suchanfragen
|
//

Modern data fetching with Redux Toolkit Query

28.2.2023 | 10 minutes of reading time

First released seven years ago, Redux was already modernized four years ago with Redux Toolkit (RTK). Then in June 2021, Redux reached the next stage of evolution by adding a dedicated data fetching solution with Redux Toolkit Query. With respect to the increasing popularity of data fetching libraries such as React Query (30k GitHub Stars), this article gives an introduction to RTK Query. It is an alternative rooted in the Redux cosmos.

The old Redux pain

"I have to add a lot of packages to get Redux to do anything useful".

With the release of RTK, the developers have removed the biggest pains that Redux users had at the time. The fact that these pains are also listed in the README of the Redux GitHub project testifies to the fact that they played a big role in the development.

One of the criticisms of the Vanilla Redux (i.e. the Redux version before RTK) was that "configuring a Redux store is too complicated". This aspect is particularly critical, since the store is more or less the heart of the Redux flow. So even for a configuration with simple middlewares, various Redux functions had to be plugged together (applyMiddleware, compose, createStore, ...).

If you have been working with React for a while, you will surely be familiar with the fact that the node_modules very quickly become a zoo of dependencies to manage. For Redux, this was another point of criticism: "I have to add a lot of packages to get Redux to do anything useful." The inconvenience here was that Redux itself brought too little to support somewhat more complex use cases. Quickly filling node_modules is of course not solved (🙈). However, installing Redux has been greatly simplified and the versatility of the functionality brought along has increased tremendously. For example, create-react-app brings a template for Redux that takes care of the installation of RTK. Also, with the manual installation of the current RTK module you have everything on board for most use cases. In addition, the Redux team made the following their mission:

"We can't solve every use case, but in the spirit of create-react-app, we can try to provide some tools that abstract over the setup process and handle the most common use cases as well as include some useful utilities that will let the user simplify their application code."

The last and probably biggest criticism of Vanilla Redux is the amount of boilerplate code. Reducers were often huge switch blocks that always made the current state "immutable" using a spread operator, actions had to be defined separately, action creators had to be used, developers had to define and configure the store. And if you combined the whole thing with TypeScript, the type definitions often exploded abruptly. The fact that data fetching was popular at the time with so-called Redux Sagas did not necessarily help this point of criticism. Additional Saga listeners had to be defined, which processed actions instead of the reducers. These then took care of the data fetching and wrote the loaded data to the store via more actions, so that it could be accessed within components. Alternatives like Redux Thunk or one's own implementations of the data fetching layer could not reduce the boilerplate code and (spoiler!) Sagas are still used in RTK today. For this reason, further comparison will be limited to RTK Query versus Sagas.

The idea of implementing data fetching using Sagas is not bad per se. However, it requires at least a rudimentary understanding of JavaScript generator functions, which are rarely used (the cool guys from Papperlapapp made a great (German) video about this topic). In addition, Sagas are responsible for handing data of network requests to the store. Thus, an application can provide feedback that data is currently being loaded or that loading has failed (which, of course, is again handled by actions). The UI components can then react to store updates and display loading spinners/skeletons and error messages. What's annoying here is that we have to define at least two actions per saga ("LoadData" + "DataLoaded"). Furthermore, in addition to the actual data, we may also have to keep corresponding flags in the store per request, and these must reflect the status of the same. Also, testing components that trigger Sagas is not trivial and requires additional testing tools such as redux-saga-tester.

So, when developing RTK, there were some flaws that needed to be fixed and this, of course, with as little code and as much utility for the users as possible:

  • Simplify store configuration
  • Bring more functionalities by design
  • Less boilerplate
  • Make data fetching more elegant

How does RTK Query work?

The basic motivation behind RTK Query can be very well derived from the previous section. Everything should be implemented with less code and preferably even more functionality. The necessary steps to get data from a service and still keep this structured and scalable were reduced to a minimum here. However, the basic idea of implementing data fetching with an asynchronous layer like Redux Sagas or Redux Thunk, which works together with the underlying Redux Store and its actions, has been retained. However, the additional complexity is hidden by Redux Query, so the topic of data fetching now seems much more declarative than before.

Comparison of Redux Saga (left) and RTK Query (right).

Connecting a new endpoint is now done by describing the interface once and everything else is generated for us. The loaded data is still automatically written to our Redux store without us having to orchestrate a jumble of actions that eventually feed the data into our reducer.

RTK Query hands-on

In this section we will take a look at RTK Query in practice. As explained above, using Vanilla Redux in a project involved many small steps. Redux Toolkit already made the whole thing much easier, so we can benefit from it with RTK Query as well. For the classic quickstart, the Redux team offers a create-react-app template.

npx create-react-app my-app --template redux-typescript

As an example, we want to build a to-do app and begin with reading the to-dos from our server. In order to do so, we start by defining our endpoint.

1export interface Todo {
2    id: number;
3    title: string;
4    completed: boolean;
5}
6
7const BASE_URL = 'https://jsonplaceholder.typicode.com';
8
9export const todoApi = createApi({
10    reducerPath: 'todos',
11    baseQuery: fetchBaseQuery({baseUrl: BASE_URL}),
12    endpoints: (builder) => ({
13            getTodos: builder.query<Todo[], void>({query: () => '/todos'})
14        }
15    )
16});
17
18export const {useGetTodosQuery} = todoApi;

The code shows our endpoint to load our to-dos. The best thing about it: we are already done! To make the interface a bit more readable and to bring out the declarative approach even more, we used TypeScript. In the code example above, we configure the following:

  • reducerPath: The name of our data in the Redux store.
  • baseQuery: The base URL of our endpoint. The corresponding resources are then specified by our getTodos endpoint with an additional /todos.
  • endpoints: Using the builder, our endpoints are specified as well as typed.
  • useGetTodosQuery: Our automatically generated hook that we can include directly in our component to retrieve data.

Below we will see how to apply the generated hook.

1const TodoListComponent = (): JSX.Element => {
2    const {data, error, isLoading} = useGetTodosQuery();
3
4    if (isLoading) {
5        return <div>Is loading...</div>;
6    }
7
8    if (error) {
9        return <div>There is an error...</div>;
10    }
11    
12    return (
13        <div>
14            {data && data.map(
15                (todo, index) => <p key={index}>{todo.title}</p>
16            )}
17        </div>
18    );
19};
20
21export default TodoListComponent;

Destructuring gives us a set of automatically generated properties that are very useful for our use case.

  • data: Contains our to-dos, which is also typed accordingly.
  • error: Contains any errors that occurred during the request.
  • isLoading: A boolean that indicates whether the request is still running.

These properties reveal a big advantage of RTK Query compared to a self-implemented variant with e.g. Sagas. We have immediate access to the status of our request and can react accordingly in the UI (e.g. by displaying a loading spinner). By this, we skip the implementation of a global solution for these cases and thus make our component extremely readable and self-explanatory. In addition to these three properties, there are other fields we can use to map possible edge cases, e.g. currentData, which gives us the last data fetch of a query to see possible differences here is the complete list).

In the following we extend our available queries with a getTodoById.

1endpoints: (builder) => ({
2    getTodos: builder.query<Todo[], void>({query: () => '/todos'}),
3    getTodoById: builder.query<Todo, number>({query: (id) => `/todos/${id}`})
4})

This illustrates the ease of extensibility of our API layer, as without RTK Query the following adjustments would be required in the code:

  • Creation of multiple actions incl. action creator
  • Extension of the reducer
  • Implementation of the asynchronous logic needed for fetching (Saga, Thunk or similar)

And this list doesn't even consider that we have to test the whole thing (think about typos in the actions!).

Next up we will have a look at an example where we send data instead of receiving it. To do so, we use so-called mutations, which are very powerful in detail, but for simple cases can be implemented very fast.

1export const todoApi = createApi({
2    reducerPath: 'todos',
3    tagTypes: ['Todos'],
4    baseQuery: fetchBaseQuery({baseUrl: BASE_URL}),
5    endpoints: (builder) => ({
6            getTodos: builder.query<Todo[], void>({
7                query: () => '/todos',
8                providesTags: () => [{type: 'Todos', id: 'allTodos'}]
9            },),
10            addTodo: builder.mutation<Todo, Partial<Todo>>({
11                query: (todo) => ({
12                    url: 'todos',
13                    method: 'POST',
14                    body: todo,
15                }),
16                invalidatesTags: [{ type: 'Todos', id: 'allTodos' }],
17            })
18        }
19    )
20});
21
22export const {useGetTodosQuery, useAddTodoMutation} = todoApi;

We create the POST query with builder.mutation, whose call we give the URL endpoint, the HTTP method (POST, PUT or PATCH), and the actual body (aka our new to-do). Also added in the code example above (though optional) are tagTypes, providesTags and invalidatesTags, which are necessary for controlling caching. Defining tagTypes allows us to uniquely identify entire APIs or subsets of them while providesTags identify individual queries. Mutations specify which specific APIs/queries they invalidate through invalidatesTags. "Invalidate" in this context means that these queries are executed again and thus the supplied data is updated.

1const TodoListComponent = (): JSX.Element => {
2    const {data, error, isLoading} = useGetTodosQuery();
3    const [addTodo] = useAddTodoMutation();
4
5    if (isLoading) {
6        return <div>Is loading...</div>;
7    }
8
9    if (error) {
10        return <div>There is an error...</div>;
11    }
12
13    return (
14        <div>
15            <button onClick={() => addTodo({title: 'Some fancy Todo'})}>
16                Add To-do
17            </button>
18            {data && data.map(
19                (todo, index) => <p key={index}>{todo.title}</p>
20            )}
21        </div>
22    );
23};
24
25export const {useGetTodosQuery, useAddTodoMutation} = todoApi;

In our example, clicking the "Add To-do" button adds a new to-do and invalidates our getTodos query. As a result, the next time the useGetTodosQuery hook is executed, it will not query the cache, but will re-fetch all to-dos. The default behavior would prevent this, since the data is only requested if it does not yet exist or is out of date (if a timed invalidation is configured). So freshly added to-dos show up directly in our to-do list.

This is a simple way to cache our data and reduce our network requests to a minimum without having to implement complex logic (or using additional dependencies).

Final words

In general, we really like Redux Toolkit Query. It is very easy to use and even better to extend. The required boilerplate code is already massively reduced by RTK. RTK Query extends this concept in a meaningful way and also extends it to the API layer to make it much leaner as well. In addition, RTK Query provides us with a number of tools to avoid having to implement common functionalities such as caching, error handling and isLoading etc. ourselves.

Especially if Redux is already in use, the switch to RTK Query feels very natural. Furthermore, RTK Query integrates very well into existing solutions, since only individual parts (e.g. Sagas) need to be replaced and this can even be done step by step.

With Redux, you also have an established and well-maintained library as a foundation, which means that the basic understanding of the concepts is already available to many developers. The available documentation is also very extensive and facilitates the start.

|

share post

//

More articles in this subject area

Discover exciting further topics and let the codecentric world inspire you.

//

Gemeinsam bessere Projekte umsetzen.

Wir helfen deinem Unternehmen.

Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.

Hilf uns, noch besser zu werden.

Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.