DEV Community

Cover image for RTK Query vs Redux Saga: What to choose for your next project?
Mohamed Elgazzar
Mohamed Elgazzar

Posted on • Updated on

RTK Query vs Redux Saga: What to choose for your next project?

Today we're going to discuss and compare Redux Saga and RTK Query approaches for API access and async tasks. We will go through both of them and in the end, you will have all the knowledge that will help you decide which one to use in your project!

So your application is getting bigger, and you decide to use Redux as your state management for the project.

Now, you want to connect your app with an external API!

In Redux you can't dispatch asynchronous actions directly in the store, so you will typically need to install an async middleware such as Redux Thunk or Redux Saga, that will help encapsulate asynchronous side effects.

RTK provides built-in tools that can be used for API interactions such as createReduxThunk and RTK Query which is indeed built on top of cAT and other RTK APIs internally. It is purpose built for data fetching so it simplifies the async logic that is usually handwritten.

In this tutorial, I assume you have basic knowledge of React and Redux.

I'm gonna show you how to integrate Saga into plain Redux and RTK, explore some of its powerful features, and then dive into RTK Query and how it cuts down a lot of data fetching and caching logic.

Redux Saga

Redux-Saga is a powerful middleware library that allows Redux store to asynchronously interact with external APIs.

Redux-Saga uses an advanced ES6 feature called generator, it's simply a kind of function that can be paused in between the execution, similar to what happens when using async/await, which makes life easier while managing asynchronous calls.

Now let's do some coding and start building our app!

We will be setting up a simple API call that gets a list of random users - I will start with integrating Saga using plain Redux then try it out with RTK and see the difference between both integrations.

Plain Redux:

Step 1 - Initialize a React application

We will be using CRA to bootstrap our React app.
Run this command in the terminal:

npx create-react-app users-list && cd users-list

Step 2 - Setting up Redux

To set up Redux and integrate Saga we will need to install some packages:

npm install redux react-redux redux-saga --save

Let's now create our store

  • Create a new directory in the src folder and name it store.
  • Inside of it create configureStore.js
  • Copy the following code into the file we just created.
import { createStore, combineReducers } from 'redux';
import users from '../reducers/reducer';

export default function configureStore() {
    const reducers = combineReducers({ users });

    return {
        ...createStore(reducers)
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we set up the actions and root reducer

  • Create an actions directory in src
  • Create action.js file
  • Copy following code into it
export const GET_USERS_FETCH = 'GET_USERS_FETCH';
export const GET_USERS_SUCCESS = 'GET_USERS_SUCCESS';

export const getUsersFetch = () => ({
    type: GET_USERS_FETCH
})
Enter fullscreen mode Exit fullscreen mode
  • Create a reducers directory in src
  • Create reducer.js
  • Copy the following code into it.
import { GET_USERS_SUCCESS } from "../actions/action";

const users = (state = { users: [] }, action) => {
    switch (action.type){
        case GET_USERS_SUCCESS:
            return { ...state, users: action.users }
        default: 
            return state;
    }
}

export default users;
Enter fullscreen mode Exit fullscreen mode

Now we update index.js file in the root directory

import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import App from './App';
import configureStore from './store/configureStore';

const store = configureStore();

const root = ReactDOM.createRoot(document.getElementById('root'));

root.render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>
);
Enter fullscreen mode Exit fullscreen mode

Step 3 - Integrate our first Saga

  • Create sagas folder in the root directory
  • Create saga.js

As we mentioned earlier sagas are implemented using generators.
Let's create the first generator function.

Copy the code below into the saga file we just created

import { takeEvery } from "redux-saga/effects";
import { GET_USERS_FETCH } from "../actions/action";


function* getUsersSaga() {
    yield takeEvery(GET_USERS_FETCH, getUsersRequest);
}

export default getUsersSaga;
Enter fullscreen mode Exit fullscreen mode

Notice the asterisk that follows the function keyword, this is how you define a generator function in ES6.

yield pauses the function until what comes after it is fulfilled, you can think of it as await in async function.

takeLatest is a special redux saga effect - Effects are simply instructions to the middleware to perform some operations

So here takeLatest fires the instance immediately and takes the latest version of data, terminating any previous active request.

takeLatest takes two parameters, the first is an action type string that triggers the function that we put in the second parameter.

There are much more helpful Saga effects - There is takeEvery which allows multiple requests to be fired concurrently, so you can start a new instance without terminating the previous active request.

throttle is also very useful to prevent too many API requests.

For more info about Saga effects check the official docs

Copy the code down below and update the saga file

import { call, put, takeEvery } from "redux-saga/effects";
import { GET_USERS_FETCH, GET_USERS_SUCCESS } from "../actions/action";

function* getUsersRequest() {
    const users = yield call(() => fetch('https://jsonplaceholder.typicode.com/users'));
    const formattedUsers = yield users.json(); 
    yield put({ type: GET_USERS_SUCCESS, formattedUsers });
}

function* getUsersSaga() {
    yield takeEvery(GET_USERS_FETCH, getUsersRequest);
}

export default getUsersSaga;
Enter fullscreen mode Exit fullscreen mode

Here we add the function that contains the actual logic for the API call.
put obviously puts the data we got from the API response and updates the store with it.

Now let's wire things up and update the store with the code below

import { createStore, combineReducers, applyMiddleware } from 'redux';
import createSagaMiddleware from 'redux-saga';
import users from '../reducers/reducer';

export default function configureStore() {
    const sagaMiddleware = createSagaMiddleware();
    const reducers = combineReducers({ users });

    return {
        ...createStore(reducers, applyMiddleware(sagaMiddleware)),
        runSaga: sagaMiddleware.run,
    }
}
Enter fullscreen mode Exit fullscreen mode

Above we simply create a saga middleware instance and return the run function that we are gonna pass the saga we just created into.

Now we update the index file

import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import App from './App';
import configureStore from './store/configureStore';
import getUsersSaga from './sagas/saga';

const store = configureStore();
store.runSaga(getUsersSaga);

const root = ReactDOM.createRoot(document.getElementById('root'));

root.render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>
);
Enter fullscreen mode Exit fullscreen mode

Finally, let's update our JSX - copy the code below into App.js and run the app.

import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { getUsersFetch } from './actions/action';


function App(){
  const dispatch = useDispatch();
  const users = useSelector(state => state.users.users);
  useEffect(() => {
    dispatch(getUsersFetch())
  }, [dispatch])
  return (
    <div className="App">
      {users.map(user => (<h2 key={Math.random()}>{user.name}</h2>))}
    </div>
  );
}
export default App;
Enter fullscreen mode Exit fullscreen mode

Now let's build the same saga functionality with RTK

We will first need to install the toolkit dependency

npm install @reduxjs/toolkit --save

Step 1 - Create usersSlice

RTK introduces slice which essentially replaces reducers and actions with a single feature that auto generates action types and action creators,

  • Let's create a new folder in the root directory and name it features.
  • Inside of it create usersSlice.js and copy the code below into it.
  • We initialized a slice using createSlice which is a function that takes an object of slice name, initialState and reducer functions, then export the actions that redux generated for us and the reducer function that manages our state.
import { createSlice } from "@reduxjs/toolkit";

const usersSlice = createSlice({
  name: "users",
  initialState: {
    users: []
  },
  reducers: {
    getUsersSuccess: (state, action) => {
      state.users = action.payload;
    },
  }
});

export const { getUsersSuccess } = usersSlice.actions;

export default usersSlice.reducer;
Enter fullscreen mode Exit fullscreen mode

Notice how we seem to be "mutating" the state object, but indeed RTK takes care of immutability behind the scene using Immer.

Step 2 - Update Saga

Now let's update our saga

import { call, put, takeEvery } from "redux-saga/effects";
import { getUsersSuccess } from "../features/userSlice";

function* getUsersRequest() {
    const users = yield call(() => fetch('https://jsonplaceholder.typicode.com/users'));
    const formattedUsers = yield users.json(); 
    yield put(getUsersSuccess(formattedUsers)) 
}

export function* getUsersSaga(action) {
    yield takeEvery('users/getUsersFetch', getUsersRequest)
}
Enter fullscreen mode Exit fullscreen mode

Notice in the getUsersSaga function how we are using the action type which is generated based on the slice name.

Step 3 - Update configureStore

Now we make a slight change in the configureStore file and we are good to go!

import {
    configureStore,
} from "@reduxjs/toolkit";
import createSagaMiddleware from "redux-saga";
import usersReducer from "../features/userSlice";


export default function configureStoreFunction() {
    const sagaMiddleware = createSagaMiddleware();

    return {
        ...configureStore({
            reducer: {
                users: usersReducer
            },
            middleware: [sagaMiddleware]
        }),
        runSaga: sagaMiddleware.run,
    }
}
Enter fullscreen mode Exit fullscreen mode

Here we use the RTK configureStore instead of the deprecated createStore that we used in plain Redux

configureStore takes an object of reducer and middleware - reducer is an object of the generated reducers , and we add the saga instance into the middleware array.

Another slight change in the imports of App.js

import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { getUsersFetch } from "./features/userSlice";

function App() {
  const dispatch = useDispatch();
  const users = useSelector((state) => state.users.users);
  useEffect(() => {
    dispatch(getUsersFetch())
  }, [dispatch])
  return (
    <div className="App">
      {users.map(user => (<h2 key={Math.random()}>{user.name}</h2>))}
    </div>
  );
}
export default App;
Enter fullscreen mode Exit fullscreen mode

Now it's time to explore RTK Query

RTK Query

We don't need to install any additional packages to use RTK Query.

Step 1 - createApi

Let's start by updating the usersSlice.js with the code below

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

export const apiSlice = createApi({
    reducerPath: 'UsersAPI'
})
Enter fullscreen mode Exit fullscreen mode

Here we are defining usersApi using createApi which is basically the core of RTK Query's functionality, then we pass an object and set the reducerPath to UsersAPI to identify it.

After that, we're going to set baseQuery, which will essentially be our client with the API base URL.

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

export const apiSlice = createApi({
    reducerPath: 'UsersAPI',
    baseQuery: fetchBaseQuery({ baseUrl: 'https://jsonplaceholder.typicode.com/' }),
})
Enter fullscreen mode Exit fullscreen mode

We then assign endpoints which will contain the logic that specifies the actual paths we are getting (or modifying) the fetch data from, and then start defining the fields using builder that we get from the arguments of endpoints.

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

export const apiSlice = createApi({
    reducerPath: 'UsersAPI',
    baseQuery: fetchBaseQuery({ baseUrl: 'https://jsonplaceholder.typicode.com/' }),
    endpoints: (builder) => ({
        getUsers: builder.query({
            query: () => 'users',
        }),
    })
})
Enter fullscreen mode Exit fullscreen mode

Now we set the basic query param that we are hitting, which would be users.

Here we used the query method because we are just getting data from the endpoint. If we are updating it, we would use mutation.

That will auto-generate a hook based on the endpoint's name, so that would be useGetUsersQuery, and then we are exporting it for later use.

Full code:

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

export const apiSlice = createApi({
    reducerPath: 'UsersAPI',
    baseQuery: fetchBaseQuery({ baseUrl: 'https://jsonplaceholder.typicode.com/' }),
    endpoints: (builder) => ({
        getUsers: builder.query({
            query: () => 'users',
        }),
    })
})

export const {
    useGetUsersQuery
} = apiSlice
Enter fullscreen mode Exit fullscreen mode

Step 2 - ApiProvider

Redux requires us to wrap our app with a Provider to which we pass our store.

When we use RTK Query for API access, we need to specify ApiProvider and pass the apiSlice into it.

Let's update index.js with the code below

import React from 'react';
import ReactDOM from 'react-dom/client';
import { ApiProvider } from "@reduxjs/toolkit/query/react";
import App from './App';
import { apiSlice } from "./features/userSlice";

const root = ReactDOM.createRoot(document.getElementById('root'));

root.render(
  <React.StrictMode>
    <ApiProvider api={apiSlice}>
      <App />
    </ApiProvider>
  </React.StrictMode>
);
Enter fullscreen mode Exit fullscreen mode

Let's now make use of what we have integrated

Update App.js with the following code:

import { useGetTodosQuery } from "./features/userSlice";

function App() {
  const {
    data: users,
    isLoading,
    isSuccess,
  } = useGetTodosQuery()  

  return (
    <div className="App">
      {isLoading && <span>Loading...</span>}
      {isSuccess && users.map(user => (<h2 key={Math.random()}>{user.name}</h2>))}
    </div>
  );
}
export default App;
Enter fullscreen mode Exit fullscreen mode

RTK query provides us with some cool stuff that we usually track and write its logic ourselves.

It encapsulates the entire data fetching process, so you can catch errors and trace the request status if it's still pending or fulfilled using isLoading without any handwritten boilerplate code that we usually do.

Let's now run the app and that's a wrap!

Full code can be found in the github repo here.

Conclusion:

As we can see, Redux Saga is a powerful tool with many cool effects built in, which saves you a lot of boilerplate code if you want to achieve what those effects offer.
However, with RTK Query, you can just kick off with a few lines of code without installing any additional dependencies. It's also the recommended approach by the Redux team for API interactions.
Finally, you should carefully pick which one will fit better for your project, as it could be a pain in the ass if you decide to change it later.

Top comments (2)

Collapse
 
markerikson profile image
Mark Erikson • Edited

Hi, I'm a Redux maintainer.

Note that we specifically recommend against using sagas in almost all cases!

Sagas are a very powerful tool, but almost no apps need that power. Additionally, sagas have always been a poor choice for data fetching use cases.

(I'll also note that there's no reason to use the legacy createStore API - you should always use RTK's configureStore, and if you are using sagas you can add them to configureStore via the middleware option.)

Today, RTK Query solves the data fetching use case, and the RTK "listener" middleware handles the reactive logic use case - it can do almost everything sagas can, but with a simpler API, smaller bundle size, and better TS support.

I recently did a talk on The Evolution of Redux Async Logic, where I covered what each tool does and when to use it. I'll paste the summary slide here:

Our Recommendations Today

What use case are you trying to solve?

Data Fetching
  • Use RTK Query as the default approach for data fetching and caching
  • If RTKQ doesn't fully fit for some reason, use createAsyncThunk
  • Only fall back to handwritten thunks if nothing else works
  • Don't use sagas or observables for data fetching!
Reacting to Actions / State Changes, Async Workflows
  • Use RTK listeners as the default for responding to store updates and writing long-running async workflows
  • Only use sagas / observables if listeners don't solve your use case well enough
Logic with State Access
  • Use thunks for complex sync and moderate async logic, including access to getState and dispatching multiple actions
Collapse
 
thisisgazzar profile image
Mohamed Elgazzar

Hi Mark, thanks for your comment!

Yeah, I actually mentioned in the end that RTK Query is the recommended approach, and I also added that createStore is now deprecated.

I will modify the post and make things clearer!