This comprehensive guide is designed for software engineers already familiar with foundational Redux concepts (up to Redux v4.x, or general state management patterns in React). It delves into the latest advancements in Redux for React, with a strong focus on Redux Toolkit (RTK), Redux Thunk, RTK Query, and modern Redux architectural patterns. The goal is to equip you with the knowledge and practical skills to build robust, efficient, and maintainable applications using the most current Redux ecosystem. We will explore key features, best practices, and common pitfalls, providing clear explanations and actionable code examples.
Chapter 1: The Modern Redux Ecosystem
1.1 Why Redux Toolkit?
Redux, while powerful, often suffered from boilerplate code, complex setup, and opinionated approaches for common tasks like asynchronous logic. Redux Toolkit (RTK) was introduced to address these pain points. It’s the official, opinionated, batteries-included toolset for efficient Redux development.
What it is: RTK is a set of utilities designed to simplify Redux development. It provides functions to reduce boilerplate, handle immutability automatically (via Immer), and simplify common tasks like store configuration, defining reducers, and handling async logic.
Why it was introduced/changed: Prior to RTK, setting up a Redux store involved manually combining reducers, applying middleware, and often using third-party packages for immutability (e.g., redux-immutable) or async operations (e.g., redux-thunk, redux-saga). This led to verbose code and a steeper learning curve. RTK streamlines this by providing sensible defaults and built-in solutions, making Redux easier to learn and use, and encouraging best practices.
How it works: RTK abstracts away much of the manual setup and boilerplate. It combines several previously separate packages and patterns into a cohesive API:
configureStore: Simplifies store creation.createSlice: Generates action creators and reducers from a single object.createAsyncThunk: Handles async actions with a consistent API.createSelector(fromreselect): Encourages memoized selectors for performance.RTK Query: Provides a powerful solution for data fetching and caching.
1.2 Core Concepts Review (Brief)
Even with RTK, the core Redux principles remain. A brief recap is essential.
1.2.1 State, Actions, Reducers
- State: A single JavaScript object representing the entire application’s data. Redux manages this global state.
- Actions: Plain JavaScript objects that describe what happened. They have a
typeproperty and an optionalpayload. Actions are dispatched to signal a change in state.// Example Action const addTodoAction = { type: 'todos/addTodo', payload: { id: 1, text: 'Learn Redux', completed: false }, }; - Reducers: Pure functions that take the current
stateand anactionas arguments, and return a new state. They must be pure: given the same inputs, they must produce the same outputs, and they must not mutate the original state directly.Tip: With RTK,// Example Reducer (pre-RTK style) function todosReducer(state = [], action) { switch (action.type) { case 'todos/addTodo': return [...state, action.payload]; case 'todos/toggleTodo': return state.map((todo) => todo.id === action.payload ? { ...todo, completed: !todo.completed } : todo ); default: return state; } }createSlicesimplifies reducer creation by allowing direct “mutation” via Immer, which behind the scenes, still ensures immutability.
1.2.2 Store, Dispatch, Selector
- Store: The single source of truth for your application’s state. It holds the state tree, allows access to the state via
getState(), allows the state to be updated viadispatch(action), and registers listeners viasubscribe(listener). - Dispatch: The method used to send actions to the Redux store. When an action is dispatched, Redux runs it through the reducers to update the state.
store.dispatch(addTodoAction); - Selector: Functions that take the Redux state as an argument and return a specific piece of data from it. Selectors are crucial for optimizing performance by ensuring components only re-render when the specific data they depend on changes.Tip: Use
// Example Selector const selectTodos = (state) => state.todos; const selectCompletedTodosCount = (state) => state.todos.filter((todo) => todo.completed).length;useSelectorhook fromreact-reduxandcreateSelectorfromreselect(or implicitly viacreateSlice) for efficient selection.
Chapter 2: Redux Toolkit (RTK) Deep Dive
2.1 configureStore: Streamlining Store Setup
What it is: configureStore is a wrapper around the original Redux createStore that pre-configures a Redux store with good defaults, making setup much simpler.
Why it was introduced/changed: Traditional Redux store setup was verbose, requiring manual application of thunk middleware, redux-devtools-extension, and combineReducers. configureStore automates these common steps.
How it works: configureStore accepts a single configuration object.
// src/app/store.ts
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from '../features/todos/todosSlice';
import usersReducer from '../features/users/usersSlice';
export const store = configureStore({
reducer: {
todos: todosReducer,
users: usersReducer,
// Add other slices here
},
// middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(myCustomMiddleware),
// devTools: process.env.NODE_ENV !== 'production', // Defaults to true in dev
});
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>;
// Inferred type: {todos: TodosState, users: UsersState}
export type AppDispatch = typeof store.dispatch;
// src/index.tsx (or App.tsx)
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import App from './App';
import { store } from './app/store';
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);
2.1.1 Default Middleware and DevTools
configureStore automatically includes:
- Redux Thunk: For handling asynchronous logic (we’ll cover
createAsyncThunklater, which builds on this). - Immer.js: Enables “mutating” logic in reducers, which is then translated into immutable updates.
- Redux DevTools Extension: Support for easy debugging in development mode.
- Serializability and Immutability Checks: Middleware that warns you if you accidentally mutate state or dispatch non-serializable values (great for catching common mistakes).
Tip: If you need to add custom middleware, use the middleware option with getDefaultMiddleware(). Always concatenate your custom middleware to avoid losing the defaults.
2.2 createSlice: Reducers, Actions, and Selectors in One
What it is: createSlice is the most important RTK API. It combines the process of defining actions, action creators, and reducers into a single function call.
Why it was introduced/changed: Traditionally, you’d write a reducer, then separately define action types constants, and then action creator functions. This led to a lot of scattered, repetitive code. createSlice co-locates this logic, improving readability and maintainability.
How it works: You provide a name, an initialState, and an object of reducers. createSlice automatically generates action creators and action types that correspond to the reducer functions you write.
// src/features/todos/todosSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
// Define a type for the todo item
interface Todo {
id: string;
text: string;
completed: boolean;
}
// Define a type for the slice state
interface TodosState {
items: Todo[];
}
// Define the initial state using that type
const initialState: TodosState = {
items: [],
};
const todosSlice = createSlice({
name: 'todos', // This name is used to generate action types (e.g., 'todos/addTodo')
initialState,
reducers: {
// Reducer functions directly "mutate" state, thanks to Immer
addTodo: (state, action: PayloadAction<string>) => {
// action.payload will be the todo text
state.items.push({
id: new Date().toISOString(), // Simple ID generation
text: action.payload,
completed: false,
});
},
toggleTodo: (state, action: PayloadAction<string>) => {
// action.payload will be the todo ID
const todo = state.items.find((todo) => todo.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
},
removeTodo: (state, action: PayloadAction<string>) => {
// action.payload will be the todo ID
state.items = state.items.filter((todo) => todo.id !== action.payload);
},
},
});
// Export auto-generated action creators
export const { addTodo, toggleTodo, removeTodo } = todosSlice.actions;
// Export the reducer
export default todosSlice.reducer;
// Selectors (can be defined directly here or in a separate file)
export const selectTodos = (state: { todos: TodosState }) => state.todos.items;
export const selectCompletedTodosCount = (state: { todos: TodosState }) =>
state.todos.items.filter((todo) => todo.completed).length;
2.2.1 reducers vs. extraReducers
reducers: Contains reducer functions that handle actions defined within the same slice. As shown above,addTodo,toggleTodo,removeTododirectly correspond to actions generated bytodosSlice.extraReducers: Contains reducer functions that handle actions defined outside of the current slice. This is commonly used for:- Responding to actions dispatched by
createAsyncThunk(covered next). - Responding to actions from other slices.
- Responding to actions dispatched by
// src/features/users/usersSlice.ts (Example of extraReducers)
import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit';
interface User {
id: number;
name: string;
}
interface UsersState {
list: User[];
status: 'idle' | 'loading' | 'succeeded' | 'failed';
error: string | null;
}
const initialState: UsersState = {
list: [],
status: 'idle',
error: null,
};
// This thunk will be handled in extraReducers
export const fetchUsers = createAsyncThunk('users/fetchUsers', async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/users');
const data = await response.json();
return data as User[];
});
const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {
// Any sync reducers specific to 'users' slice can go here
},
extraReducers: (builder) => {
// Handle the lifecycle actions of fetchUsers thunk
builder
.addCase(fetchUsers.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchUsers.fulfilled, (state, action: PayloadAction<User[]>) => {
state.status = 'succeeded';
state.list = action.payload;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message || 'Failed to fetch users';
});
},
});
export default usersSlice.reducer;
export const selectAllUsers = (state: { users: UsersState }) => state.users.list;
export const selectUsersStatus = (state: { users: UsersState }) => state.users.status;
2.2.2 Immer Integration
What it is: Immer.js is a library that allows you to write simpler, “mutating” Redux reducers while still producing immutable state updates behind the scenes.
Why it was introduced/changed: The strict immutability requirement of Redux reducers often led to verbose and error-prone code, especially when dealing with nested state (e.g., return { ...state, data: { ...state.data, item: { ...state.data.item, value: newValue }}}). Immer simplifies this significantly.
How it works: createSlice uses Immer internally. When you “mutate” the state object within a reducers or extraReducers function, Immer creates a draft of the state, applies your changes to the draft, and then produces a brand new, immutable state object based on the changes in the draft. You don’t need to return anything from the reducer if you are “mutating” the state directly; Immer handles the return value for you. If you return a new state object, Immer won’t be used for that specific case.
// Example: How Immer works behind the scenes (you don't write this directly)
import produce from 'immer';
const oldState = {
user: {
name: 'Alice',
address: {
city: 'New York',
zip: '10001',
},
},
};
const newState = produce(oldState, (draft) => {
draft.user.address.zip = '10002'; // Direct mutation on draft
});
console.log(oldState === newState); // false (new state object created)
console.log(oldState.user === newState.user); // false (new user object created)
console.log(oldState.user.address === newState.user.address); // false (new address object created)
Tip: While Immer makes it safe to “mutate” state within RTK reducers, remember that immer does not make objects deeply immutable. Only the modified parts of the state tree will be new objects. References to unmodified parts of the state tree will remain the same.
2.3 createAsyncThunk: Handling Asynchronous Logic
What it is: createAsyncThunk is a utility for handling asynchronous actions (like API calls) that dispatches standard Redux actions representing the lifecycle of an async operation: pending, fulfilled, and rejected.
Why it was introduced/changed: Asynchronous operations are fundamental in modern applications. Before createAsyncThunk, redux-thunk middleware allowed dispatching functions, but managing the three states (loading, success, error) and corresponding actions/reducers was still manual boilerplate. createAsyncThunk automates this.
How it works: You provide a unique action type string (e.g., 'users/fetchUsers') and an async payload creator callback function. This callback performs the async logic (e.g., fetching data) and returns a promise. createAsyncThunk dispatches a pending action when the promise starts, a fulfilled action when it resolves, and a rejected action when it fails.
// src/features/posts/postsSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
interface Post {
userId: number;
id: number;
title: string;
body: string;
}
interface PostsState {
list: Post[];
status: 'idle' | 'loading' | 'succeeded' | 'failed';
error: string | null;
}
const initialState: PostsState = {
list: [],
status: 'idle',
error: null,
};
// Define an async thunk for fetching posts
export const fetchPosts = createAsyncThunk(
'posts/fetchPosts', // Action type prefix
async (userId: number | undefined, { rejectWithValue }) => {
try {
const url = userId ? `https://jsonplaceholder.typicode.com/posts?userId=${userId}` : 'https://jsonplaceholder.typicode.com/posts';
const response = await fetch(url);
if (!response.ok) {
// You can customize error handling here
const errorData = await response.json();
return rejectWithValue(errorData.message || 'Failed to fetch posts');
}
const data = await response.json();
return data as Post[];
} catch (err: any) {
return rejectWithValue(err.message || 'Network error');
}
}
);
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
// Sync reducers can go here
},
extraReducers: (builder) => {
builder
.addCase(fetchPosts.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchPosts.fulfilled, (state, action: PayloadAction<Post[]>) => {
state.status = 'succeeded';
state.list = action.payload; // Set posts to the fetched data
})
.addCase(fetchPosts.rejected, (state, action) => {
state.status = 'failed';
// The error message from rejectWithValue is available in action.payload
state.error = action.payload as string || action.error.message || 'Something went wrong';
});
},
});
export default postsSlice.reducer;
export const selectAllPosts = (state: { posts: PostsState }) => state.posts.list;
export const selectPostsStatus = (state: { posts: PostsState }) => state.posts.status;
export const selectPostsError = (state: { posts: PostsState }) => state.posts.error;
2.3.1 Lifecycle Actions: pending, fulfilled, rejected
When you dispatch fetchPosts(), three types of actions are automatically dispatched based on the promise’s status:
fetchPosts.pending: Dispatched when the async operation starts.action.payloadisundefined.fetchPosts.fulfilled: Dispatched when the promise resolves successfully.action.payloadcontains the resolved value.fetchPosts.rejected: Dispatched when the promise rejects.action.payloadcontains the value returned byrejectWithValue, oraction.errorcontains the error object.
These actions are handled in the extraReducers section of your slice.
2.3.2 Error Handling and Data Transformation
- Error Handling: Use the
rejectWithValueutility provided by thethunkAPIargument withincreateAsyncThunk. This allows you to dispatch a specific value as theaction.payloadfor therejectedaction, rather than just the default error object. - Data Transformation: The
payload creatorfunction is the ideal place to transform data before it’s stored in the Redux state. For example, if your API returns data in a different format than your state expects, perform the mapping here.
// Inside createAsyncThunk payload creator
async (arg, { getState, dispatch, extra, requestId, signal, rejectWithValue }) => {
// ...
// Example: transforming data
const rawData = await response.json();
const transformedData = rawData.map(item => ({ id: item.id, name: item.name.toUpperCase() }));
return transformedData; // This will be action.payload in fulfilled
}
2.4 Advanced createSlice Patterns
2.4.1 Reusable Reducer Logic
For common reducer patterns (e.g., setting a loading status, handling errors across multiple async operations), you can define reusable reducer functions or utilize a shared extraReducers builder.
// src/utils/reducerHelpers.ts
import { AnyAction, AsyncThunk, Draft } from '@reduxjs/toolkit';
// A generic status state type
interface StatusState {
status: 'idle' | 'loading' | 'succeeded' | 'failed';
error: string | null;
}
// Helper to handle pending state for any async thunk
export const handlePending = <State extends StatusState>(state: Draft<State>) => {
state.status = 'loading';
state.error = null;
};
// Helper to handle fulfilled state for any async thunk
export const handleFulfilled = <State extends StatusState>(state: Draft<State>) => {
state.status = 'succeeded';
};
// Helper to handle rejected state for any async thunk
export const handleRejected = <State extends StatusState>(state: Draft<State>, action: AnyAction) => {
state.status = 'failed';
state.error = (action.payload as string) || action.error.message || 'An unknown error occurred';
};
// src/features/products/productsSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { handlePending, handleFulfilled, handleRejected } from '../../utils/reducerHelpers';
interface Product {
id: number;
name: string;
price: number;
}
interface ProductsState {
items: Product[];
status: 'idle' | 'loading' | 'succeeded' | 'failed';
error: string | null;
}
const initialState: ProductsState = {
items: [],
status: 'idle',
error: null,
};
export const fetchProducts = createAsyncThunk(
'products/fetchProducts',
async (_, { rejectWithValue }) => {
try {
const response = await fetch('/api/products'); // Replace with your actual API endpoint
if (!response.ok) {
const errorData = await response.json();
return rejectWithValue(errorData.message || 'Failed to fetch products');
}
return (await response.json()) as Product[];
} catch (err: any) {
return rejectWithValue(err.message || 'Network error');
}
}
);
const productsSlice = createSlice({
name: 'products',
initialState,
reducers: {
// ...sync reducers
},
extraReducers: (builder) => {
builder
.addCase(fetchProducts.pending, handlePending) // Reusable pending handler
.addCase(fetchProducts.fulfilled, (state, action: PayloadAction<Product[]>) => {
handleFulfilled(state); // Reusable fulfilled status handler
state.items = action.payload; // Slice-specific logic
})
.addCase(fetchProducts.rejected, handleRejected); // Reusable rejected handler
},
});
export default productsSlice.reducer;
export const selectAllProducts = (state: { products: ProductsState }) => state.products.items;
export const selectProductsStatus = (state: { products: ProductsState }) => state.products.status;
export const selectProductsError = (state: { products: ProductsState }) => state.products.error;
2.4.2 Selector Best Practices
While createSlice doesn’t directly generate memoized selectors, it’s a good practice to define selectors alongside your slice or in a dedicated selectors.ts file within the feature folder. For performance, always use reselect (or createSelector from RTK itself) for derived state.
// src/features/todos/todosSlice.ts (updated with reselect)
import { createSlice, PayloadAction, createSelector } from '@reduxjs/toolkit';
// ... (interfaces and initialState as before)
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// ... reducers
},
});
export const { addTodo, toggleTodo, removeTodo } = todosSlice.actions;
export default todosSlice.reducer;
// Base selector to get the todos items array
const selectTodosState = (state: { todos: TodosState }) => state.todos.items;
// Memoized selector for filtering completed todos
export const selectCompletedTodos = createSelector(
[selectTodosState], // Input selectors
(todos) => todos.filter((todo) => todo.completed) // Transformer function
);
// Memoized selector for getting the count of active todos
export const selectActiveTodosCount = createSelector(
[selectTodosState],
(todos) => todos.filter((todo) => !todo.completed).length
);
Tip: Export selectors from the same file as your slice, or from a index.ts in your feature folder to create a cleaner import path for components.
Chapter 3: RTK Query: Data Fetching and Caching Made Easy
3.1 Introduction to RTK Query
What it is: RTK Query is a powerful data fetching and caching solution built on top of Redux Toolkit. It simplifies data management by providing a declarative way to interact with APIs, handle caching, revalidation, and much more, with minimal boilerplate.
Why RTK Query? (Comparison to createAsyncThunk for data fetching):
While createAsyncThunk is excellent for general-purpose asynchronous logic, it still requires manual management of:
- Loading states, error states for each request.
- Caching data to prevent unnecessary re-fetches.
- Revalidating cached data (e.g., after a mutation).
- Handling multiple requests for the same data (deduplication).
RTK Query handles all of these automatically. It’s purpose-built for API interaction, significantly reducing the amount of code you need to write for common data fetching patterns. It aims to eliminate useReducer, useEffect, and useState for data fetching states entirely.
How it works: RTK Query extends Redux Toolkit by creating a dedicated Redux slice for managing API states and generates React hooks (e.g., useGetTodosQuery, useAddTodoMutation) that components can use to interact with the API endpoints. It automatically handles the fetching, caching, and invalidation lifecycle.
3.2 Setting Up an API Slice
An API slice is the central point for defining all your API endpoints.
3.2.1 createApi and fetchBaseQuery
createApi: The core function to create your API slice. It requires:reducerPath: A unique string to identify this API’s slice in the Redux store.baseQuery: A function that handles the actual data fetching (e.g.,fetchoraxios).fetchBaseQueryis a lightweight wrapper aroundfetchthat automatically sets headers and handles JSON parsing.endpoints: An object where you define your API queries and mutations.tagTypes: An array of string tag names used for caching and invalidation.
// src/services/api.ts (or src/app/services/api.ts)
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
// Define a service using a base URL and expected endpoints
export const todoApi = createApi({
reducerPath: 'todoApi', // Unique reducer path for this API slice
baseQuery: fetchBaseQuery({ baseUrl: 'http://localhost:3000/' }), // Your API base URL
tagTypes: ['Todo'], // Define tags for cache invalidation
endpoints: (builder) => ({
getTodos: builder.query<Todo[], void>({
query: () => 'todos', // Endpoint URL
providesTags: ['Todo'], // Tag this query for invalidation
}),
addTodo: builder.mutation<Todo, Pick<Todo, 'text' | 'completed'>>({
query: (newTodo) => ({
url: 'todos',
method: 'POST',
body: newTodo,
}),
invalidatesTags: ['Todo'], // Invalidate 'Todo' cache after adding a todo
}),
updateTodo: builder.mutation<Todo, Todo>({
query: ({ id, ...patch }) => ({
url: `todos/${id}`,
method: 'PUT', // or 'PATCH'
body: patch,
}),
invalidatesTags: ['Todo'],
}),
deleteTodo: builder.mutation<void, string>({
query: (id) => ({
url: `todos/${id}`,
method: 'DELETE',
}),
invalidatesTags: ['Todo'],
}),
}),
});
// Export auto-generated hooks for use in your components
export const { useGetTodosQuery, useAddTodoMutation, useUpdateTodoMutation, useDeleteTodoMutation } = todoApi;
// src/app/store.ts (Integrate the API slice into your store)
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from '../features/todos/todosSlice';
import { todoApi } from '../services/api'; // Import your API slice
export const store = configureStore({
reducer: {
todos: todosReducer,
[todoApi.reducerPath]: todoApi.reducer, // Add the API reducer
},
// Adding the api middleware enables caching, invalidation, polling,
// and other useful features of `rtk-query`.
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(todoApi.middleware),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// src/interfaces/Todo.ts (Example type definition)
export interface Todo {
id: string;
text: string;
completed: boolean;
}
3.2.2 Defining Endpoints: Queries and Mutations
builder.query: Used forGETrequests where you fetch data.- The generic arguments
ResultType, ArgTypespecify the type of data returned and the type of the argument passed to the query. query: A function that returns the URL path or a request object.providesTags: An array ofTagstrings or an array of objects{ type: Tag, id: ID }. When these tags are invalidated, queries providing them will re-fetch.
- The generic arguments
builder.mutation: Used forPOST,PUT,PATCH,DELETErequests that modify data on the server.query: Similar toquery, but usually includesmethodandbody.invalidatesTags: An array ofTagstrings or objects{ type: Tag, id: ID }. Queries using these tags will be re-fetched after the mutation completes.
3.3 Consuming RTK Query Hooks
RTK Query automatically generates React hooks for each endpoint you define.
3.3.1 useQuery and useMutation
use<EndpointName>Query: Forqueryendpoints (GET requests). Returns an object containing:data: The fetched data (undefined initially).isLoading:truewhile the first fetch is in progress.isFetching:truefor any fetch (initial or re-fetch).isSuccess:trueif data was successfully fetched.isError:trueif an error occurred.error: The error object.refetch: A function to manually re-fetch the query.
use<EndpointName>Mutation: Formutationendpoints (POST, PUT, DELETE, PATCH requests). Returns an array[trigger, { data, isLoading, isSuccess, isError, error }].trigger: A function to initiate the mutation.- The second element of the array is an object with similar state properties as
useQuery.
// src/components/TodoList.tsx
import React, { useState } from 'react';
import { useGetTodosQuery, useAddTodoMutation, useUpdateTodoMutation, useDeleteTodoMutation } from '../services/api';
import { Todo } from '../interfaces/Todo'; // Assuming you have this interface
const TodoList: React.FC = () => {
const { data: todos, error, isLoading, isFetching } = useGetTodosQuery();
const [addTodo] = useAddTodoMutation();
const [updateTodo] = useUpdateTodoMutation();
const [deleteTodo] = useDeleteTodoMutation();
const [newTodoText, setNewTodoText] = useState('');
const handleAddTodo = async () => {
if (newTodoText.trim()) {
await addTodo({ text: newTodoText, completed: false }).unwrap(); // .unwrap() to throw error for try/catch
setNewTodoText('');
}
};
const handleToggleTodo = (todo: Todo) => {
updateTodo({ ...todo, completed: !todo.completed });
};
const handleDeleteTodo = (id: string) => {
deleteTodo(id);
};
if (isLoading) return <div>Loading todos...</div>;
if (error) return <div>Error: {JSON.stringify(error)}</div>;
return (
<div>
<h2>Todos</h2>
<div>
<input
type="text"
value={newTodoText}
onChange={(e) => setNewTodoText(e.target.value)}
placeholder="Add new todo"
/>
<button onClick={handleAddTodo}>Add Todo</button>
</div>
{isFetching && !isLoading && <div>Refetching...</div>} {/* Show refetching indicator */}
<ul>
{todos?.map((todo) => (
<li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
<span onClick={() => handleToggleTodo(todo)}>
{todo.text}
</span>
<button onClick={() => handleDeleteTodo(todo.id)} style={{ marginLeft: '10px' }}>
Delete
</button>
</li>
))}
</ul>
</div>
);
};
export default TodoList;
3.3.2 Automatic Re-fetching and Caching
- Caching: RTK Query automatically caches responses based on query arguments. If the same query is made twice, and the data is in the cache and hasn’t been invalidated, the cached data is returned immediately.
- Deduplication: If multiple components request the same data, RTK Query will only send one network request and share the result among all consumers.
- Invalidation: Mutations use
invalidatesTagsto specify which cached queries should be considered stale. When a tag is invalidated, any active query thatprovidesTagswith that tag will automatically re-fetch its data. This ensures your UI is always up-to-date after data modifications.
3.4 Advanced RTK Query Features
3.4.1 Optimistic Updates
What it is: Optimistic updates involve updating the UI immediately after a mutation is initiated, before the server responds. If the server request fails, the UI is then reverted. This provides a faster, more responsive user experience.
How it works: Use updateQueryData or updateQueryData (patch argument) within onQueryStarted for mutations.
// src/services/api.ts (add this to updateTodo and deleteTodo mutations)
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { Todo } from '../interfaces/Todo';
export const todoApi = createApi({
reducerPath: 'todoApi',
baseQuery: fetchBaseQuery({ baseUrl: 'http://localhost:3000/' }),
tagTypes: ['Todo'],
endpoints: (builder) => ({
getTodos: builder.query<Todo[], void>({
query: () => 'todos',
providesTags: ['Todo'],
}),
addTodo: builder.mutation<Todo, Pick<Todo, 'text' | 'completed'>>({
query: (newTodo) => ({
url: 'todos',
method: 'POST',
body: newTodo,
}),
// Using invalidatesTags is often simpler for general invalidation after adds/deletes
// For optimistic updates, consider `onQueryStarted` for specific patches
invalidatesTags: ['Todo'],
}),
updateTodo: builder.mutation<Todo, Todo>({
query: ({ id, ...patch }) => ({
url: `todos/${id}`,
method: 'PUT',
body: patch,
}),
// Optimistic update for toggling a todo's completed status
async onQueryStarted({ id, ...patch }, { dispatch, queryFulfilled }) {
// Optimistically update the cached data
const patchResult = dispatch(
todoApi.util.updateQueryData('getTodos', undefined, (draft) => {
const todo = draft.find((t) => t.id === id);
if (todo) {
Object.assign(todo, patch); // Apply the patch
}
})
);
try {
await queryFulfilled; // Wait for the actual mutation to complete
} catch {
patchResult.undo(); // If it fails, revert the optimistic update
/**
* Optionally, on failure, you can dispatch an action to show an error notification
* Or re-fetch the original query to ensure data consistency
* dispatch(todoApi.endpoints.getTodos.initiate(undefined, { forceRefetch: true }));
*/
}
},
// We still want to invalidate tags to ensure all related queries are fresh,
// especially if other parts of the UI depend on this data and aren't using this specific optimistic update logic.
invalidatesTags: ['Todo'],
}),
deleteTodo: builder.mutation<void, string>({
query: (id) => ({
url: `todos/${id}`,
method: 'DELETE',
}),
// Optimistic update for deleting a todo
async onQueryStarted(id, { dispatch, queryFulfilled }) {
const patchResult = dispatch(
todoApi.util.updateQueryData('getTodos', undefined, (draft) => {
// Remove the todo from the draft state
const index = draft.findIndex((todo) => todo.id === id);
if (index !== -1) {
draft.splice(index, 1);
}
})
);
try {
await queryFulfilled;
} catch {
patchResult.undo();
}
},
// InvalidatesTags for delete can be redundant if optimistic update fully covers it,
// but is useful as a fallback or for external dependencies.
invalidatesTags: ['Todo'],
}),
}),
});
3.4.2 Cache Invalidation and Tag Invalidation
tagTypes, providesTags, and invalidatesTags are the core of RTK Query’s caching strategy.
tagTypes: Defines the categories of data your API provides.providesTags: Inqueryendpoints, tells RTK Query what “tags” this data represents.invalidatesTags: Inmutationendpoints, tells RTK Query which cached data (identified by tags) becomes stale and needs to be re-fetched. This is the mechanism that ensures your UI updates automatically after a mutation.
You can also invalidate specific items using { type: 'Post', id: postId } for more granular control.
3.4.3 Polling and Debouncing
- Polling: Automatically re-fetches a query at a specified interval.
- Set
pollingIntervaloption inuseQueryhook:useGetTodosQuery(undefined, { pollingInterval: 5000 });(refetches every 5 seconds).
- Set
- Debouncing: You can debounce query arguments using standard React techniques (e.g.,
useStateanduseEffectwithsetTimeout/clearTimeout). RTK Query itself handles debouncing of multiple identical queries (deduplication) inherently.
3.4.4 Customizing Queries and Mutations
- Custom
baseQuery: IffetchBaseQueryisn’t enough (e.g., foraxiosor more complex authentication), you can provide your ownbaseQueryfunction. - Transforming Responses/Requests: The
transformResponseandtransformRequestoptions inbuilder.queryorbuilder.mutationallow you to modify data immediately before it’s cached or before it’s sent to the server. - Conditional Fetching: Pass
{ skip: boolean }to theuseQueryhook options to conditionally skip fetching.
// Example: Conditional fetching
const { data: userPosts } = useGetUserPostsQuery(userId, {
skip: !userId, // Skip fetching if userId is null/undefined
});
// Example: transformResponse
getUsers: builder.query<User[], void>({
query: () => 'users',
transformResponse: (rawResult: RawUser[]) => {
// Transform user data before it hits the cache
return rawResult.map(user => ({ ...user, fullName: `${user.firstName} ${user.lastName}` }));
},
}),
Chapter 4: Redux Selectors and Performance
4.1 The Importance of Selectors
Selectors are functions that extract specific pieces of data from the Redux state. They are crucial for:
- Encapsulation: Components don’t need to know the exact structure of the state. They just use selectors.
- Reusability: A selector can be used by multiple components.
- Performance Optimization: When combined with memoization, selectors prevent unnecessary re-renders of components.
Without proper selectors, a component using useSelector((state) => state.some.nested.property) might re-render even if state.some.nested.property itself didn’t change, but state.some.other.property did, because useSelector does a shallow equality check on the returned value.
4.2 reselect: Memoization for Performance
What it is: reselect is a library (and its createSelector function is included in Redux Toolkit) for creating memoized selector functions. A memoized selector will only re-calculate its output if its inputs (the results of its input selectors) have changed.
Why it’s important: If a derived piece of state (e.g., a filtered list of todos) is expensive to compute, reselect ensures that computation only happens when the underlying data truly changes, preventing unnecessary work and improving UI responsiveness.
How it works: createSelector takes an array of “input selectors” and a “result function”.
- Input selectors extract values from the Redux state.
- The result function takes the outputs of the input selectors as arguments and computes the final derived state.
reselectmemoizes the result function based on the shallow equality of its arguments (the outputs of the input selectors).
// src/features/todos/todoSelectors.ts (a dedicated file for selectors)
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from '../../app/store'; // Assuming RootState is defined
interface Todo {
id: string;
text: string;
completed: boolean;
}
// 1. Input Selector: Gets the entire todos array from state
const selectTodoItems = (state: RootState) => state.todos.items;
// 2. Input Selector (can be combined with other state parts)
const selectVisibilityFilter = (state: RootState) => state.filter; // Imagine a filter slice
// 3. Memoized Selector: Filters todos based on visibility filter
export const selectVisibleTodos = createSelector(
[selectTodoItems, selectVisibilityFilter], // Array of input selectors
(todos, filter) => {
console.log('Calculating visible todos...'); // This will only log when inputs change
switch (filter) {
case 'active':
return todos.filter((todo) => !todo.completed);
case 'completed':
return todos.filter((todo) => todo.completed);
default:
return todos;
}
}
);
// 4. Another Memoized Selector: Calculates completed count
export const selectCompletedTodosCount = createSelector(
[selectTodoItems],
(todos) => {
console.log('Calculating completed todos count...'); // Only logs when todos change
return todos.filter((todo) => todo.completed).length;
}
);
// In a component:
// const visibleTodos = useSelector(selectVisibleTodos);
// const completedCount = useSelector(selectCompletedTodosCount);
4.3 Selector Composition and Best Practices
- Co-locate Selectors: Define selectors alongside their respective slices or in a dedicated
selectors.tsfile within the feature folder. - Composition: Build more complex selectors by composing simpler ones. This makes selectors more modular and easier to test.
// Example: A selector for a single todo item export const selectTodoById = createSelector( [selectTodoItems, (state: RootState, todoId: string) => todoId], // Pass ID as an argument to the selector (todos, todoId) => todos.find((todo) => todo.id === todoId) ); // In a component: // const todo = useSelector((state) => selectTodoById(state, '123')); - Granularity: Selectors should return the smallest possible piece of data needed by a component. This helps
useSelector’s shallow equality check to prevent unnecessary re-renders. - Avoid Inline Selectors with
useSelector: Do not define selector functions directly insideuseSelectorcalls in your components if they create new array/object references on every render, as this will cause unnecessary re-renders.// BAD: This selector creates a new array every render, causing re-renders const completedTodos = useSelector(state => state.todos.items.filter(todo => todo.completed)); // GOOD: Use a memoized selector import { selectCompletedTodos } from './todosSlice'; // or todoSelectors.ts const completedTodos = useSelector(selectCompletedTodos); - Type Safety: Always type your selectors using TypeScript for better maintainability and to catch errors early.
Chapter 5: Modern Redux Architecture and Best Practices
5.1 Feature-Based Folder Structure
Organizing your Redux code (and indeed, your entire application) by feature makes it more scalable and easier to navigate. Each feature (e.g., todos, users, products) gets its own folder containing all related Redux logic (slice, selectors, types, API definitions if not using RTK Query for that specific API).
src/
├── app/
│ ├── store.ts // Redux store configuration
│ ├── hooks.ts // Typed useSelector/useDispatch hooks
│ └── services/ // RTK Query API slices
│ └── baseApi.ts // Base API slice for RTK Query
│ └── todosApi.ts // RTK Query slice for todos
│ └── authApi.ts // RTK Query slice for auth
├── features/ // Feature modules
│ ├── todos/
│ │ ├── index.ts // Exports from the feature
│ │ ├── todosSlice.ts // createSlice definition
│ │ ├── todosSelectors.ts // Memoized selectors for todos
│ │ ├── components/ // UI components related to todos
│ │ │ ├── TodoList.tsx
│ │ │ └── TodoItem.tsx
│ │ └── types.ts // Types/interfaces for todos
│ ├── users/
│ │ ├── index.ts
│ │ ├── usersSlice.ts
│ │ └── components/
│ ├── auth/
│ │ ├── authSlice.ts
│ │ ├── authSelectors.ts
│ │ └── components/
│ └── common/ // Reusable/global slices (e.g., notifications)
├── components/ // Reusable UI components (not feature-specific)
├── pages/ // Top-level page components
├── utils/ // Utility functions
├── assets/
├── index.tsx
└── App.tsx
Benefits:
- Locality: All related code for a feature is in one place.
- Maintainability: Easier to understand, modify, and debug a specific feature.
- Scalability: Prevents the root
reducersandactionsfolders from becoming massive.
5.2 Centralized Error Handling
While createAsyncThunk and RTK Query provide mechanisms to catch errors per thunk/endpoint, consider a centralized approach for displaying user-friendly messages or logging errors.
Global Error Slice: Create a Redux slice for managing global notifications or errors.
// src/features/common/notificationSlice.ts import { createSlice, PayloadAction } from '@reduxjs/toolkit'; interface NotificationState { message: string | null; type: 'success' | 'error' | 'info' | null; id: string | null; } const initialState: NotificationState = { message: null, type: null, id: null, }; const notificationSlice = createSlice({ name: 'notification', initialState, reducers: { showNotification: (state, action: PayloadAction<{ message: string; type: 'success' | 'error' | 'info' }>) => { state.message = action.payload.message; state.type = action.payload.type; state.id = new Date().toISOString(); // Unique ID for keying }, clearNotification: (state) => { state.message = null; state.type = null; state.id = null; }, }, }); export const { showNotification, clearNotification } = notificationSlice.actions; export default notificationSlice.reducer; export const selectNotification = (state: any) => state.notification; // Assuming notification in root stateHandling Errors in Thunks/Mutations: Dispatch
showNotificationwhen a thunk/mutation rejects.// Inside createAsyncThunk or RTK Query's onQueryStarted catch block // src/features/users/usersSlice.ts (createAsyncThunk example) import { showNotification } from '../../features/common/notificationSlice'; export const fetchUsers = createAsyncThunk( 'users/fetchUsers', async (_, { rejectWithValue, dispatch }) => { // Access dispatch from thunkAPI try { // ... fetch logic if (!response.ok) { const errorData = await response.json(); dispatch(showNotification({ message: errorData.message || 'Failed to load users', type: 'error' })); return rejectWithValue(errorData.message); } // ... } catch (err: any) { dispatch(showNotification({ message: err.message || 'Network error occurred', type: 'error' })); return rejectWithValue(err.message); } } ); // src/services/api.ts (RTK Query example - in updateTodo mutation) // ... async onQueryStarted({ id, ...patch }, { dispatch, queryFulfilled }) { const patchResult = dispatch( todoApi.util.updateQueryData('getTodos', undefined, (draft) => { // ... optimistic update }) ); try { await queryFulfilled; } catch (error) { patchResult.undo(); const errorMessage = (error as any)?.error?.data?.message || (error as any)?.error?.error || 'Failed to update todo.'; dispatch(showNotification({ message: errorMessage, type: 'error' })); } }, // ...Error Display Component: A global component that listens to the
notificationslice and displays messages (e.g., using a toast library).
5.3 Testing Redux Logic
Testing Redux logic (slices, thunks, RTK Query endpoints) is crucial for robust applications. Use a testing library like @testing-library/react for components and Jest for Redux logic.
5.3.1 Testing Slices
Test reducers by dispatching actions and asserting the state change.
// src/features/todos/todosSlice.test.ts
import todosReducer, { addTodo, toggleTodo, removeTodo } from './todosSlice';
describe('todosSlice', () => {
it('should return the initial state', () => {
expect(todosReducer(undefined, { type: '' })).toEqual({ items: [] });
});
it('should handle addTodo', () => {
const initialState = { items: [] };
const action = addTodo('New Todo');
const newState = todosReducer(initialState, action);
expect(newState.items.length).toBe(1);
expect(newState.items[0].text).toBe('New Todo');
expect(newState.items[0].completed).toBe(false);
});
it('should handle toggleTodo', () => {
const todoId = '1';
const initialState = {
items: [{ id: todoId, text: 'Test Todo', completed: false }],
};
const action = toggleTodo(todoId);
const newState = todosReducer(initialState, action);
expect(newState.items[0].completed).toBe(true);
const newStateAgain = todosReducer(newState, action);
expect(newStateAgain.items[0].completed).toBe(false); // Toggle back
});
it('should handle removeTodo', () => {
const todoId = '1';
const initialState = {
items: [
{ id: todoId, text: 'Test Todo', completed: false },
{ id: '2', text: 'Another Todo', completed: false },
],
};
const action = removeTodo(todoId);
const newState = todosReducer(initialState, action);
expect(newState.items.length).toBe(1);
expect(newState.items[0].id).toBe('2');
});
});
5.3.2 Testing Async Thunks
Mock the API calls using libraries like msw (Mock Service Worker) or jest.mock('node-fetch'). Test the dispatched actions and the state changes.
// src/features/posts/postsSlice.test.ts
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { fetchPosts } from './postsSlice';
import postsReducer from './postsSlice';
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
describe('postsSlice async thunks', () => {
beforeEach(() => {
// Mock global fetch for testing
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve([
{ userId: 1, id: 1, title: 'Test Post', body: '...' },
]),
})
) as jest.Mock;
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should dispatch fulfilled action when fetchPosts succeeds', async () => {
const store = mockStore({ posts: { list: [], status: 'idle', error: null } });
await store.dispatch(fetchPosts(1) as any); // Type assertion for mock store
const actions = store.getActions();
expect(actions[0].type).toEqual('posts/fetchPosts/pending');
expect(actions[1].type).toEqual('posts/fetchPosts/fulfilled');
expect(actions[1].payload).toEqual([{ userId: 1, id: 1, title: 'Test Post', body: '...' }]);
});
it('should dispatch rejected action when fetchPosts fails', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
ok: false,
json: () => Promise.resolve({ message: 'Failed to load' }),
})
) as jest.Mock;
const store = mockStore({ posts: { list: [], status: 'idle', error: null } });
await store.dispatch(fetchPosts(1) as any);
const actions = store.getActions();
expect(actions[0].type).toEqual('posts/fetchPosts/pending');
expect(actions[1].type).toEqual('posts/fetchPosts/rejected');
expect(actions[1].payload).toEqual('Failed to load');
});
it('should update state correctly on fetchPosts.fulfilled', () => {
const initialState = { list: [], status: 'idle', error: null };
const action = {
type: 'posts/fetchPosts/fulfilled',
payload: [{ userId: 1, id: 1, title: 'New Post', body: 'abc' }],
};
const newState = postsReducer(initialState, action);
expect(newState.status).toBe('succeeded');
expect(newState.list).toEqual([{ userId: 1, id: 1, title: 'New Post', body: 'abc' }]);
});
});
5.3.3 Testing RTK Query Endpoints
RTK Query provides api.endpoints.<endpointName>.initiate() for testing. Use msw to mock network requests.
// src/services/api.test.ts
import { setupApiStore } from '../app/testUtils'; // Helper for RTK Query testing
import { todoApi } from './api';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
rest.get('http://localhost:3000/todos', (req, res, ctx) => {
return res(ctx.json([{ id: '1', text: 'Test Todo', completed: false }]));
}),
rest.post('http://localhost:3000/todos', (req, res, ctx) => {
return res(ctx.json({ id: '2', text: 'New Todo', completed: false }));
}),
);
// Helper to set up a fresh RTK Query store for each test
const storeRef = setupApiStore(todoApi);
beforeAll(() => server.listen());
afterEach(() => {
server.resetHandlers();
storeRef.api.dispatch(todoApi.util.resetApiState()); // Clear RTK Query cache
});
afterAll(() => server.close());
describe('todoApi', () => {
it('should fetch todos', async () => {
const { data } = await storeRef.store.dispatch(todoApi.endpoints.getTodos.initiate());
expect(data).toEqual([{ id: '1', text: 'Test Todo', completed: false }]);
});
it('should add a todo and invalidate cache', async () => {
// First, fetch to populate cache
await storeRef.store.dispatch(todoApi.endpoints.getTodos.initiate());
// Mock the post request to return a new todo
server.use(
rest.post('http://localhost:3000/todos', (req, res, ctx) => {
return res(ctx.json({ id: '2', text: 'Brand New Todo', completed: false }));
})
);
// Mock the get request after invalidation (simulate re-fetch)
server.use(
rest.get('http://localhost:3000/todos', (req, res, ctx) => {
return res(ctx.json([
{ id: '1', text: 'Test Todo', completed: false },
{ id: '2', text: 'Brand New Todo', completed: false }
]));
})
);
const { data } = await storeRef.store.dispatch(
todoApi.endpoints.addTodo.initiate({ text: 'Brand New Todo', completed: false })
);
// The mutation returns the result of the POST
expect(data).toEqual({ id: '2', text: 'Brand New Todo', completed: false });
// Assert that getTodos query was re-fetched due to invalidation
// This requires inspecting the RTK Query cache or dispatching getTodos again
// For simplicity, we can just assert that the next `getTodos` call gets the new data
const { data: newTodos } = await storeRef.store.dispatch(todoApi.endpoints.getTodos.initiate());
expect(newTodos?.length).toBe(2);
expect(newTodos).toContainEqual({ id: '2', text: 'Brand New Todo', completed: false });
});
});
// src/app/testUtils.ts (Helper for RTK Query testing)
import { configureStore } from '@reduxjs/toolkit';
import type { Action, Reducer, CombinedState, EnhancedStore } from '@reduxjs/toolkit';
import type { Api } from '@reduxjs/toolkit/query';
// Helper function to create a test store with an RTK Query API slice
export const setupApiStore = <
A extends Api<any, Record<string, any>, string, string, string>,
R extends Record<string, Reducer | CombinedState<any>>
>(
api: A,
extraReducers?: R
) => {
const get = () =>
configureStore({
reducer: {
[api.reducerPath]: api.reducer,
...extraReducers,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(api.middleware),
});
type Store = ReturnType<typeof get>;
return {
api,
store: get() as EnhancedStore<
ReturnType<Store['getState']>,
Action,
ReturnType<Store['getMiddlewares']>
>,
};
};
5.4 Type Safety with TypeScript
TypeScript is highly recommended for Redux applications. RTK is built with TypeScript in mind, making it easy to infer types and ensure type safety throughout your state management.
5.4.1 Inferring Root State and Dispatch Types
The first step is to infer your RootState and AppDispatch types directly from your configureStore setup.
// src/app/store.ts
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from '../features/todos/todosSlice';
import usersReducer from '../features/users/usersSlice';
import notificationReducer from '../features/common/notificationSlice';
import { todoApi } from '../services/api';
export const store = configureStore({
reducer: {
todos: todosReducer,
users: usersReducer,
notification: notificationReducer,
[todoApi.reducerPath]: todoApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(todoApi.middleware),
});
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Then, create typed useSelector and useDispatch hooks to use throughout your components instead of the untyped ones from react-redux.
// src/app/hooks.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
5.4.2 Typing Slices, Actions, and Thunks
- Slice State: Define interfaces for your slice state.
// src/features/todos/todosSlice.ts interface Todo { id: string; text: string; completed: boolean; } interface TodosState { items: Todo[]; } const initialState: TodosState = { // Use this type for initialState items: [], }; - Payload Actions: Use
PayloadAction<T>from@reduxjs/toolkitto type action payloads.// src/features/todos/todosSlice.ts addTodo: (state, action: PayloadAction<string>) => { // action.payload will be string // ... }, toggleTodo: (state, action: PayloadAction<string>) => { // action.payload will be string // ... }, createAsyncThunk: Type thepayload creatorandthunkAPIarguments.// src/features/posts/postsSlice.ts export const fetchPosts = createAsyncThunk< Post[], // Return type of the payload creator (fulfilled action.payload) number | undefined, // First argument to the payload creator (e.g., userId) { state: RootState; // Optional: type for getState() dispatch: AppDispatch; // Optional: type for dispatch rejectValue: string; // Optional: type for rejectWithValue } >( 'posts/fetchPosts', async (userId, { rejectWithValue }) => { // ... } );
Chapter 6: Practical Projects
These projects demonstrate the application of Redux Toolkit, createAsyncThunk, and RTK Query in real-world scenarios. We’ll simulate a backend using a tool like json-server for API interactions.
Setup for all projects:
- Initialize a React project (e.g.,
npx create-react-app my-redux-app --template typescriptor Vite). - Install Redux Toolkit and React Redux:
npm install @reduxjs/toolkit react-redux - Install
json-serverglobally or as a dev dependency:npm install -g json-serverornpm install --save-dev json-server - Create a
db.jsonfile in your project root forjson-server.
To run json-server:
Open a terminal in your project root and run: json-server --watch db.json --port 3000
6.1 Project 1: Simple Task Manager (CRUD with RTK and createAsyncThunk)
This project will manage a list of tasks (todos) with basic CRUD operations. We’ll use createAsyncThunk for asynchronous API calls.
db.json content:
{
"tasks": []
}
Steps:
Define Task Interface:
// src/types/Task.ts export interface Task { id: string; title: string; completed: boolean; }Create Task Slice (
src/features/tasks/tasksSlice.ts):import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import { Task } from '../../types/Task'; interface TasksState { list: Task[]; status: 'idle' | 'loading' | 'succeeded' | 'failed'; error: string | null; } const initialState: TasksState = { list: [], status: 'idle', error: null, }; // Async Thunks for CRUD operations export const fetchTasks = createAsyncThunk('tasks/fetchTasks', async (_, { rejectWithValue }) => { try { const response = await fetch('http://localhost:3000/tasks'); if (!response.ok) throw new Error('Failed to fetch tasks'); return (await response.json()) as Task[]; } catch (err: any) { return rejectWithValue(err.message); } }); export const addTask = createAsyncThunk('tasks/addTask', async (title: string, { rejectWithValue }) => { try { const newTask: Omit<Task, 'id'> = { title, completed: false }; const response = await fetch('http://localhost:3000/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newTask), }); if (!response.ok) throw new Error('Failed to add task'); return (await response.json()) as Task; } catch (err: any) { return rejectWithValue(err.message); } }); export const updateTask = createAsyncThunk('tasks/updateTask', async (task: Task, { rejectWithValue }) => { try { const response = await fetch(`http://localhost:3000/tasks/${task.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(task), }); if (!response.ok) throw new Error('Failed to update task'); return (await response.json()) as Task; } catch (err: any) { return rejectWithValue(err.message); } }); export const deleteTask = createAsyncThunk('tasks/deleteTask', async (id: string, { rejectWithValue }) => { try { const response = await fetch(`http://localhost:3000/tasks/${id}`, { method: 'DELETE', }); if (!response.ok) throw new Error('Failed to delete task'); return id; // Return the ID to easily remove from state } catch (err: any) { return rejectWithValue(err.message); } }); const tasksSlice = createSlice({ name: 'tasks', initialState, reducers: { // Synchronous reducers if any, otherwise leave empty }, extraReducers: (builder) => { builder .addCase(fetchTasks.pending, (state) => { state.status = 'loading'; }) .addCase(fetchTasks.fulfilled, (state, action: PayloadAction<Task[]>) => { state.status = 'succeeded'; state.list = action.payload; }) .addCase(fetchTasks.rejected, (state, action) => { state.status = 'failed'; state.error = action.payload as string; }) .addCase(addTask.fulfilled, (state, action: PayloadAction<Task>) => { state.list.push(action.payload); }) .addCase(addTask.rejected, (state, action) => { state.error = action.payload as string; }) .addCase(updateTask.fulfilled, (state, action: PayloadAction<Task>) => { const index = state.list.findIndex(task => task.id === action.payload.id); if (index !== -1) { state.list[index] = action.payload; } }) .addCase(updateTask.rejected, (state, action) => { state.error = action.payload as string; }) .addCase(deleteTask.fulfilled, (state, action: PayloadAction<string>) => { state.list = state.list.filter(task => task.id !== action.payload); }) .addCase(deleteTask.rejected, (state, action) => { state.error = action.payload as string; }); }, }); export default tasksSlice.reducer; export const selectAllTasks = (state: { tasks: TasksState }) => state.tasks.list; export const selectTasksStatus = (state: { tasks: TasksState }) => state.tasks.status; export const selectTasksError = (state: { tasks: TasksState }) => state.tasks.error;Configure Store (
src/app/store.ts):import { configureStore } from '@reduxjs/toolkit'; import tasksReducer from '../features/tasks/tasksSlice'; export const store = configureStore({ reducer: { tasks: tasksReducer, }, }); export type RootState = ReturnType<typeof store.getState>; export type AppDispatch = typeof store.dispatch;Create Typed Hooks (
src/app/hooks.ts):import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; import type { RootState, AppDispatch } from './store'; export const useAppDispatch: () => AppDispatch = useDispatch; export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;Create TaskList Component (
src/components/TaskList.tsx):import React, { useEffect, useState } from 'react'; import { useAppDispatch, useAppSelector } from '../app/hooks'; import { fetchTasks, addTask, updateTask, deleteTask, selectAllTasks, selectTasksStatus, selectTasksError, } from '../features/tasks/tasksSlice'; import { Task } from '../types/Task'; const TaskList: React.FC = () => { const dispatch = useAppDispatch(); const tasks = useAppSelector(selectAllTasks); const status = useAppSelector(selectTasksStatus); const error = useAppSelector(selectTasksError); const [newTaskTitle, setNewTaskTitle] = useState(''); useEffect(() => { if (status === 'idle') { dispatch(fetchTasks()); } }, [status, dispatch]); const handleAddTask = () => { if (newTaskTitle.trim()) { dispatch(addTask(newTaskTitle)); setNewTaskTitle(''); } }; const handleToggleTask = (task: Task) => { dispatch(updateTask({ ...task, completed: !task.completed })); }; const handleDeleteTask = (id: string) => { dispatch(deleteTask(id)); }; if (status === 'loading') return <div>Loading tasks...</div>; if (error) return <div>Error: {error}</div>; return ( <div> <h1>Task Manager</h1> <div> <input type="text" value={newTaskTitle} onChange={(e) => setNewTaskTitle(e.target.value)} placeholder="Add new task" /> <button onClick={handleAddTask}>Add Task</button> </div> <ul> {tasks.map((task) => ( <li key={task.id} style={{ textDecoration: task.completed ? 'line-through' : 'none' }}> <span onClick={() => handleToggleTask(task)}> {task.title} </span> <button onClick={() => handleDeleteTask(task.id)} style={{ marginLeft: '10px' }}> Delete </button> </li> ))} </ul> </div> ); }; export default TaskList;Integrate into App (
src/App.tsx):import React from 'react'; import TaskList from './components/TaskList'; import './App.css'; // Optional styling function App() { return ( <div className="App"> <TaskList /> </div> ); } export default App;Wrap App with Provider (
src/index.tsx):import React from 'react'; import ReactDOM from 'react-dom/client'; import { Provider } from 'react-redux'; import { store } from './app/store'; import App from './App'; const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); root.render( <React.StrictMode> <Provider store={store}> <App /> </Provider> </React.StrictMode> );
6.2 Project 2: E-commerce Product Listing (Data Fetching with RTK Query)
This project will display a list of products fetched from an API, demonstrating the power of RTK Query for simplified data fetching and caching.
db.json content:
{
"products": [
{ "id": "p1", "name": "Laptop", "price": 1200, "description": "Powerful laptop for work and gaming." },
{ "id": "p2", "name": "Mouse", "price": 25, "description": "Ergonomic wireless mouse." },
{ "id": "p3", "name": "Keyboard", "price": 75, "description": "Mechanical gaming keyboard." },
{ "id": "p4", "name": "Monitor", "price": 300, "description": "27-inch 4K display." }
]
}
Steps:
Define Product Interface:
// src/types/Product.ts export interface Product { id: string; name: string; price: number; description: string; }Create RTK Query API Slice (
src/services/productsApi.ts):import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; import { Product } from '../types/Product'; export const productsApi = createApi({ reducerPath: 'productsApi', baseQuery: fetchBaseQuery({ baseUrl: 'http://localhost:3000/' }), tagTypes: ['Product'], // For cache invalidation if we add mutations later endpoints: (builder) => ({ getProducts: builder.query<Product[], void>({ query: () => 'products', providesTags: ['Product'], }), // Add a mutation example (optional for this project, but good to show) addProduct: builder.mutation<Product, Omit<Product, 'id'>>({ query: (product) => ({ url: 'products', method: 'POST', body: product, }), invalidatesTags: ['Product'], }), }), }); export const { useGetProductsQuery, useAddProductMutation } = productsApi;Configure Store (
src/app/store.ts):import { configureStore } from '@reduxjs/toolkit'; import { productsApi } from '../services/productsApi'; export const store = configureStore({ reducer: { [productsApi.reducerPath]: productsApi.reducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(productsApi.middleware), }); export type RootState = ReturnType<typeof store.getState>; export type AppDispatch = typeof store.dispatch;Create Typed Hooks (
src/app/hooks.ts): (Same as Project 1)Create ProductList Component (
src/components/ProductList.tsx):import React from 'react'; import { useGetProductsQuery } from '../services/productsApi'; // Use RTK Query hook const ProductList: React.FC = () => { const { data: products, error, isLoading, isFetching } = useGetProductsQuery(); if (isLoading) return <div>Loading products...</div>; if (error) return <div>Error loading products: {JSON.stringify(error)}</div>; return ( <div> <h1>Product Catalog</h1> {isFetching && !isLoading && <div>Refreshing products...</div>} <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '20px' }}> {products?.map((product) => ( <div key={product.id} style={{ border: '1px solid #ccc', padding: '15px', borderRadius: '8px' }}> <h3>{product.name}</h3> <p>Price: ${product.price.toFixed(2)}</p> <p>{product.description}</p> </div> ))} </div> </div> ); }; export default ProductList;Integrate into App (
src/App.tsx):import React from 'react'; import ProductList from './components/ProductList'; import './App.css'; function App() { return ( <div className="App"> <ProductList /> </div> ); } export default App;Wrap App with Provider (
src/index.tsx): (Same as Project 1)
6.3 Project 3: User Authentication Flow (Handling Global State and Side Effects)
This project simulates a basic authentication flow, demonstrating how Redux can manage user authentication state (login/logout, user details) and handle related side effects like token storage. We’ll use a combination of createSlice for synchronous state and createAsyncThunk for login/logout actions.
db.json content:
{
"users": [
{ "id": "u1", "username": "testuser", "password": "password123", "email": "test@example.com" }
]
}
Steps:
Define User and Auth Interfaces:
// src/types/Auth.ts export interface User { id: string; username: string; email: string; } export interface AuthState { user: User | null; token: string | null; isAuthenticated: boolean; status: 'idle' | 'loading' | 'succeeded' | 'failed'; error: string | null; }Create Auth Slice (
src/features/auth/authSlice.ts):import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import { AuthState, User } from '../../types/Auth'; const initialState: AuthState = { user: null, token: localStorage.getItem('authToken'), // Load token from localStorage isAuthenticated: !!localStorage.getItem('authToken'), // Check if token exists status: 'idle', error: null, }; // Simulate login API call export const loginUser = createAsyncThunk( 'auth/loginUser', async ({ username, password }: { username: string; password: string }, { rejectWithValue }) => { try { // In a real app, this would be an actual API call const response = await fetch('http://localhost:3000/users?username=' + username); const users = await response.json(); const user = users[0]; if (user && user.password === password) { // Simulate token generation const token = `fake-jwt-token-${user.id}`; localStorage.setItem('authToken', token); return { user: { id: user.id, username: user.username, email: user.email }, token }; } else { throw new Error('Invalid credentials'); } } catch (err: any) { return rejectWithValue(err.message || 'Login failed'); } } ); const authSlice = createSlice({ name: 'auth', initialState, reducers: { logout: (state) => { state.user = null; state.token = null; state.isAuthenticated = false; state.status = 'idle'; state.error = null; localStorage.removeItem('authToken'); // Clear token from localStorage }, }, extraReducers: (builder) => { builder .addCase(loginUser.pending, (state) => { state.status = 'loading'; state.error = null; }) .addCase(loginUser.fulfilled, (state, action: PayloadAction<{ user: User; token: string }>) => { state.status = 'succeeded'; state.isAuthenticated = true; state.user = action.payload.user; state.token = action.payload.token; }) .addCase(loginUser.rejected, (state, action) => { state.status = 'failed'; state.isAuthenticated = false; state.user = null; state.token = null; state.error = action.payload as string; localStorage.removeItem('authToken'); // Ensure token is removed on failed login }); }, }); export const { logout } = authSlice.actions; export default authSlice.reducer; export const selectIsAuthenticated = (state: { auth: AuthState }) => state.auth.isAuthenticated; export const selectAuthUser = (state: { auth: AuthState }) => state.auth.user; export const selectAuthStatus = (state: { auth: AuthState }) => state.auth.status; export const selectAuthError = (state: { auth: AuthState }) => state.auth.error;Configure Store (
src/app/store.ts):import { configureStore } from '@reduxjs/toolkit'; import authReducer from '../features/auth/authSlice'; export const store = configureStore({ reducer: { auth: authReducer, }, }); export type RootState = ReturnType<typeof store.getState>; export type AppDispatch = typeof store.dispatch;Create Typed Hooks (
src/app/hooks.ts): (Same as Project 1)Create Auth Components (
src/components/Auth.tsxandsrc/components/UserProfile.tsx):// src/components/Auth.tsx import React, { useState } from 'react'; import { useAppDispatch, useAppSelector } from '../app/hooks'; import { loginUser, selectAuthStatus, selectAuthError, selectIsAuthenticated } from '../features/auth/authSlice'; const Auth: React.FC = () => { const dispatch = useAppDispatch(); const status = useAppSelector(selectAuthStatus); const error = useAppSelector(selectAuthError); const isAuthenticated = useAppSelector(selectIsAuthenticated); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const handleLogin = (e: React.FormEvent) => { e.preventDefault(); dispatch(loginUser({ username, password })); }; if (isAuthenticated) { return null; // Don't show login form if already authenticated } return ( <div style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px', maxWidth: '300px', margin: '20px auto' }}> <h2>Login</h2> <form onSubmit={handleLogin}> <div> <label>Username:</label> <input type="text" value={username} onChange={(e) => setUsername(e.target.value)} disabled={status === 'loading'} required /> </div> <div style={{ marginTop: '10px' }}> <label>Password:</label> <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} disabled={status === 'loading'} required /> </div> <button type="submit" disabled={status === 'loading'} style={{ marginTop: '15px', padding: '10px 15px' }}> {status === 'loading' ? 'Logging in...' : 'Login'} </button> {error && <p style={{ color: 'red', marginTop: '10px' }}>Error: {error}</p>} </form> </div> ); }; export default Auth;// src/components/UserProfile.tsx import React from 'react'; import { useAppDispatch, useAppSelector } from '../app/hooks'; import { selectAuthUser, selectIsAuthenticated, logout } from '../features/auth/authSlice'; const UserProfile: React.FC = () => { const dispatch = useAppDispatch(); const user = useAppSelector(selectAuthUser); const isAuthenticated = useAppSelector(selectIsAuthenticated); const handleLogout = () => { dispatch(logout()); }; if (!isAuthenticated || !user) { return null; // Don't show profile if not authenticated } return ( <div style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px', maxWidth: '300px', margin: '20px auto', textAlign: 'center' }}> <h2>Welcome, {user.username}!</h2> <p>Email: {user.email}</p> <button onClick={handleLogout} style={{ marginTop: '15px', padding: '10px 15px', backgroundColor: 'salmon', color: 'white', border: 'none', cursor: 'pointer' }}> Logout </button> </div> ); }; export default UserProfile;Integrate into App (
src/App.tsx):import React from 'react'; import Auth from './components/Auth'; import UserProfile from './components/UserProfile'; import { useAppSelector } from './app/hooks'; import { selectIsAuthenticated } from './features/auth/authSlice'; import './App.css'; // Optional styling function App() { const isAuthenticated = useAppSelector(selectIsAuthenticated); return ( <div className="App"> <h1>Redux Auth Example</h1> {!isAuthenticated ? <Auth /> : <UserProfile />} {isAuthenticated && ( <div style={{ marginTop: '30px', padding: '20px', border: '1px dashed lightblue' }}> <h3>Authenticated Content Area</h3> <p>This content is only visible to logged-in users.</p> {/* You could render other components that require authentication here */} </div> )} </div> ); } export default App;Wrap App with Provider (
src/index.tsx): (Same as Project 1)
Chapter 7: Further Exploration & Resources
This section provides additional resources for continued learning and mastering Redux for React.
7.1 Blogs/Articles
- Official Redux Blog: Keep an eye on the official Redux blog for updates, announcements, and deeper dives into RTK features and best practices.
- LogRocket Blog: Often publishes excellent articles on React and Redux, including performance tips and advanced patterns.
- Kent C. Dodds Blog: While not exclusively Redux, Kent C. Dodds provides insightful articles on general React application architecture and testing which are highly relevant.
- Dev.to / Medium: Search for articles tagged “Redux Toolkit,” “RTK Query,” or “React Redux” for community-driven content and practical examples.
7.2 Video Tutorials/Courses
- Redux Official YouTube Channel: The maintainers often publish short, informative videos on new features and common patterns.
- Fireship.io: Offers concise and high-energy introductions to various web technologies, including Redux.
- Academind (Maximilian Schwarzmüller): Has comprehensive courses on React and Redux on platforms like Udemy.
- The Net Ninja: Provides free, well-explained tutorial series on YouTube covering React, Redux, and more.
7.3 Official Documentation
- Redux Toolkit Official Documentation: This is your primary and most reliable resource. It’s incredibly well-written, comprehensive, and always up-to-date.
- React Redux Official Documentation: Explains how to integrate Redux with React applications using the
react-reduxlibrary. - Redux (Core) Official Documentation: While RTK handles much of the boilerplate, understanding the core Redux principles can still be beneficial.
7.4 Community Forums
- Redux Discord Channel: Join the official Redux community on Discord for real-time help, discussions, and announcements.
- Link available from the official Redux Toolkit documentation site.
- Stack Overflow: A vast repository of questions and answers related to Redux, React, and Redux Toolkit.
- Reddit (r/reactjs, r/reduxjs): Active communities where you can ask questions, share insights, and stay updated.
7.5 Project Ideas for Practice
To solidify your Redux knowledge, try building these projects:
- Shopping Cart: Manage adding/removing items, updating quantities, and calculating totals. Integrate an RTK Query for product data.
- Blog Post Editor: Allow users to create, edit, and delete blog posts. Implement categories and tags, with filtering capabilities.
- Real-time Chat Application: (More advanced) Use WebSockets and Redux for managing messages, user presence, and channels.
- Weather Dashboard: Fetch weather data for multiple cities using RTK Query, allowing users to add/remove cities and view forecasts.
- Kanban Board (Trello-like): Manage tasks across multiple columns (e.g., To Do, In Progress, Done) with drag-and-drop functionality (consider
react-beautiful-dnd). - Expense Tracker: Track income and expenses, categorize transactions, and display summary reports.
- Flashcard App: Create and manage sets of flashcards, with features for reviewing and tracking progress.
- Recipe Finder: Integrate with a public recipe API (e.g., Spoonacular, TheMealDB) to search for recipes, view details, and save favorites.
- Interactive Quiz App: Manage quiz questions, user answers, scores, and navigate between questions.
- File Uploader with Progress Bar: Simulate file uploads with progress tracking using Redux state and a fake API.
7.6 Essential Third-Party Libraries/Tools
axios: WhilefetchBaseQuery(based on nativefetch) is often sufficient for RTK Query,axiosis a popular alternative HTTP client if you prefer its API or need specific features not infetch.json-server: (Already used in projects) For quickly creating a fake REST API for prototyping and development.msw(Mock Service Worker): Excellent for mocking API requests in tests and development environments. Highly recommended for robust RTK Query testing.normalize-tornormalizr: For normalizing nested API responses into a flatter, more efficient state shape, especially useful for large, complex data graphs. While RTK Query handles some aspects, explicit normalization can still be valuable.lodash/ramda: Utility libraries for functional programming patterns, useful for complex data transformations in reducers or selectors, though native array methods are often sufficient.- Chrome Redux DevTools Extension: Absolutely indispensable for debugging your Redux store, inspecting actions, and time-travel debugging. Ensure it’s installed.
- ESLint/Prettier: For consistent code style and preventing common errors. RTK provides recommended ESLint rules.
- Vite / Next.js / Create React App: Modern React development setups that seamlessly integrate with Redux.
By exploring these resources and building diverse projects, you’ll gain a deeper understanding and practical mastery of Redux for React. Happy coding!