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)
}
}
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
})
- 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;
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>
);
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;
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;
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,
}
}
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>
);
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;
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;
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)
}
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,
}
}
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;
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'
})
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/' }),
})
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',
}),
})
})
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
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>
);
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;
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)
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'sconfigureStore
, and if you are using sagas you can add them toconfigureStore
via themiddleware
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
createAsyncThunk
Reacting to Actions / State Changes, Async Workflows
Logic with State Access
getState
and dispatching multiple actionsHi 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!