Chapter 7- React - Redux

We started React with the issue of real-time updates — if a variable is changing, then I want to render it in the UI instantly. Then we went to props, but there came our first stepping stone: prop drilling.
Solving that, we came across Context, and it seemed all done — no extra unnecessary states or props. But when building a large application, we happened to come across another stepping stone: multiple stores and multiple providers.
And there came the most powerful line:
“Single source of truth” — and hence, REDUX!
Redux comes in—a powerful library that helps developers write predictable, testable, and scalable applications. But before diving deep into how Redux works, let’s explore what it is, where it came from, and the specific challenges it solves in modern web development.
What is Redux?
Redux is a friendly tool that acts as a predictable state container for JavaScript applications. It's most often used with libraries like React, but you can also use it with Angular, Vue, or even plain JavaScript.
At its heart, Redux helps you keep track of your app's state in one place, called the store. It gives you a clear and organized way to update that state using actions and reducers, making everything consistent and easy to follow.
Instead of having state scattered all over different components, which can lead to confusion or bugs, Redux brings it all together in one spot—providing a single source of truth. This makes understanding and fixing data flow much simpler.
Redux works on three core principles:
Single source of truth – The entire state of the app is stored in one place.
State is read-only – The only way to change the state is to emit an action.
Changes are made with pure functions – Reducers take the previous state and an action to return a new state.
History and Origin of Redux
Redux was created in 2015 by Dan Abramov and Andrew Clark, taking a lot of inspiration from Facebook’s Flux architecture. Back then, developers were having a tough time managing state across different parts of large applications.
Flux introduced a unidirectional data flow and separated tasks using actions, dispatchers, and stores. But it was a bit complicated—there were multiple stores, multiple dispatcher functions, and lots of boilerplate code.
Dan Abramov wanted to simplify Flux by cutting out the unnecessary parts and using functional programming principles. He was inspired by Elm, a functional language for front-end development. Redux combined Flux’s unidirectional flow with Elm’s ideas of immutable state and pure functions, creating a simpler and highly testable model.
His talk, "Hot Reloading with Time Travel", showed off Redux's cool feature of tracking every change in the app's state over time, which got developers really excited. Since then, Redux has become one of the go-to libraries for state management, especially in React applications.
Why Redux Was Introduced
Let's dive into why Redux became a must-have tool.
Modern front-end applications, especially single-page applications (SPAs), are super dynamic. They handle:
User input across various components
Asynchronous data from APIs
Navigation state
Authentication
UI states like loading indicators, errors, modals, and more
Managing all this with React’s local state (useState
, useReducer
) can get tricky when:
Components are deeply nested
Multiple components need the same data
It's hard to trace or debug state changes
Updates lead to unnecessary re-renders or prop drilling headaches
Redux came to the rescue by centralizing the state and offering a reliable, consistent, and predictable way to update it. It helps keep things organized by separating logic (reducers), triggers (actions), and data (store).
Real-World Problems
1. State Management Across Components
In big applications, lots of components might need to access or change the same data. Without Redux, this can cause prop drilling or too much use of context. Redux makes it easy for any component to get any state it needs from the centralized store, using useSelector
and useDispatch
in React.
2. Predictability
Redux’s strict one-way flow and use of pure functions (reducers) make sure that state changes are always predictable. Every action that updates the state passes through a reducer, which makes it simple to track, debug, and test.
3. Debugging and Time Travel
With tools like the Redux DevTools, developers can:
Check out every action and see the resulting state.
Travel back in time to look at previous states.
Replay actions to track down bugs.
This is super handy in big applications where finding errors can be tough.
4. Consistency Across the App
Since the state is all in one place and updates follow the same rules, apps become more consistent. Even if lots of developers are working on a project, Redux makes sure everyone follows the same pattern for managing data.
5. Improved Testability
Reducers are pure functions with no side effects, which makes them really easy to unit test. You can give them a previous state and an action, and check that the new state is what you expect.
6. Decoupled Logic
Business logic doesn’t have to be in components anymore. It can be neatly handled in reducers, action creators, or middleware like Redux Thunk or Redux Saga, keeping your components focused on UI rendering.
When to Use Redux
While Redux offers powerful benefits, it’s not always necessary—especially in small projects. You might want to consider Redux if:
Your app has complex state logic.
Multiple components rely on shared state.
You want advanced debugging and state tracking.
You need a consistent structure as the team grows.
Redux vs Context API
Feature | Context API | Redux |
Purpose | Primarily for prop drilling avoidance (simpler state sharing). | Designed for managing complex and large-scale application state. |
Boilerplate Code | Minimal setup and code. | Requires more setup: actions, reducers, store, etc. |
Scalability | Works well for small to medium apps. | Better suited for large applications with complex state logic. |
Performance | May cause unnecessary re-renders if not optimized. | Optimized updates with selective rendering via reducers. |
State Management | Not centralized (shared through nested providers). | Centralized with a single source of truth (global store). |
Middleware Support | No built-in support. | Supports middleware (like redux-thunk, redux-saga) for async logic. |
DevTools | Limited debugging capabilities. | Powerful Redux DevTools for time-travel debugging, inspection, etc. |
Learning Curve | Easy to learn, part of React itself. | Steeper learning curve due to additional concepts. |
Community & Ecosystem | Limited tooling. | Rich ecosystem and widespread community support. |
Now let's start with Redux.
Here's how we will learn:
First, we will focus on pure React in JavaScript only. Once you understand the logic and how it actually works, using RTK or Thunk will be easier.
Next, we will learn about Redux DevTools.
Then, we will move on to Redux Toolkit.
After that, we will cover Redux Thunk.
Finally, we will explore RTK.
While learning each topic, we will begin with the theory and definitions. We will build applications, mainly focusing on CRUD, to-do, or cart functionality. These are basic enough to help us learn everything without getting too confused.
So let's start digging in.
Vanilla Redux (JS Only)
Imagine you’re building a toy store. You have:
A shelf where all the toys (data) are kept — this is like the Redux store.
A note saying "Add a teddy bear to the shelf" — this is an action.
A manager who reads the note and updates the shelf accordingly — this is the reducer.
And whenever the shelf changes, the shop gets updated — this is like the subscribe function updating your UI.
Redux helps you store data (like your app’s state) in one place, and update it in a predictable way.
1. Store — "The Shelf"
This is where all your app’s data lives.
It's like a big box that holds everything your app needs to remember.
The store is created using a special function.
You can ask the store: “Hey, what’s in you?” using
getState()
.You can tell it: “Hey, update yourself” using
dispatch()
.
In code:
// Import Redux (assuming you've installed it)
const Redux = require('redux');
// 1 Create a reducer function (more on this later)
const reducerFunction = (state = { items: [] }, action) => {
// For now, just return the state as-is
return state;
};
// 2 Create the Store (the "big box")
// - Pass in the reducer function (tells the store how to update)
const store = Redux.createStore(reducerFunction);
// 3 Check what's inside the store (like peeking into the box)
console.log(store.getState()); // { items: [] }
// Later, we'll use store.dispatch() to update it
2. Actions — "The Notes"
Actions are just plain JavaScript objects.
You write a note to the store: “Please add this todo.”
This note must have a
type
(like a label) that tells the store what kind of change to make.You can also send extra data using
payload
.
Example:
// Example 1: A simple action (just a type)
const incrementAction = {
type: 'INCREMENT_COUNTER' // No payload needed here
};
// Example 2: Action with payload (sending data)
const addTodoAction = {
type: 'ADD_TODO', // Required "label" for the Store
payload: 'Buy groceries' // Extra data (the actual todo text)
};
// Sending the action to the Store (we'll cover dispatch later)
// store.dispatch(addTodoAction);
3. Reducers — "The Manager Who Reads Notes"
A reducer is a function that knows how to change the state based on the action it receives.
It takes the current state and the action.
It returns a new state (not the same one!).
It never touches the original — it just makes a new one based on instructions.
Example:
// Example: A todo list reducer
function todoReducer(state = [], action) {
// Check the action type (like reading a note's label)
switch (action.type) {
case 'ADD_TODO':
// 📝 Return NEW state (old todos + new one)
return [...state, action.payload]; // Never modify original array!
case 'DELETE_TODO':
// Filter out the todo to delete
return state.filter(todo => todo.id !== action.payload.id);
default:
// Unknown action? Just return state unchanged
return state;
}
}
// Usage with the store:
// const store = Redux.createStore(todoReducer);
4. Dispatch — "Send the Note to the Manager"
You use dispatch()
to send the action to the store.
// First, create an action (the "note")
const addTodoAction = {
type: 'ADD_TODO',
payload: 'Study Redux' // The actual data
};
// Then dispatch it to the store
store.dispatch(addTodoAction);
// Or do it in one line:
store.dispatch({
type: 'ADD_TODO',
payload: 'Master Redux'
});
This tells the store: “Here’s a request to update the state.”
What Happens Under the Hood:
Redux calls your reducer with:
Current state
The action you dispatched
Reducer returns new state
Store updates → Notifies all listenersKey Notes:
1. Dispatch is the only way to trigger state changes.
2. The action flows: dispatch → reducer → new state.
5. Subscribe — "Let Me Know When the Shelf Changes"
When the store updates, you want your UI (like a list on the screen) to update too.
subscribe()
lets you listen to changes.You give it a function — and Redux will call it every time something changes.
// Set up the listener
const unsubscribe = store.subscribe(() => {
// This runs EVERY TIME the state changes
console.log(" State updated!", store.getState());
// In a real app, you'd update your UI here:
// renderTodos(store.getState().todos);
});
// Later, if you want to stop listening:
unsubscribe(); // Rings the doorbell!
// Simple counter logger
store.subscribe(() => {
console.log(`Current count: ${store.getState().count}`);
});
store.dispatch({ type: 'INCREMENT' }); // Logs: "Current count: 1"
store.dispatch({ type: 'INCREMENT' }); // Logs: "Current count: 2"
The callback runs after every dispatch
Always unsubscribe when you don't need updates anymore (prevents memory leaks)
In React, you'll use
useSelector
instead (we'll cover this later)
6. getState() — "What’s on the Shelf Right Now?"
This lets you peek into the store and see what’s inside.
// Peek at the current state
const currentState = store.getState();
console.log(" Shelf contents:", currentState);
// Practical example (Todo app):
console.log("My todos:", currentState.todos);
// Might log: ["Buy milk", "Learn Redux"]
Let’s Build It: ToDo App with Plain JavaScript and Redux
Now that you understand the theory, let’s build a ToDo App from scratch.
Folder Setup
todo-app/
├── index.html
└── app.js
Step 1: Create index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Redux ToDo App</title>
</head>
<body>
<h1>My ToDo List</h1>
<input id="todo-input" placeholder="What do you need to do?" />
<button id="add-btn">Add</button>
<ul id="todo-list"></ul>
<!-- Redux Library -->
<script src="https://unpkg.com/redux@4.2.1/dist/redux.min.js"></script>
<script src="app.js"></script>
</body>
</html>
Step 2: app.js
– Start Writing Code Step-by-Step
1. Define Action Types (Just text labels to avoid typos)
/* ========== 1. ACTION TYPES ========== */
// Constants to prevent typos in action.type strings
const ADD_TODO = 'ADD_TODO';
const DELETE_TODO = 'DELETE_TODO';
2. Create Action Creators (Functions that return action objects)
/* ========== 2. ACTION CREATORS ========== */
// Factory functions that create action objects
function addTodo(text) {
return {
type: ADD_TODO,
payload: text // The todo text to add
};
}
function deleteTodo(index) {
return {
type: DELETE_TODO,
payload: index // Array index of todo to remove
};
}
3. Write the Reducer (How state should change)
/* ========== 3. REDUCER ========== */
// Handles state updates based on action types
function todoReducer(state = [], action) {
switch (action.type) {
case ADD_TODO:
// Return new array with added todo (never modify existing state!)
return [...state, action.payload];
case DELETE_TODO:
// Return filtered array without the deleted todo
return state.filter((_, i) => i !== action.payload);
default:
// Always return current state for unknown actions
return state;
}
}
4. Create Store
/* ========== 4. STORE CREATION ========== */
// Create store with our reducer
const store = Redux.createStore(todoReducer);
5. Render ToDos on Screen (Whenever state changes)
/* ========== 5. RENDER FUNCTION ========== */
// Updates DOM to match current state
function render() {
const todos = store.getState(); // Get current todos
const list = document.getElementById('todo-list');
// Clear previous list items
list.innerHTML = '';
// Create list item for each todo
todos.forEach((todo, index) => {
const li = document.createElement('li');
li.textContent = todo;
// Add delete button for each todo
const delBtn = document.createElement('button');
delBtn.textContent = '❌';
delBtn.onclick = () => store.dispatch(deleteTodo(index));
li.appendChild(delBtn);
list.appendChild(li);
});
}
6. Listen to Changes
/* ========== 6. STATE SUBSCRIPTION ========== */
// Re-render whenever state changes
store.subscribe(render);
7. Add Event Listener to Button
/* ========== 7. EVENT LISTENERS ========== */
// Handle new todo submissions
document.getElementById('add-btn').addEventListener('click', () => {
const input = document.getElementById('todo-input');
const text = input.value.trim();
if (text) {
store.dispatch(addTodo(text)); // Add to store
input.value = ''; // Clear input
}
});
// Initial render
render();
That’s It! Your Redux ToDo App is Ready
Now you:
Added new todos to the store.
Deleted them from the store.
Watched your UI update itself automatically when the store changed.
Final Tips
Redux has only 3 moving parts: store, actions, reducers.
Always return new state, never mutate the old one.
Learn Redux without React first – it will make using it with React way easier.
Application URL - https://redux-playground-zxewrswt.stackblitz.io
Editor URL - https://stackblitz.com/edit/redux-playground-zxewrswt?file=index.js
Absolutely! Let’s explore Redux DevTools in a beginner-friendly and in-depth way — just like you’re watching or reading your first detailed vlog on it.
Redux DevTools
Imagine being able to see your entire app’s brain, travel back in time, pause it, and understand every move it makes — that’s Redux DevTools.
What Are Redux DevTools?
Redux DevTools is a browser extension (or library) that lets you visualize, inspect, and debug your Redux store.
Think of it like this:
You send an action: “Add to cart”
Redux updates the store
DevTools records that action and the change it made
You can see that action, the state before and after, and even go back in time
It’s like a black box for your app's brain — every thought, every memory, and every decision is saved and visible.
Why Redux DevTools Are Helpful (Real Benefits)
Visual Debugging
No more guessing what changed — see it.Time Travel
Pause, play, rewind your app’s state like a movie.Bug Tracking
Find out what went wrong and when.Learning Tool
Beginners can watch Redux work in real time.Performance Optimizing
Spot unnecessary actions or large state changes.
How to Install Redux DevTools
1. Install the Browser Extension
Chrome:
Go to Chrome Web StoreFirefox:
Go to Firefox Add-ons
Click "Add to browser" → Done!
2. Add Support in Your Code
If you’re using plain Redux (not Redux Toolkit):
const store = Redux.createStore(
rootReducer,
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
);
If using Redux Toolkit (recommended for React apps):
const store = configureStore({
reducer: rootReducer,
devTools: true // it's enabled by default
});
1. State Viewer
This shows the entire Redux state at any given moment.
Want to know what’s inside the store right now?
Click on "State" and see it as a tree view.
Tip: Click through nested objects to explore deep structures.
This is how it looks initially.
2. Action Logger
Every time you dispatch an action (like "ADD_TODO" or "DELETE_ITEM"), Redux DevTools:
Logs it in a timeline
Shows you the type and payload
Lets you click each one and see how the state changed
This helps you debug issues like: “Why didn’t my todo show up?”
3. Time Travel Debugging (Jump, Play, Pause)
This is like having a remote control for your app.
Buttons:
Jump to the first action
Pause recording actions
Replay actions one by one
Rollback to previous states
This lets you test your app at different stages without refreshing or clicking again.
4. Diff Comparison
Every time the state updates, DevTools shows you a diff:
What changed?
What stayed the same?
It highlights:
Added values
Removed ones
Modified values
Useful for finding small changes in large state trees.
5. Chart Visualizations
The “Chart” tab helps you see your app’s state like a map.
You can explore:
Actions
State changes
Dependencies between slices
This is especially powerful in large apps where:
You manage many reducers
You want to understand the flow
Now after all this lets understand multiple reducers
What is combineReducers
?
combineReducers
is a helper function from Redux that lets you combine multiple reducer functions into one main reducer.
Imagine your application state as a big object. Instead of having one function handle all the different parts of that object, combineReducers
allows you to divide the logic into separate functions, each taking care of its own piece of the state.
The Problem Without combineReducers
Let’s say you have two different features in your app:
A counter
A user profile
You could manage both like this:
/**
* Initial state object for the application.
* Contains:
* - count: A number representing a counter value
* - user: An object containing user information (name and email)
*/
const initialState = {
count: 0,
user: {
name: '',
email: ''
}
};
/**
* Root reducer function that manages the application state.
*
* @param {Object} state - Current state of the application. Defaults to initialState if not provided.
* @param {Object} action - Action object containing type and optional payload.
* @returns {Object} - New state after applying the action.
*/
function rootReducer(state = initialState, action) {
// Switch statement determines how state should change based on action type
switch (action.type) {
// Case for incrementing the counter
case 'INCREMENT':
return {
// Spread operator copies all existing state properties
...state,
// Override the count property with incremented value
count: state.count + 1
};
// Case for setting user data
case 'SET_USER':
return {
// Spread operator copies all existing state properties
...state,
// Override the user property with new user data from action payload
user: action.payload
};
// Default case returns current state for unknown actions
default:
return state;
}
}
As you add more features, this rootReducer
will become long and hard to read.
Enter combineReducers
Instead of one giant reducer, you can break it down:
/**
* Reducer for managing count state
*
* @param {number} state - Current count value, defaults to 0 if not provided
* @param {Object} action - Action object containing type and optional payload
* @returns {number} - New count value after applying the action
*/
function countReducer(state = 0, action) {
switch (action.type) {
// Handles INCREMENT action
case 'INCREMENT':
// Returns current state + 1 (pure function - doesn't mutate state)
return state + 1;
// Default case returns current state for any unhandled actions
default:
return state;
}
}
/**
* Reducer for managing user state
*
* @param {Object} state - Current user object, defaults to {name: '', email: ''} if not provided
* @param {string} state.name - User's name
* @param {string} state.email - User's email
* @param {Object} action - Action object containing type and payload
* @returns {Object} - New user object after applying the action
*/
function userReducer(state = { name: '', email: '' }, action) {
switch (action.type) {
// Handles SET_USER action
case 'SET_USER':
// Replaces entire user state with action payload
// Note: In real apps, you might want to validate payload structure
return action.payload;
// Default case returns current state for any unhandled actions
default:
return state;
}
}
Then combine them:
import { combineReducers } from 'redux';
/**
* Combines multiple reducers into a single reducer function.
* The resulting reducer manages a state object where each key corresponds
* to the state managed by its associated reducer.
*
* @type {import('redux').Reducer}
* @property {number} count - State managed by countReducer
* @property {Object} user - State managed by userReducer
* @property {string} user.name - User's name from userReducer
* @property {string} user.email - User's email from userReducer
*/
const rootReducer = combineReducers({
/**
* Reducer managing the count state slice
* @see countReducer
*/
count: countReducer,
/**
* Reducer managing the user state slice
* @see userReducer
*/
user: userReducer
});
// Alternative JSDoc syntax for more detailed typing:
/**
* @typedef {Object} RootState
* @property {number} count - The counter value from countReducer
* @property {Object} user - The user object from userReducer
* @property {string} user.name - User's name
* @property {string} user.email - User's email
*/
/**
* @type {import('redux').Reducer<RootState>}
*/
const rootReducer = combineReducers({
count: countReducer,
user: userReducer
});
Now, rootReducer
will automatically create a state structure like this:
{
count: 0,
user: {
name: '',
email: ''
}
}
And each reducer will only be responsible for its own part of the state.
How combineReducers
Works Internally
Under the hood, combineReducers
creates a function like this:
function rootReducer(state = {}, action) {
return {
count: countReducer(state.count, action),
user: userReducer(state.user, action)
};
}
It calls each reducer with its part of the state and the same action.
Using it in a Redux Store
Here’s how everything fits together:
import { createStore, combineReducers } from 'redux';
/**
* Counter reducer - handles numeric count state
* @param {number} [state=0] Initial count value (defaults to 0)
* @param {Object} action Redux action object
* @returns {number} New count value
*/
function countReducer(state = 0, action) {
switch (action.type) {
case 'INCREMENT':
return state + 1;
default:
return state;
}
}
/**
* User reducer - handles user profile state
* @param {Object} [state={name: '', email: ''}] Initial user state
* @param {string} state.name User's name
* @param {string} state.email User's email
* @param {Object} action Redux action object
* @returns {Object} New user state
*/
function userReducer(state = { name: '', email: '' }, action) {
switch (action.type) {
case 'SET_USER':
return {
...action.payload,
// You could add validation or transformations here
};
default:
return state;
}
}
/**
* Combined root reducer that manages the complete application state
* @type {import('redux').Reducer}
*/
const rootReducer = combineReducers({
count: countReducer, // Manages state.count
user: userReducer // Manages state.user
});
/**
* Redux store instance that holds the complete application state
* @type {import('redux').Store}
*/
const store = createStore(
rootReducer,
// Optional: You could add middleware or enhancers here
// window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
);
// Example action dispatches
store.dispatch({ type: 'INCREMENT' }); // Increments count by 1
store.dispatch({ type: 'INCREMENT' }); // Increments count again
store.dispatch({
type: 'SET_USER',
payload: {
name: 'John',
email: 'john@example.com'
}
});
/**
* Gets and logs the current complete Redux state
* @returns {void}
*/
function logCurrentState() {
console.log('Current Redux state:', store.getState());
// Example output:
// {
// count: 2,
// user: { name: 'John', email: 'john@example.com' }
// }
}
logCurrentState();
// Optional: Subscribe to store changes
const unsubscribe = store.subscribe(logCurrentState);
// Later you can unsubscribe when needed
// unsubscribe();
Nested combineReducers
/**
* Combined reducer for application-specific state (auth and settings)
* @type {import('redux').Reducer}
* @property {Object} auth - Authentication state managed by authReducer
* @property {Object} settings - Application settings managed by settingsReducer
*/
const appReducer = combineReducers({
/**
* Handles authentication state
* @see authReducer
*/
auth: authReducer,
/**
* Handles application configuration/settings
* @see settingsReducer
*/
settings: settingsReducer
});
/**
* Root reducer combining all state slices in the application
* @type {import('redux').Reducer}
* @property {Object} app - Contains auth and settings from appReducer
* @property {Object} ui - UI state managed by uiReducer
*/
const rootReducer = combineReducers({
/**
* Main application state (nested reducer)
* @see appReducer
*/
app: appReducer,
/**
* UI-related state (loading states, modals, etc.)
* @see uiReducer
*/
ui: uiReducer
});
// TypeScript-style alternative with full state shape documentation:
/**
* @typedef {Object} AppState
* @property {import('./authReducer').AuthState} auth
* @property {import('./settingsReducer').SettingsState} settings
*/
/**
* @typedef {Object} RootState
* @property {AppState} app
* @property {import('./uiReducer').UIState} ui
*/
/**
* @type {import('redux').Reducer<RootState>}
*/
const rootReducer = combineReducers({
app: appReducer,
ui: uiReducer
});
The Need for Combine Reducers
As applications grow, managing all state with a single reducer becomes unwieldy. Different parts of your state might need to handle different actions, and having everything in one massive switch statement is hard to maintain.
This is where the concept of combining reducers comes in. It allows you to:
Split your state management into smaller, more focused reducers
Keep each reducer responsible for a specific slice of the state
Combine them back into a single reducer function
Now time has come for react-redux
Installing Redux and react-redux
npm install redux react-redux
Or if you're using yarn:
yarn add redux react-redux
These packages provide:
redux
: The core Redux library that creates the store and handles actions/reducersreact-redux
: Official React bindings for Redux that provide components likeProvider
and hooks likeuseSelector
anduseDispatch
You might also want to install Redux DevTools for debugging:
npm install --save-dev redux-devtools-extension
1. Provider – The Connection Point
What it does:
The Provider
is like a big delivery hub that makes your Redux store accessible to your entire React app. Imagine your Redux store as a giant warehouse of data (like user info, cart items, etc.). The Provider
shares this data with any component that asks for it.
Key Concept:
// Import necessary libraries and components
import React from 'react'; // Main React library
import ReactDOM from 'react-dom'; // React DOM rendering utilities
import { Provider } from 'react-redux'; // Provides Redux store to React components
import { createStore } from 'redux'; // Redux function to create the application store
import rootReducer from './reducers'; // Combined reducers for the application
import App from './App'; // Main application component
/**
* Create the Redux store that holds the complete state tree of the app.
* The store is created by passing the root reducer which combines all
* individual reducers of the application.
*/
const store = createStore(rootReducer);
/**
* Render the application to the DOM.
*
* The <Provider> component makes the Redux store available to all
* child components that are wrapped in connect().
*
* The main <App> component is rendered inside the Provider,
* which allows all components in the app to access the Redux store.
*
* The application is mounted to the DOM element with id 'root'.
*/
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
You're telling React: “Hey, here’s a global data store. Any component inside <App />
can now tap into it.”
Think of it like:
A school has a library (Redux store). By placing the library key (Provider) at the entrance of the school, every student (component) can enter and borrow books (data), as long as they know what they’re looking for.
Key points about Provider:
It uses React's Context API behind the scenes
Only one store should be passed to it
Any component in the component tree can access the store if needed
It doesn't interfere with component's local state - you can use both together
2. useSelector – Reading From the Store
What it does:
The useSelector
hook lets your component read data from the Redux store. Think of it like a library card – you use it to get access to specific books from the library.
const cartItems = useSelector(state => state.cart.items);
You're saying: “Give me the list of items inside the cart part of the store.”
Reactivity:
Every time state.cart.items
changes, your component automatically re-renders to show the latest data. This is like a notification system: if a book you borrowed gets updated, the library tells you.
// Import the useSelector hook from react-redux to access the Redux store state
import { useSelector } from 'react-redux';
/**
* Cart component that displays all items in the shopping cart.
*
* This component connects to the Redux store to access the cart items
* and renders them in a list format.
*/
function Cart() {
// Select cart items from the Redux store state
// The useSelector hook takes a selector function that extracts
// the specific part of state we need (cart.items in this case)
const cartItems = useSelector(state => state.cart.items);
return (
<div>
{/*
Render each cart item as a separate div
Using item.id as the key for optimal React rendering performance
*/}
{cartItems.map(item => (
<div key={item.id}>
{/* Display the name of each cart item */}
{item.name}
</div>
))}
</div>
);
}
export default Cart;
Important notes about useSelector:
It takes a selector function that receives the entire Redux state
It will cause a re-render whenever the selected value changes
For performance, you can use multiple useSelector calls instead of returning a large object
For derived data, consider using memoized selectors with libraries like Reselect
3. useDispatch – Sending Actions to the Store
What it does:
useDispatch
is your way of sending instructions (actions) to the Redux store. It's like placing an order at a counter.
dispatch(addToCart(product))
You’re saying: “Hey Redux, please add this product to the cart.”
Real-world Analogy:
You're in a restaurant. dispatch
is like telling the waiter (Redux) to perform a task (like placing your food order). The action (e.g. addToCart
) tells Redux what you want to do, and the reducers will update the store accordingly.
// Import the useDispatch hook from react-redux to dispatch actions to the Redux store
import { useDispatch } from 'react-redux';
// Import the addToCart action creator from our actions file
import { addToCart } from './actions';
/**
* Product component that displays a single product and allows adding it to cart.
*
* @param {Object} props - Component props
* @param {Object} props.product - The product object containing product details
* @param {string} props.product.name - The name of the product
* @param {number|string} props.product.id - Unique identifier for the product
* @param {number} props.product.price - Price of the product
*/
function Product({ product }) {
// Initialize the dispatch function from Redux
// This will be used to send actions to the Redux store
const dispatch = useDispatch();
return (
<div className="product">
{/* Display the product name */}
<h3>{product.name}</h3>
{/*
Button to add the product to cart
When clicked, it dispatches the addToCart action with the product as payload
*/}
<button
onClick={() => dispatch(addToCart(product))}
aria-label={`Add ${product.name} to cart`}
>
Add to Cart
</button>
</div>
);
}
export default Product;
Best practices with useDispatch:
Consider creating action dispatcher functions to avoid dispatching directly in components
For async operations, use middleware like redux-thunk or redux-saga
Memoize dispatch functions with useCallback if passing them as props to optimized components
Project Time!!!
Features we will implement -
Add to Cart:
Products can be added to the cart
Duplicate items increment quantity instead of creating new entries
Remove from Cart:
Individual items can be removed
Entire cart can be cleared
Update Quantity:
Quantity can be increased/decreased with buttons
Direct quantity input could be added easily
Calculations:
Automatic calculation of total quantity
Automatic calculation of total amount
Per-item subtotal calculation
Step 1: Set Up Redux Store Structure
// types/cartTypes.ts
// Define the shape of a single cart item
export interface CartItem {
id: string | number;
name: string;
price: number;
quantity: number;
image?: string; // Optional product image
}
// Define the shape of the entire cart state
export interface CartState {
items: CartItem[];
totalQuantity: number;
totalAmount: number;
}
Step 2: Create Cart Actions
// actions/cartActions.ts
import { CartItem } from '../types/cartTypes';
// Action types
export const ADD_TO_CART = 'ADD_TO_CART';
export const REMOVE_FROM_CART = 'REMOVE_FROM_CART';
export const UPDATE_QUANTITY = 'UPDATE_QUANTITY';
export const CLEAR_CART = 'CLEAR_CART';
// Action creators
export const addToCart = (item: Omit<CartItem, 'quantity'>) => ({
type: ADD_TO_CART,
payload: { ...item, quantity: 1 }, // Default quantity to 1 when adding
});
export const removeFromCart = (id: string | number) => ({
type: REMOVE_FROM_CART,
payload: id,
});
export const updateQuantity = (id: string | number, quantity: number) => ({
type: UPDATE_QUANTITY,
payload: { id, quantity },
});
export const clearCart = () => ({
type: CLEAR_CART,
});
Step 3: Create Cart Reducer
// reducers/cartReducer.ts
// Importing required types
import { CartState, CartItem } from '../types/cartTypes';
// Importing action types
import {
ADD_TO_CART,
REMOVE_FROM_CART,
UPDATE_QUANTITY,
CLEAR_CART,
} from '../actions/cartActions';
// Initial state of the cart
const initialState: CartState = {
items: [],
totalQuantity: 0,
totalAmount: 0,
};
// Utility function to calculate total quantity and total amount from items array
const calculateTotals = (items: CartItem[]) => {
const totalQuantity = items.reduce((sum, item) => sum + item.quantity, 0);
const totalAmount = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
return { totalQuantity, totalAmount };
};
// Reducer function to handle cart-related actions
export const cartReducer = (
state = initialState,
action: any
): CartState => {
switch (action.type) {
// Add item to cart
case ADD_TO_CART: {
// Check if the item already exists in the cart
const existingItem = state.items.find(
(item) => item.id === action.payload.id
);
let updatedItems;
if (existingItem) {
// If item exists, increase its quantity
updatedItems = state.items.map((item) =>
item.id === action.payload.id
? { ...item, quantity: item.quantity + 1 }
: item
);
} else {
// If item doesn't exist, add it to the cart
updatedItems = [...state.items, action.payload];
}
// Recalculate totals after updating the items
const { totalQuantity, totalAmount } = calculateTotals(updatedItems);
return {
...state,
items: updatedItems,
totalQuantity,
totalAmount,
};
}
// Remove item from cart using its ID
case REMOVE_FROM_CART: {
const updatedItems = state.items.filter(
(item) => item.id !== action.payload
);
// Recalculate totals after removal
const { totalQuantity, totalAmount } = calculateTotals(updatedItems);
return {
...state,
items: updatedItems,
totalQuantity,
totalAmount,
};
}
// Update the quantity of a specific item
case UPDATE_QUANTITY: {
const { id, quantity } = action.payload;
// If quantity is zero or negative, return current state (or optionally remove item)
if (quantity <= 0) {
return state;
}
// Update the quantity for the matched item
const updatedItems = state.items.map((item) =>
item.id === id ? { ...item, quantity } : item
);
// Recalculate totals after quantity update
const { totalQuantity, totalAmount } = calculateTotals(updatedItems);
return {
...state,
items: updatedItems,
totalQuantity,
totalAmount,
};
}
// Clear the entire cart and reset to initial state
case CLEAR_CART:
return initialState;
// For any unknown action, return current state
default:
return state;
}
};
Step 4: Create Product Component
// components/Product.tsx
import React from 'react';
import { useDispatch } from 'react-redux';
import { addToCart } from '../actions/cartActions';
interface ProductProps {
product: {
id: string | number;
name: string;
price: number;
image?: string;
description?: string;
};
}
/**
* Product component displays a single product with add to cart functionality
* @param {ProductProps} props - Component props containing product information
*/
const Product: React.FC<ProductProps> = ({ product }) => {
const dispatch = useDispatch();
const handleAddToCart = () => {
dispatch(addToCart(product));
};
return (
<div className="product-card">
{product.image && (
<img
src={product.image}
alt={product.name}
className="product-image"
/>
)}
<h3 className="product-name">{product.name}</h3>
<p className="product-price">${product.price.toFixed(2)}</p>
{product.description && (
<p className="product-description">{product.description}</p>
)}
<button
onClick={handleAddToCart}
className="add-to-cart-btn"
aria-label={`Add ${product.name} to cart`}
>
Add to Cart
</button>
</div>
);
};
export default Product;
Step 5: Create Cart Component
// components/Cart.tsx
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
removeFromCart,
updateQuantity,
clearCart
} from '../actions/cartActions';
import { CartState } from '../types/cartTypes';
/**
* Cart component displays all items in the cart with options to modify quantities,
* remove items, or clear the entire cart
*/
const Cart: React.FC = () => {
const dispatch = useDispatch();
const { items, totalQuantity, totalAmount } = useSelector(
(state: { cart: CartState }) => state.cart
);
const handleRemoveItem = (id: string | number) => {
dispatch(removeFromCart(id));
};
const handleQuantityChange = (id: string | number, newQuantity: number) => {
dispatch(updateQuantity(id, newQuantity));
};
const handleClearCart = () => {
dispatch(clearCart());
};
if (items.length === 0) {
return (
<div className="cart-empty">
<p>Your cart is empty</p>
</div>
);
}
return (
<div className="cart-container">
<h2 className="cart-title">Your Cart ({totalQuantity} items)</h2>
<div className="cart-items">
{items.map((item) => (
<div key={item.id} className="cart-item">
<div className="item-info">
<h4 className="item-name">{item.name}</h4>
<p className="item-price">${item.price.toFixed(2)}</p>
</div>
<div className="item-quantity">
<button
onClick={() => handleQuantityChange(item.id, item.quantity - 1)}
disabled={item.quantity <= 1}
aria-label="Decrease quantity"
>
-
</button>
<span className="quantity-value">{item.quantity}</span>
<button
onClick={() => handleQuantityChange(item.id, item.quantity + 1)}
aria-label="Increase quantity"
>
+
</button>
</div>
<div className="item-subtotal">
${(item.price * item.quantity).toFixed(2)}
</div>
<button
onClick={() => handleRemoveItem(item.id)}
className="remove-item"
aria-label={`Remove ${item.name} from cart`}
>
×
</button>
</div>
))}
</div>
<div className="cart-summary">
<div className="total-amount">
<span>Total:</span>
<span>${totalAmount.toFixed(2)}</span>
</div>
<button
onClick={handleClearCart}
className="clear-cart-btn"
>
Clear Cart
</button>
<button className="checkout-btn">Proceed to Checkout</button>
</div>
</div>
);
};
export default Cart;
Step 6: Combine Reducers and Create Store
// store.ts
import { createStore, combineReducers } from 'redux';
import { cartReducer } from './reducers/cartReducer';
// Combine all reducers
const rootReducer = combineReducers({
cart: cartReducer,
// Add other reducers here if needed
});
// Define root state type based on the reducers
export type RootState = ReturnType<typeof rootReducer>;
// Create Redux store
export const store = createStore(rootReducer);
Step 7: Implement the Main App Component
// App.tsx
import React from 'react';
import { Provider } from 'react-redux';
import { store } from './store';
import Product from './components/Product';
import Cart from './components/Cart';
import './App.css';
// Sample products data
const sampleProducts = [
{
id: 1,
name: 'Wireless Headphones',
price: 99.99,
image: 'https://example.com/headphones.jpg',
description: 'High-quality wireless headphones with noise cancellation',
},
{
id: 2,
name: 'Smart Watch',
price: 199.99,
image: 'https://example.com/smartwatch.jpg',
description: 'Latest model with health tracking features',
},
// Add more products as needed
];
const App: React.FC = () => {
return (
<Provider store={store}>
<div className="app">
<header className="app-header">
<h1>E-Commerce Store</h1>
</header>
<main className="app-main">
<section className="products-section">
<h2>Products</h2>
<div className="products-grid">
{sampleProducts.map((product) => (
<Product key={product.id} product={product} />
))}
</div>
</section>
<aside className="cart-section">
<Cart />
</aside>
</main>
</div>
</Provider>
);
};
export default App;
Now, as we approach the more commonly used redux format , we have reached redux-toolkit.
So, let's begin with redux-toolkit.
What is Redux Toolkit?
Redux Toolkit is a library that wraps around the original Redux library and provides powerful utilities to simplify state management.
Why use it?
Reduces boilerplate
Comes with Immer for immutability
Includes Redux Thunk by default
Simplifies store setup
Encourages best practices
To install:
npm install @reduxjs/toolkit react-redux
What Are Slices?
In Redux Toolkit, Slices combine the reducer logic and action creators in one place. Think of it as a “slice” of your global state + the actions that can change it.
Creating a slice:
// Import the createSlice function from Redux Toolkit
// createSlice simplifies the process of creating Redux slices (reducer + actions)
import { createSlice } from '@reduxjs/toolkit';
// Create a counter slice using createSlice
// A slice contains the reducer logic and actions for a specific feature
const counterSlice = createSlice({
// Name of the slice, used to generate action types
name: 'counter',
// Initial state for this slice of the Redux store
initialState: { value: 0 },
// Reducer functions that handle state updates
reducers: {
// Increment reducer: increases the counter value by 1
// Uses immer under the hood for safe state mutation
increment: state => {
state.value += 1;
},
// Decrement reducer: decreases the counter value by 1
decrement: state => {
state.value -= 1;
},
// IncrementByAmount reducer: adds specified value to the counter
// Uses action payload to determine increment amount
incrementByAmount: (state, action) => {
state.value += action.payload;
}
}
});
// Export the generated action creators for use in components
// These functions create actions that we can dispatch
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
// Export the reducer function for use in the Redux store configuration
// This handles all actions defined in the slice's reducers field
export default counterSlice.reducer;
Note: You directly mutate
state
inside reducers, but thanks to Immer, it’s actually immutable behind the scenes.
Immer: Mutability Made Simple
Normally in Redux, you must return a new object to update state, which can be error-prone. Redux Toolkit uses Immer internally, which lets you "mutate" state directly while keeping it immutable.
Without Immer (vanilla Redux):
return { ...state, value: state.value + 1 };
With Immer (RTK):
state.value += 1;
Behind the scenes, Immer tracks your changes and produces a new immutable state object.
Todo App with Redux Toolkit
Let’s see Redux Toolkit in action with a simple Todo example.
1. Create todoSlice.js
// Import createSlice from Redux Toolkit
// createSlice provides an efficient way to create Redux state slices with associated reducers and actions
import { createSlice } from '@reduxjs/toolkit';
// Create a todo slice to manage todo items in the Redux store
const todoSlice = createSlice({
// Slice name used to generate action types
name: 'todo',
// Initial state is an empty array to store todo items
initialState: [],
// Reducer functions to handle different todo operations
reducers: {
// Add a new todo item to the state
// Uses immer to allow safe state mutation
addTodo: (state, action) => {
// Push new todo object with unique ID, text from payload, and default completed status
state.push({
id: Date.now(), // Generate unique ID using current timestamp
text: action.payload, // Todo text from action payload
completed: false // Initial completed status
});
},
// Toggle the completed status of a todo item
toggleTodo: (state, action) => {
// Find the todo item by ID from action payload
const todo = state.find(t => t.id === action.payload);
if (todo) {
// Safely mutate the state with immer
todo.completed = !todo.completed;
}
},
// Delete a todo item from the state
deleteTodo: (state, action) => {
// Return new array filtered to exclude todo with matching ID
// Note: This uses a return statement instead of direct mutation
return state.filter(t => t.id !== action.payload);
}
}
});
// Export generated action creators for use in components
// These functions create actions that can be dispatched
export const { addTodo, toggleTodo, deleteTodo } = todoSlice.actions;
// Export the reducer function for Redux store configuration
// Handles all todo-related state updates
export default todoSlice.reducer;
2. Configure store
// Import configureStore from Redux Toolkit
// configureStore simplifies Redux store creation with good defaults
import { configureStore } from '@reduxjs/toolkit';
// Import the todo reducer from the todo slice file
import todoReducer from './todoSlice';
// Create the Redux store using configureStore
const store = configureStore({
// Combine multiple reducers into a single root reducer
reducer: {
// Define the 'todo' state branch managed by todoReducer
// This matches the slice name defined in todoSlice
todo: todoReducer
}
// Note: configureStore automatically enables:
// - Redux DevTools Extension
// - Thunk middleware
// - Development checks for common mistakes
});
// Export the configured store as the default export
// This store should be provided to the React app using the Provider component
export default store;
3. Connect in your React component
// Import React and necessary hooks
import React, { useState } from 'react';
// Import Redux hooks to interact with the store
import { useSelector, useDispatch } from 'react-redux';
// Import todo-related action creators
import { addTodo, toggleTodo, deleteTodo } from './todoSlice';
function TodoApp() {
// Local state for managing the input field
const [input, setInput] = useState('');
// Get todos from Redux store state using useSelector
// 'state.todo' corresponds to the reducer name in store configuration
const todos = useSelector(state => state.todo);
// Get dispatch function to send actions to Redux store
const dispatch = useDispatch();
// Handle adding new todo
const handleAdd = () => {
if (input) {
// Dispatch addTodo action with input text as payload
dispatch(addTodo(input));
// Clear input field after submission
setInput('');
}
};
return (
<div>
{/* Todo input section */}
<input
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Enter todo..."
/>
<button onClick={handleAdd}>Add</button>
{/* Todo list display */}
<ul>
{todos.map(todo => (
<li
key={todo.id}
// Toggle todo completion on click
onClick={() => dispatch(toggleTodo(todo.id))}
>
{/* Show text with strike-through if completed */}
<span style={{
textDecoration: todo.completed ? 'line-through' : 'none'
}}>
{todo.text}
</span>
{/* Delete button with stopPropagation to prevent toggle */}
<button
onClick={(e) => {
e.stopPropagation();
dispatch(deleteTodo(todo.id))
}}
>
❌
</button>
</li>
))}
</ul>
</div>
);
}
export default TodoApp;
What is Middleware in Redux?
Middleware sits between dispatching an action and the moment it reaches the reducer. It lets you intercept actions to:
Log actions
Delay actions
Handle side effects like API calls
Example: Logger middleware
// Redux middleware that logs actions and state changes
// Follows the middleware signature: store => next => action => fn
const logger = store => next => action => {
// Log the action before it propagates through the middleware chain
console.log('Dispatching:', action);
// Pass the action to the next middleware/reducer and get the result
// This is crucial for proper middleware chain execution
const result = next(action);
// Log the updated state after the action has been processed
// Note: store.getState() returns the entire state tree
console.log('Next State:', store.getState());
// Return the result to maintain the middleware chain
// This preserves compatibility with other middlewares
return result;
};
// Usage example:
// Apply this middleware when creating the store:
// const store = configureStore({
// middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger)
// });
// Key features:
// 1. Logs action type and payload before processing
// 2. Shows state changes after reducers have handled the action
// 3. Maintains proper middleware chain by returning next() result
// 4. Works with both synchronous and asynchronous actions
// Important considerations:
// - Should be added as the first middleware for accurate action logging
// - Might expose sensitive data in state/actions - use carefully in production
// - Consider conditional logging based on environment (dev vs prod)
// - For production use, consider using redux-logger package instead
In RTK, add custom middleware like this:
// Create Redux store using Redux Toolkit's configureStore
const store = configureStore({
// Combine all reducers into a single root reducer
// rootReducer should be created using combineReducers if multiple reducers exist
reducer: rootReducer,
// Configure middleware chain
middleware: (getDefaultMiddleware) =>
// Start with default middleware provided by Redux Toolkit
// Includes: thunk, immutable check, serializable check
getDefaultMiddleware()
// Add custom logger middleware to the chain
.concat(logger)
});
// Key Configuration Details:
// 1. Default middleware includes:
// - Redux Thunk (async actions)
// - Immutability checks in development
// - Serializability checks in development
// 2. Logger middleware added last to capture complete state changes
// 3. Store automatically configures:
// - Redux DevTools Extension integration
// - Development checks for common errors
// Production Considerations:
// - Remove logger middleware in production using env check:
// .concat(process.env.NODE_ENV !== 'production' ? logger : [])
// - Consider removing development checks:
// getDefaultMiddleware({
// immutableCheck: false,
// serializableCheck: false
// })
API Calls in Redux Toolkit
API calls are side effects and not supposed to happen in reducers. We use middleware (like Redux Thunk) for this.
RTK also has createAsyncThunk
for handling async actions.
Example: Fetching posts
Create async thunk
// Import Redux Toolkit async utilities
// createAsyncThunk handles asynchronous action creators
// createSlice creates Redux slice with async reducer support
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
// Create async thunk for fetching posts from API
// First argument: action type prefix (sliceName/actionName)
// Second argument: async payload creator callback
export const fetchPosts = createAsyncThunk(
'posts/fetchPosts', // Action type prefix for generated action types:
// - pending: 'posts/fetchPosts/pending'
// - fulfilled: 'posts/fetchPosts/fulfilled'
// - rejected: 'posts/fetchPosts/rejected'
async () => {
// Perform actual API call
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
// Return parsed JSON as payload for fulfilled action
// Note: errors are automatically caught and handled
return response.json();
// For error handling, you could add try/catch:
// try {
// const res = await fetch(...);
// return await res.json();
// } catch (error) {
// return thunkAPI.rejectWithValue(error.message);
// }
}
);
// Typical next steps (not shown here):
// 1. Create slice with extraReducers to handle async states
// 2. Export reducer and actions
// 3. Use in component with dispatch(fetchPosts())
// Best practices notes:
// - Keep async logic in thunks separate from reducers
// - Handle loading/error states in slice
// - Consider adding cancellation with AbortController
// - Use TypeScript interfaces for API response types
// - Add error handling for production use
// - Consider rate-limiting for API protection
postsSlice.js
// Create posts slice to manage API fetched posts data
const postsSlice = createSlice({
// Slice name used to generate action types
name: 'posts',
// Initial state shape for posts data management:
// - data: array to store fetched posts
// - loading: boolean for request status tracking
// - error: string/null to store error messages
initialState: {
data: [],
loading: false,
error: null
},
// Handle async thunk actions using extraReducers
extraReducers: (builder) => {
builder
// Handle pending state when fetchPosts is initiated
.addCase(fetchPosts.pending, state => {
state.loading = true;
state.error = null; // Clear previous errors
})
// Handle successful data fetch
.addCase(fetchPosts.fulfilled, (state, action) => {
state.loading = false;
// Store fetched data in state
// Note: This replaces existing data, consider merging for pagination
state.data = action.payload;
})
// Handle API request failure
.addCase(fetchPosts.rejected, (state, action) => {
state.loading = false;
// Store error message from rejected promise
// For better error handling, use action.payload with rejectWithValue
state.error = action.error.message;
});
}
});
// Export the generated reducer for store configuration
export default postsSlice.reducer;
// Typical usage patterns:
// - Dispatch(fetchPosts()) in components
// - Access state with useSelector:
// const { data, loading, error } = useSelector(state => state.posts)
// Best practices notes:
// 1. Consider adding data normalization for large datasets
// 2. Add error reset functionality in separate reducer
// 3. Implement caching strategies for repeated requests
// 4. Add cancellation support with AbortController
// 5. Use selectors for state access abstraction
// 6. Consider adding timestamp for data freshness checks
Redux Thunk (Async Logic)
Redux Thunk is a middleware that lets you return a function instead of an action. This is great for:
Delaying dispatch
Running async logic
Making API calls
RTK includes it by default!
Custom thunk example:
// Traditional Redux async action creator using thunk middleware
// Note: Consider using createAsyncThunk from Redux Toolkit for modern approach
export const fetchUsers = () => async (dispatch) => {
// Dispatch loading state action
dispatch(startLoading());
try {
// Initiate API request to fetch users
const res = await fetch('/api/users');
// Check for HTTP errors (fetch doesn't reject on HTTP error status)
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
// Parse JSON response
const data = await res.json();
// Dispatch success action with formatted data
dispatch(usersLoaded(data));
} catch (err) {
// Dispatch error action with error message
// Consider adding error formatting/handling logic here
dispatch(setError(err.message));
// For better error handling, you might want to:
// - Distinguish between network errors and server errors
// - Log errors to monitoring service
// - Dispatch specific error types
}
};
// Typical action creators needed (not shown here):
// - startLoading(): { type: 'users/startLoading' }
// - usersLoaded(payload): { type: 'users/loaded', payload }
// - setError(message): { type: 'users/setError', payload: message }
// Modern Redux Toolkit alternative:
// Use createAsyncThunk + createSlice extraReducers for better TypeScript support
// and automatic action type generation
// Best practices notes:
// 1. Add request cancellation support with AbortController
// 2. Consider adding timeout handling
// 3. Add data validation/normalization for API responses
// 4. Use proper API service layer abstraction
// 5. Add retry logic for transient errors
// 6. Consider adding caching mechanisms
Here is the repo of todo application in redux toolkit -
https://github.com/vaishdwivedi1/redux-toolkit-todo
Finally, we come to the last and one of the most important topics of React: REDUX TOOLKIT QUERY.
What is RTK Query?
RTK Query is a zero-config, opinionated data fetching and caching layer for Redux apps. It handles the tough stuff of managing server-state (data from APIs) by automatically setting up Redux logic like actions, reducers, and selectors. Designed to fit seamlessly with Redux Toolkit, it saves you the hassle of writing thunks, middleware, or caching logic on your own.
Key Features:
Auto-Generated React Hooks: Create query and mutation hooks with a single function.
Smart Caching: Automatic deduplication, revalidation, and garbage collection.
Declarative API: Define endpoints with endpoints, and RTK Query handles the rest.
Optimistic Updates: Update the UI instantly while syncing with the server in the background.
TypeScript Support: First-class TypeScript integration for type-safe APIs.
Why Use RTK Query?
The Problem with Traditional Redux
In classic Redux, fetching data requires:
Writing async thunks or sagas.
Managing loading, error, and success states.
Caching logic to avoid redundant network requests.
Normalizing data for performance.
This leads to verbose code, maintenance overhead, and potential bugs.
How RTK Query Solves It
RTK Query automates these tasks:
Automatic Caching: Queries are cached by default, and identical requests are deduped.
Auto-Generated Hooks: Use
useQuery
oruseMutation
hooks for data fetching.Built-in Loading & Error States: Access
isLoading
,isError
, and more directly.Simplified Mutations: Update server data and automatically re-fetch related queries.
Setting Up RTK Query
Step 1: Install Redux Toolkit
npm install @reduxjs/toolkit react-redux
Step 2: Create an API Service
Define your API endpoints in a file like apiSlice.js
:
// Import core RTK Query functions for creating API service
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
/**
* Create a centralized API service instance using RTK Query
* This will be used to define endpoints and generate React hooks
*/
export const api = createApi({
/**
* Configure base query with default settings for all requests
* Uses fetchBaseQuery (wrapper around fetch) with base API URL
*/
baseQuery: fetchBaseQuery({
baseUrl: '/api', // Base URL for all API endpoints
// (Optional: Add default headers/authentication here)
}),
/**
* Define API endpoints for CRUD operations
* Endpoints can be queries (GET) or mutations (POST/PUT/DELETE)
* Each endpoint will automatically generate a React hook
*/
endpoints: (builder) => ({
/**
* GET /posts endpoint configuration
* Query endpoint for fetching list of posts
* Generated hook: useGetPostsQuery()
*/
getPosts: builder.query({
query: () => ({
url: '/posts', // Endpoint URL (appended to baseUrl)
method: 'GET', // Default method, can be omitted
// (Optional: Add params/headers here)
}),
// (Optional: Add transformResponse/transformError here)
// (Optional: Add providesTags for caching)
}),
/**
* POST /posts endpoint configuration
* Mutation endpoint for creating new post
* Generated hook: useAddPostMutation()
*/
addPost: builder.mutation({
query: (newPost) => ({
url: '/posts', // Endpoint URL
method: 'POST',
body: newPost, // Request payload
// (Optional: Add headers/params here)
}),
// (Optional: Add invalidatesTags for cache invalidation)
// (Optional: Add onQueryStarted for optimistic updates)
}),
}),
});
/**
* Auto-generated React hooks from defined endpoints
* Hooks follow naming convention:
* - use + EndpointName + Query/Mutation
* Example: getPosts(query) -> useGetPostsQuery
* addPost(mutation) -> useAddPostMutation
*
* Hook features:
* - Automatic data fetching/caching
* - Loading/error states management
* - Cache lifecycle control
*/
export const {
useGetPostsQuery, // Hook for fetching posts
useAddPostMutation // Hook for creating posts
} = api;
Explanation:
createApi Configuration:
Core setup for API service
Combines base configuration with endpoints
Manages caching, hook generation, and Redux integration
fetchBaseQuery:
Lightweight fetch wrapper
Handles request/response serialization
Base URL for all subsequent endpoints
Endpoint Builder:
builder.query
for read operations (GET)builder.mutation
for write operations (POST/PUT/DELETE)Each endpoint configuration includes:
URL path
HTTP method
Request configuration (body, headers, params)
Auto-Generated Hooks:
Return object with { data, error, isLoading, refetch }
Mutation hooks return [triggerFn, { data, error, status }]
Handle full request lifecycle management
Step 3: Configure the Store
Add the API service to your Redux store:
// Import core Redux Toolkit store configuration utility
import { configureStore } from '@reduxjs/toolkit';
// Import API service instance from our slice definition
import { api } from './apiSlice';
/**
* Create and configure Redux store instance
* This serves as the central state management container
* Integrates with RTK Query for API state management
*/
export const store = configureStore({
/**
* Combine reducers from different slices
* RTK Query automatically generates its reducer under api.reducerPath
*/
reducer: {
// Dynamic namespace for RTK Query's internal state
// Uses the reducerPath defined in the API service (default: 'api')
[api.reducerPath]: api.reducer,
// (Optional: Add other reducers here for client-side state management)
// Example: user: userReducer
},
/**
* Configure middleware chain
* Essential for RTK Query functionality including:
* - Caching
* - Polling
* - Request lifecycle management
*/
middleware: (getDefaultMiddleware) =>
// Start with default middleware including:
// - redux-thunk
// - Immutability check (development)
// - Serializability check (development)
getDefaultMiddleware()
// Add RTK Query's middleware for automatic caching and polling
.concat(api.middleware),
});
/**
* Store Configuration Summary:
* - Dedicated slice for RTK Query state (managed automatically)
* - Integrated middleware for handling API request lifecycle
* - Ready for expansion with additional reducers/middleware
*
* TypeScript Note: Store type is inferred automatically
* Export store type for potential type-safe access patterns
*/
// Optional: Export RootState type for type-safe selectors
// export type RootState = ReturnType<typeof store.getState>;
Explanation:
configureStore Purpose:
Creates the Redux store instance
Combines reducers and middleware
Enables DevTools integration automatically
Reducer Configuration:
[api.reducerPath]
uses the auto-generated reducer path from createApiMaintains RTK Query's internal state (cache, subscriptions, etc)
Shows where to add additional reducers for client state
Middleware Configuration:
Preserves default middleware (thunk, immutability checks)
Adds RTK Query's custom middleware for:
Automatic caching/revalidation
Request deduplication
Polling management
Error handling
TypeScript Support:
Automatic type inference for store state
Optional RootState type export for type-safe selectors
Step 4: Use Hooks in Components
Fetch data in your React components:
import { useGetPostsQuery, useAddPostMutation } from './apiSlice';
/**
* PostsList Component
* Demonstrates RTK Query integration for data fetching and mutations
* Features:
* - Automatic query execution on mount
* - Loading/error states handling
* - Optimistic UI updates (when configured)
* - Cache management through API tags
*/
function PostsList() {
// Query Hook: Fetch posts data
// Automatically triggers on component mount
// Returns { data, isLoading, isError, refetch, ... }
const {
data: posts, // Renamed for clarity
isLoading, // Loading state indicator
isError // Error state indicator
} = useGetPostsQuery(
undefined, // Query argument (none required in this case)
{
// Optional: Add polling/refetch options
// pollingInterval: 5000,
// refetchOnMountOrArgChange: true
}
);
// Mutation Hook: Post creation
// Returns [trigger function, { isLoading, error, data }]
const [addPost] = useAddPostMutation({
// Optional: Add fixed cache tags for invalidation
// fixedCacheTag: 'Posts'
});
// Loading state UI
if (isLoading) return <div>Loading...</div>;
// Error state UI
if (isError) return <div>Error fetching posts!</div>;
// Main render
return (
<div className="posts-container">
{/* Post list rendering */}
{posts?.map(post => (
<div key={post.id} className="post-item">
{post.title}
</div>
))}
{/* Post creation button */}
<button
onClick={() =>
// Trigger mutation with new post payload
addPost({
title: 'New Post',
// In real usage: Get values from form state
body: 'Sample content',
userId: 1
})
// Optional: Handle promise for error tracking
// .unwrap()
// .then(...)
// .catch(...)
}
className="add-post-btn"
>
Add Post
</button>
</div>
);
}
/**
* Component Behavior Details:
* - On mount: Automatically fetches posts via useGetPostsQuery
* - On addPost click:
* 1. Sends POST request
* 2. If invalidatesTags configured in API slice:
* - Automatically refetches posts after mutation
* 3. Without cache invalidation: Requires manual refetch
*
* Best Practice Notes:
* - Consider adding error boundaries for production
* - For forms: Use controlled components with local state
* - Add loading state for mutations (isLoading from mutation result)
* - Implement optimistic updates via onQueryStarted in API slice
* - Consider adding debouncing for rapid user interactions
*/
Explanation:
Query Hook (useGetPostsQuery):
Auto-fetches on component mount
Manages loading/error states internally
Returns destructured response with renamed data
Mutation Hook (useAddPostMutation):
Returns tuple with trigger function and status object
Trigger function accepts payload for POST request
Demonstrates optional promise handling
State Handling:
Clear loading/error states before rendering content
Optional loading state for mutation (not shown)
UI Structure:
Basic list rendering with unique keys
Action button with inline handler
Styling class placeholders
Performance Considerations:
Cache behavior depends on API slice configuration
Optional refetching strategies
Component Lifecycle Flow:
Mount → Fetch Posts → Render
↑
Add Post → Mutation → (Optional Cache Invalidation → Auto-refetch)
Advanced Features
1. Cache Management
Re-fetching on Demand: Use
refetch
from the query hook or invalidate tags to trigger re-fetches.Tags for Cache Invalidation: Tag endpoints to invalidate cached data after mutations:
endpoints: (builder) => ({ /** * GET /posts - Query Endpoint Configuration * Fetches all posts and manages caching */ getPosts: builder.query<Post[], void>({ query: () => '/posts', // Endpoint path /** * providesTags - Cache Management * Registers this data with 'Posts' tag * When other operations invalidate this tag: * - Automatic re-fetch occurs if mounted components are using this data * - Updates all dependent components with fresh data */ providesTags: ['Posts'], // Can also use result-based function: // providesTags: (result) => // result ? result.map(({ id }) => ({ type: 'Post', id })) : ['Post'] // Optional: Add transform for response normalization // transformResponse: (response: ApiResponse<Post[]>) => response.data, // Optional: Set caching time (seconds) // keepUnusedDataFor: 60, }), /** * POST /posts - Mutation Endpoint Configuration * Creates new post and invalidates cached data */ addPost: builder.mutation<Post, CreatePostDto>({ query: (newPost) => ({ url: '/posts', method: 'POST', body: newPost, // Request payload // headers: { 'Content-Type': 'application/json' }, // Default }), /** * invalidatesTags - Cache Invalidation * Invalidates all components using 'Posts' tag * Triggers automatic re-fetch of getPosts query * Can target specific cache entries: * invalidatesTags: (result, error, arg) => [{ type: 'Post', id: arg.id }] */ invalidatesTags: ['Posts'], // Full cache invalidation // Optional: Optimistic update configuration // onQueryStarted: (newPost, { dispatch, queryFulfilled }) => { // // Implement optimistic update logic here // } }), // Additional endpoint pattern example: // getPostById: builder.query<Post, string>({ // query: (id) => `/posts/${id}`, // providesTags: (result, error, id) => [{ type: 'Post', id }], // }), }),
Explanation:
Cache Tag System:
providesTags
: Declares what data a query providesinvalidatesTags
: Specifies which cached data to mark as staleTags can be:
String identifiers (
'Posts'
)Type+ID objects (
{ type: 'Post', id: '123' }
)
Query Lifecycle:
graph LR A[Component Mount] --> B[getPosts Query] B --> C{Cache Valid?} C -->|Yes| D[Return Cached Data] C -->|No| E[Fetch Fresh Data] F[addPost Mutation] --> G[Invalidate 'Posts' Tag] G --> H[Trigger getPosts Re-fetch]
Best Practices:
Granular Tagging: Prefer specific tags over broad invalidation
// Better than ['Posts'] for large datasets providesTags: (result) => result ? result.map(({ id }) => ({ type: 'Post', id })) : ['Post']
Optimistic Updates: Use
onQueryStarted
for instant UI feedbackError Handling: Add
transformErrorResponse
for consistent error formats
Performance Considerations:
Broad tags (
['Posts']
) simplify code but may cause over-fetchingSpecific tags optimize updates but require careful ID management
Balance between cache freshness and network requests
Full Integration Flow:
Component mounts and calls
useGetPostsQuery()
RTK Query checks cache for 'Posts' tagged data
Either returns cached data or fetches fresh
User triggers
addPost
mutationOn success:
Invalidates 'Posts' tag
All active
useGetPostsQuery()
hooks re-fetchUI automatically updates with fresh data
This configuration eliminates manual cache management while maintaining:
Data consistency
Automatic updates
Network optimization
Type safety (when using TypeScript)
2. Optimistic Updates
Update the UI before the server responds using onQueryStarted
:
addPost: builder.mutation<Post, CreatePostDto>({
query: (newPost) => ({
url: '/posts',
method: 'POST',
body: newPost,
}),
/**
* onQueryStarted - Lifecycle hook for mutation side effects
* Enables optimistic/pessimistic updates and rollback logic
* @param newPost - Mutation argument (new post data)
* @param context - Contains { dispatch, queryFulfilled, getState }
*/
async onQueryStarted(newPost, { dispatch, queryFulfilled }) {
try {
// Pessimistic Update Pattern (default)
// 1. Wait for server confirmation
const { data: createdPost } = await queryFulfilled;
// 2. Update local cache manually
dispatch(
api.util.updateQueryData(
'getPosts', // Target endpoint name
undefined, // Original query argument (none in this case)
(draftPosts) => { // Immer-powered draft modifier
// Add new post to existing cache
draftPosts.push(createdPost);
// Alternative: Prepend to list
// draftPosts.unshift(createdPost);
}
)
);
// 3. Can dispatch additional actions here
// dispatch(otherSlice.actions.notifyPostAdded());
} catch (error) {
// Error Handling Strategies:
// 1. Automatic rollback not needed here (pessimistic update)
// 2. Log error to monitoring service
// 3. Dispatch error notification
console.error('Post creation failed:', error);
// Example error handling:
// dispatch(notificationsSlice.actions.showError(
// 'Failed to create post'
// ));
}
},
// Optional: For optimistic updates instead:
// async onQueryStarted(newPost, { dispatch, queryFulfilled }) {
// const patchResult = dispatch(
// api.util.updateQueryData('getPosts', undefined, draft => {
// draft.push({ ...newPost, id: Date.now() }); // Temp ID
// })
// );
// try {
// await queryFulfilled;
// } catch {
// patchResult.undo(); // Rollback on error
// }
// },
}),
Key Concepts Explained:
Pessimistic vs Optimistic Updates:
graph TD A[Mutation Trigger] --> B{Pessimistic?} B -->|Yes| C[Wait for Server Response] C --> D[Update Cache on Success] B -->|No| E[Immediately Update Cache] E --> F[Handle Success/Error]
Cache Update Mechanics:
api.util.updateQueryData
uses Immer.js for immutable updatesOperates on normalized RTK Query cache
More efficient than full re-fetch when:
Server returns complete created object
UI needs instant feedback
Error Handling Considerations:
Pessimistic updates don't require rollbacks
Maintain consistent state with server
Better for data integrity, worse for perceived performance
When to Use This Pattern:
When server returns complete created entity
When UI doesn't need instant feedback
When data consistency is critical
Performance Comparison Table:
Metric | Pessimistic | Optimistic |
UI Response | Slower | Instant |
Data Consistency | High | Risky |
Error Handling | Simpler | Complex |
Network Usage | Same | Same |
Here's a comparison table highlighting the key differences between pessimistic and optimistic updates in data fetching:
Criteria | Pessimistic Updates | Optimistic Updates |
Data Consistency | Waits for server confirmation before UI update | Updates UI immediately, then syncs with server |
User Experience | Slower feedback (shows loading states) | Instant feedback (feels faster) |
Error Handling | No rollback needed (UI only updates on success) | Requires explicit rollback logic on failure |
Implementation Complexity | Simple (no undo logic) | Complex (requires undo/rollback handling) |
Use Cases | Critical operations (e.g., financial transactions) | Non-critical actions (e.g., likes, comments) |
Rollback Mechanism | Not required | Manual undo via patchResult.undo() |
Server Dependency | High (UI depends on server response) | Low (UI assumes success) |
Network Perception | Feels slower (waits for network) | Feels instant (UI updates immediately) |
Cache Management | Updates cache after server confirmation | Updates cache first, reverts on error |
Data Integrity Risk | Low (data always matches server) | Higher (temporary mismatch possible) |
Example Scenario | Bank transfer, medical records update | Social media likes, draft autosave |
When to Use Which?
Pessimistic:
Critical data where accuracy is paramount
When server response is needed for UI updates
Simple error handling preferred
Optimistic:
Actions where speed > perfect accuracy
Highly interactive UIs (e.g., drag-and-drop)
When server usually succeeds (low error rate)
Code Pattern Comparison
Pessimistic Update (RTK Query):
async onQueryStarted(arg, { dispatch, queryFulfilled }) {
try {
const { data } = await queryFulfilled; // Wait for server
dispatch(updateCache(data)); // Update UI after success
} catch (error) {
// No rollback needed
}
}
Optimistic Update (RTK Query):
async onQueryStarted(arg, { dispatch, queryFulfilled }) {
const patchResult = dispatch(updateCache(arg)); // Update UI first
try {
await queryFulfilled; // Sync with server
} catch {
patchResult.undo(); // Rollback on failure
}
}
Pessimistic = "Trust but verify"
Optimistic = "Assume the best, prepare for the worst"
Choose based on your app's needs: data criticality vs. user experience priorities.
3. Polling & Realtime Updates
// Execute query with polling configuration
const { data } = useGetPostsQuery(
// Query arguments (none required in this case)
undefined,
// Query options object
{
/**
* pollingInterval: 5000 - Auto-re-fetching configuration
*
* Features:
* - Automatically re-fetches data every 5 seconds
* - Continues polling while components are mounted
* - Smart refetching that respects cache tags
* - Network requests are deduplicated (no duplicate calls)
*
* Behavior:
* 1. Initial fetch on component mount
* 2. Subsequent fetches every 5000ms
* 3. Stops when component unmounts
* 4. Pauses when window loses focus (default behavior)
*
* Use cases:
* - Real-time dashboards
* - Live score updates
* - Any frequently changing data
*/
pollingInterval: 5000, // 5000ms = 5 seconds
// Optional additional configurations:
// skip: false, // Bypass polling conditionally
// refetchOnMountOrArgChange: true, // Force initial fetch
// refetchOnFocus: true, // Re-fetch when window regains focus
// refetchOnReconnect: true // Re-fetch when network reconnects
}
);
Explanation:
Polling Mechanism:
Automatic recurring data fetching
Background updates without user interaction
Built-in cache invalidation and deduplication
Performance Considerations:
graph LR A[Polling Start] --> B[Network Request] B --> C{Data Changed?} C -->|Yes| D[Update UI] C -->|No| E[No UI Changes]
Best Practices:
Use sparingly - balance freshness vs network load
Consider server load when choosing interval
Combine with
skip
option for conditional pollingFor true real-time needs, consider WebSockets + RTK Query cache updates
Alternate Pattern (Dynamic Polling):
const [pollingInterval, setPollingInterval] = useState(5000); const { data } = useGetPostsQuery(undefined, { pollingInterval, }); // Can dynamically adjust interval based on app state
This implementation pattern is ideal for scenarios where:
WebSocket integration isn't available
You need simple real-time-like behavior
Data changes frequently but predictably
You want built-in network optimization
We've covered a lot of theory, so let's put it into practice. This blog is already quite long, so I will attach a to-do application with the RTK GitHub repo below.
https://github.com/vaishdwivedi1/rtk-query
Conclusion
The article dives into Redux, a tool for managing state in JavaScript apps, especially popular with React. It kicks off by talking about the challenges of handling state and data flow in big apps and introduces Redux as the fix. The main ideas of Redux are having one source of truth, keeping state unchanged, and using pure functions. It covers the history and perks of Redux, then walks you through setting it up, like creating a store, sending actions, and using reducer functions. The article also breaks down Redux Toolkit, which makes Redux easier by cutting down on repetitive code and adding handy tools like Immer and Redux Thunk. It includes RTK Query, a feature for handling server-state with automatic caching and ready-made React hooks. Examples, like a to-do app and cart management, show how to use Redux and its new features in real life.
Do share your Thoughts!!!
Subscribe to my newsletter
Read articles from Vaishnavi Dwivedi directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by