Introduction
Welcome to this in-depth guide on Redux with Thunk middleware and API fetching. By the end of this tutorial, you'll have mastered the art of handling asynchronous operations in Redux applications.
Learning Objectives
After completing this material, you will be able to:
- Attach middleware to the Redux store
- Debug using the Redux Logger
- Evaluate when it is appropriate to use Thunk
- Define a
fetchrequest to an API - Write a thunk action creator to make an asynchronous request to an API and dispatch another action when the response is received
- Refactor an async call in a React component to use Redux with Thunk
1. Understanding Redux Middleware
Redux middleware sits between action dispatching and reducer execution, allowing you to intercept, modify, or handle actions before they reach the reducers.
Analogy: The Post Office Sorting System
Think of Redux middleware as the sorting system at a post office:
- Actions are like mail being sent (dispatched)
- Middleware is like postal workers who can inspect, redirect, or process mail before final delivery
- Reducers are the final destinations (mailboxes) that receive and store the mail
Without middleware, all mail goes directly to its destination. With middleware, you can add special handling processes like tracking, logging, or even sending additional mail based on what you see.
Attaching Middleware to the Redux Store
To add middleware to your Redux store, you'll use Redux's applyMiddleware function:
import { createStore, applyMiddleware } from 'redux';
import rootReducer from './reducers';
import thunk from 'redux-thunk';
import logger from 'redux-logger';
// Create the store with middleware
const store = createStore(
rootReducer,
applyMiddleware(thunk, logger)
);
export default store;
3. Use Debouncing and Throttling
For actions triggered by user input (like search), implement debouncing:
import { debounce } from 'lodash';
// In your component
function SearchComponent() {
const dispatch = useDispatch();
// Create a debounced version of the search thunk
const debouncedSearch = React.useCallback(
debounce((query) => {
dispatch(searchItems(query));
}, 300),
[dispatch]
);
return (
<input
type="text"
onChange={(e) => debouncedSearch(e.target.value)}
placeholder="Search..."
/>
);
}
4. Implement Pagination or Infinite Scrolling
For large data sets, fetch only what's needed:
// Action creator for paginated data
const fetchUsers = (page = 1, limit = 20) => async (dispatch, getState) => {
// Check if we're already loading or have reached the end
const { loading, endReached } = getState().users;
if (loading || endReached) return;
dispatch({ type: 'FETCH_USERS_START', payload: { page } });
try {
const response = await fetch(
`/api/users?page=${page}&limit=${limit}`
);
const data = await response.json();
dispatch({
type: 'FETCH_USERS_SUCCESS',
payload: {
users: data.users,
page,
endReached: data.users.length < limit
}
});
} catch (error) {
dispatch({
type: 'FETCH_USERS_FAILURE',
payload: error.message
});
}
};
// In your component
function UserList() {
const dispatch = useDispatch();
const { users, page, loading, error } = useSelector(state => state.users);
// Load initial page
useEffect(() => {
dispatch(fetchUsers(1));
}, [dispatch]);
// Handle scroll to load more
const handleLoadMore = () => {
if (!loading) {
dispatch(fetchUsers(page + 1));
}
};
return (
<div>
<ul>{users.map(user => <UserItem key={user.id} user={user} />)}
{loading && Loading more users...
}
{error && <p>Error: {error}</p>}
<button onClick={handleLoadMore} disabled={loading}>
Load More
</button>
</div>
);
}
5. Use Normalized State Structure
For complex data with relationships, normalize your Redux store:
// Instead of nested arrays:
{
posts: [
{
id: 1,
title: 'Post 1',
author: { id: 1, name: 'John' },
comments: [
{ id: 1, text: 'Great post!', author: { id: 2, name: 'Jane' } }
]
}
]
}
// Use a normalized structure:
{
posts: {
byId: {
'1': { id: 1, title: 'Post 1', authorId: 1, commentIds: [1] }
},
allIds: [1]
},
users: {
byId: {
'1': { id: 1, name: 'John' },
'2': { id: 2, name: 'Jane' }
},
allIds: [1, 2]
},
comments: {
byId: {
'1': { id: 1, text: 'Great post!', authorId: 2, postId: 1 }
},
allIds: [1]
}
}
10. Advanced Redux Patterns with Thunk
1. Composed Thunks
Create smaller thunks that can be composed together for complex workflows:
// Small, focused thunks
const fetchUserProfile = (userId) => async (dispatch) => {
dispatch({ type: 'FETCH_PROFILE_START' });
try {
const response = await fetch(`/api/users/${userId}/profile`);
const profile = await response.json();
dispatch({ type: 'FETCH_PROFILE_SUCCESS', payload: profile });
return profile;
} catch (error) {
dispatch({ type: 'FETCH_PROFILE_FAILURE', payload: error.message });
throw error;
}
};
const fetchUserPosts = (userId) => async (dispatch) => {
dispatch({ type: 'FETCH_POSTS_START' });
try {
const response = await fetch(`/api/users/${userId}/posts`);
const posts = await response.json();
dispatch({ type: 'FETCH_POSTS_SUCCESS', payload: posts });
return posts;
} catch (error) {
dispatch({ type: 'FETCH_POSTS_FAILURE', payload: error.message });
throw error;
}
};
// Composed thunk
const fetchUserData = (userId) => async (dispatch) => {
dispatch({ type: 'FETCH_USER_DATA_START', payload: userId });
try {
// Run these in parallel
const [profile, posts] = await Promise.all([
dispatch(fetchUserProfile(userId)),
dispatch(fetchUserPosts(userId))
]);
dispatch({
type: 'FETCH_USER_DATA_SUCCESS',
payload: { userId, profile, posts }
});
return { profile, posts };
} catch (error) {
dispatch({
type: 'FETCH_USER_DATA_FAILURE',
payload: { userId, error: error.message }
});
throw error;
}
};
2. Conditional Thunks with State-Based Logic
Use current state to decide whether to perform an action:
const refreshDataIfNeeded = () => (dispatch, getState) => {
const { lastFetched } = getState().data;
const TEN_MINUTES = 10 * 60 * 1000;
// Only refresh if data is stale (older than 10 minutes)
if (!lastFetched || Date.now() - lastFetched > TEN_MINUTES) {
return dispatch(fetchData());
} else {
console.log('Data is fresh, no need to refetch');
return Promise.resolve(getState().data.items);
}
};
3. Dynamic Thunks with Factory Functions
Create thunk factories for customizable actions:
// A thunk factory that creates specialized fetch thunks
const createEntityFetcher = (entityType, apiEndpoint) => {
// These action types will be unique to this entity type
const FETCH_START = `FETCH_${entityType.toUpperCase()}_START`;
const FETCH_SUCCESS = `FETCH_${entityType.toUpperCase()}_SUCCESS`;
const FETCH_FAILURE = `FETCH_${entityType.toUpperCase()}_FAILURE`;
// Return a thunk creator function
return (id) => async (dispatch) => {
dispatch({ type: FETCH_START, payload: id });
try {
const response = await fetch(`${apiEndpoint}/${id}`);
if (!response.ok) throw new Error(`API error: ${response.status}`);
const data = await response.json();
dispatch({ type: FETCH_SUCCESS, payload: data });
return data;
} catch (error) {
dispatch({ type: FETCH_FAILURE, payload: { id, error: error.message } });
throw error;
}
};
};
// Usage
const fetchUser = createEntityFetcher('user', '/api/users');
const fetchProduct = createEntityFetcher('product', '/api/products');
const fetchOrder = createEntityFetcher('order', '/api/orders');
// Then use them in components
dispatch(fetchUser(123));
dispatch(fetchProduct(456));
4. Thunks with Optimistic Updates
Update the UI immediately before the API call completes, then confirm or revert:
const toggleTodoOptimistic = (id) => async (dispatch, getState) => {
// Find the current todo
const { todos } = getState().todos;
const todo = todos.find(t => t.id === id);
if (!todo) return;
// Create the updated version
const updatedTodo = {
...todo,
completed: !todo.completed
};
// 1. Optimistically update the UI
dispatch({
type: 'TOGGLE_TODO_OPTIMISTIC',
payload: updatedTodo
});
try {
// 2. Make the API call
const response = await fetch(`/api/todos/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: updatedTodo.completed })
});
if (!response.ok) throw new Error(`API error: ${response.status}`);
// 3. Confirm the update with server data
const serverTodo = await response.json();
dispatch({ type: 'TOGGLE_TODO_SUCCESS', payload: serverTodo });
} catch (error) {
// 4. If it fails, revert the optimistic update
dispatch({ type: 'TOGGLE_TODO_FAILURE', payload: { id, error: error.message } });
// Restore the original todo
dispatch({
type: 'TOGGLE_TODO_REVERT',
payload: todo
});
}
};
5. Thunks with WebSocket Integration
Combine Redux with WebSockets for real-time updates:
// Initialize WebSocket connection
const initializeWebSocket = () => (dispatch) => {
// Create WebSocket connection
const socket = new WebSocket('wss://api.example.com/ws');
// Store reference to socket in Redux
dispatch({ type: 'WS_CONNECT', payload: socket });
// Set up event handlers
socket.onopen = () => {
dispatch({ type: 'WS_CONNECTED' });
};
socket.onclose = () => {
dispatch({ type: 'WS_DISCONNECTED' });
};
socket.onerror = (error) => {
dispatch({ type: 'WS_ERROR', payload: error });
};
socket.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
// Dispatch different actions based on message type
switch (message.type) {
case 'NEW_TODO':
dispatch({ type: 'ADD_TODO_FROM_WS', payload: message.data });
break;
case 'TODO_UPDATED':
dispatch({ type: 'UPDATE_TODO_FROM_WS', payload: message.data });
break;
case 'TODO_DELETED':
dispatch({ type: 'DELETE_TODO_FROM_WS', payload: message.data.id });
break;
default:
console.log('Unknown message type:', message.type);
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
// Clean up function for disconnecting
return () => {
socket.close();
};
};
// Send message through WebSocket
const sendWebSocketMessage = (message) => (dispatch, getState) => {
const { socket } = getState().websocket;
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message));
} else {
console.error('WebSocket is not connected');
}
};
Conclusion
In this comprehensive guide, we've covered everything you need to know about using Redux with Thunk middleware for handling asynchronous operations and API fetching in your React applications:
- You learned how to attach middleware to the Redux store and debug your applications using Redux Logger.
- You now understand when to use Thunk middleware and how to write effective thunk action creators.
- You've seen how to make fetch requests to APIs and handle the responses properly.
- You've learned how to refactor React components to leverage Redux with Thunk for cleaner, more maintainable code.
- You've explored advanced patterns and optimization techniques to take your Redux applications to the next level.
Remember that Redux with Thunk is just one approach to managing asynchronous operations in Redux. As your applications grow in complexity, you might want to explore alternatives like Redux Saga or Redux Observable for more advanced use cases. However, Thunk provides an excellent balance of simplicity and power for many applications.
By mastering these concepts, you're well-equipped to build robust, scalable, and maintainable React applications with efficient state management and API integration.
Additional Resources
Practice Exercises
In this example, we've added two middleware functions:
- thunk: Allows action creators to return functions instead of action objects
- logger: Logs all actions and state changes to the console
Important Note on Middleware Order
The order of middleware matters! They execute in the order provided to applyMiddleware. In our example, thunk processes actions first, then logger logs what happened.
2. Debugging with Redux Logger
Redux Logger is a middleware that logs every action dispatched to the store, along with the previous and next state. It's an invaluable tool for debugging Redux applications.
Setting Up Redux Logger
// First, install the package
npm install redux-logger
// Then, import and apply it to your store
import { createLogger } from 'redux-logger';
const logger = createLogger({
collapsed: true, // Collapses action logs by default
diff: true // Shows the difference between states
});
const store = createStore(
rootReducer,
applyMiddleware(thunk, logger)
);
Example: What Redux Logger Output Looks Like
When you dispatch an action, Redux Logger will output something like this to your console:
action SET_LOADING @ 12:34:56.789
prev state {users: [], loading: false, error: null}
action {type: "SET_LOADING", payload: true}
next state {users: [], loading: true, error: null}
This shows:
- The action type and timestamp
- The state before the action
- The action object itself
- The resulting state after the reducer processes the action
Real-World Application
Redux Logger is especially useful when debugging complex state changes or tracking down unexpected behavior. For example, if a component isn't updating as expected, you can check the logger output to see:
- Was the action dispatched at all?
- Did the action contain the correct payload?
- Did the reducer update the state correctly?
Pro Tip: Development Only
Only include Redux Logger in development builds, not production, as it adds overhead:
const middlewares = [thunk];
if (process.env.NODE_ENV === 'development') {
const logger = createLogger();
middlewares.push(logger);
}
const store = createStore(
rootReducer,
applyMiddleware(...middlewares)
);
3. When to Use Redux Thunk
Redux Thunk solves a fundamental limitation of Redux: by default, Redux action creators can only return plain action objects, not promises or functions. This makes handling asynchronous operations challenging.
Analogy: The Restaurant Kitchen
Imagine Redux as a restaurant kitchen:
- Regular action creators are like simple food orders – "I want a burger now"
- Thunk action creators are like complex orders with conditions – "I want a burger, but only after the fries are ready, and please add ketchup if available"
Without Thunk, the kitchen (Redux) can only handle immediate, simple orders. With Thunk, the kitchen can now handle conditional, sequential, and time-dependent orders.
Use Thunk When You Need To:
- Make API calls (fetch data from a server)
- Dispatch multiple actions in sequence
- Dispatch actions conditionally based on state
- Delay dispatching an action
- Access the current Redux state before dispatching
Example: Dispatch Multiple Actions
// Without Thunk (requires multiple dispatch calls in component)
const fetchUsers = () => ({
type: 'FETCH_USERS'
});
const setUsers = (users) => ({
type: 'SET_USERS',
payload: users
});
const setError = (error) => ({
type: 'SET_ERROR',
payload: error
});
// With Thunk (handles the entire flow)
const fetchUsers = () => {
return async (dispatch) => {
dispatch({ type: 'SET_LOADING', payload: true });
try {
const response = await fetch('/api/users');
const data = await response.json();
dispatch({ type: 'SET_USERS', payload: data });
} catch (error) {
dispatch({ type: 'SET_ERROR', payload: error.message });
} finally {
dispatch({ type: 'SET_LOADING', payload: false });
}
};
};
When NOT to Use Thunk:
- For simple, synchronous updates to the store
- When you need more complex middleware patterns (consider redux-saga or redux-observable for those cases)
- When your application is small and doesn't require complex state management
4. Working with the Fetch API
The fetch API is a modern interface for making HTTP requests in the browser. It's the standard way to retrieve resources asynchronously across the network.
Basic Fetch Request Structure
fetch(url, options)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
})
.then(data => {
// Handle the data
console.log(data);
})
.catch(error => {
// Handle errors
console.error('Fetch error:', error);
});
Fetch with Async/Await
Most modern code uses async/await for cleaner, more readable asynchronous code:
async function fetchData(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('Fetch error:', error);
throw error; // Re-throw to handle it in the calling function
}
}
Common Fetch Options
// GET request (default)
fetch('/api/users');
// POST request with JSON body
fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: 'John', email: 'john@example.com' })
});
// Including credentials (cookies)
fetch('/api/user-profile', {
credentials: 'include'
});
// Setting a timeout (requires AbortController)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
fetch('/api/data', {
signal: controller.signal
})
.then(response => response.json())
.catch(error => {
if (error.name === 'AbortError') {
console.log('Request timed out');
} else {
console.error('Fetch error:', error);
}
})
.finally(() => clearTimeout(timeoutId));
Real-World Example: Fetching User Data
Here's how you might fetch user data from a REST API:
async function getUserData(userId) {
const apiUrl = `https://api.example.com/users/${userId}`;
try {
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${getAccessToken()}`,
'Accept': 'application/json'
}
});
if (response.status === 404) {
return null; // User not found
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `API error: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Failed to fetch user data:', error);
// You might want to handle specific errors differently
throw error;
}
}
5. Writing Thunk Action Creators
Thunk action creators are functions that return another function (a thunk) instead of returning an action object directly. This thunk receives dispatch and getState as arguments.
Analogy: The Cooking Recipe
A regular action creator is like a simple recipe card with a single instruction: "Add salt." A thunk action creator is like a complete recipe that involves multiple steps, timing, and decision-making:
- "Check if you have eggs"
- "If not, go to the store and buy eggs"
- "Beat the eggs in a bowl"
- "Heat the pan and add butter"
- "Pour in the eggs and cook until done"
The thunk lets you execute a series of steps that can depend on current conditions (state) and can trigger multiple actions.
Basic Structure of a Thunk Action Creator
// A thunk action creator
const myThunkAction = (parameters) => {
// Returns a thunk function
return (dispatch, getState) => {
// Inside this function, you can:
// 1. Access the current state
const currentState = getState();
// 2. Dispatch regular actions
dispatch({ type: 'ACTION_TYPE', payload: 'data' });
// 3. Perform async operations
return fetch('/api/data')
.then(response => response.json())
.then(data => {
// 4. Dispatch additional actions with the results
dispatch({ type: 'DATA_LOADED', payload: data });
})
.catch(error => {
dispatch({ type: 'LOAD_ERROR', payload: error.message });
});
};
};
Example: Fetching Weather Data
Let's implement a complete thunk action creator for fetching weather data:
// Action types (best practice: define them as constants)
const FETCH_WEATHER_START = 'FETCH_WEATHER_START';
const FETCH_WEATHER_SUCCESS = 'FETCH_WEATHER_SUCCESS';
const FETCH_WEATHER_FAILURE = 'FETCH_WEATHER_FAILURE';
// Regular action creators
const fetchWeatherStart = () => ({
type: FETCH_WEATHER_START
});
const fetchWeatherSuccess = (data) => ({
type: FETCH_WEATHER_SUCCESS,
payload: data
});
const fetchWeatherFailure = (error) => ({
type: FETCH_WEATHER_FAILURE,
payload: error
});
// Thunk action creator
const fetchWeather = (city) => {
return async (dispatch, getState) => {
// Check if we're already loading weather data
const { weather } = getState();
if (weather.loading) {
// Avoid multiple simultaneous requests
return;
}
// Dispatch action to set loading state
dispatch(fetchWeatherStart());
try {
// Make API request
const apiKey = 'your_api_key';
const response = await fetch(
`https://api.weatherapi.com/v1/current.json?key=${apiKey}&q=${city}`
);
if (!response.ok) {
throw new Error(`Weather API error: ${response.status}`);
}
const data = await response.json();
// Dispatch success action with the data
dispatch(fetchWeatherSuccess(data));
return data; // Optional: return data for component usage
} catch (error) {
// Dispatch error action
dispatch(fetchWeatherFailure(error.message));
// Re-throw for component-level error handling (optional)
throw error;
}
};
};
Advanced Pattern: Using Selectors in Thunks
You can combine thunks with selectors for more powerful patterns:
// Selector function (reusable way to extract data from state)
const selectUserById = (state, userId) =>
state.users.data.find(user => user.id === userId);
// Thunk that uses a selector
const updateUserIfNeeded = (userId, updates) => {
return (dispatch, getState) => {
const currentState = getState();
const user = selectUserById(currentState, userId);
// Only update if the user exists and isn't already being updated
if (user && !currentState.users.updating.includes(userId)) {
dispatch({ type: 'USER_UPDATE_START', payload: userId });
return fetch(`/api/users/${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
})
.then(response => response.json())
.then(updatedUser => {
dispatch({
type: 'USER_UPDATE_SUCCESS',
payload: { userId, updates: updatedUser }
});
})
.catch(error => {
dispatch({
type: 'USER_UPDATE_FAILURE',
payload: { userId, error: error.message }
});
});
} else {
// Return a resolved promise for consistent behavior
return Promise.resolve();
}
};
};
6. Refactoring React Components to Use Redux with Thunk
Now let's see how to refactor a React component that makes API calls directly to use Redux with Thunk instead.
Before: Component with Direct API Call
import React, { useState, useEffect } from 'react';
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUsers = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const data = await response.json();
setUsers(data);
} catch (error) {
setError(error.message);
} finally {
setLoading(false);
}
};
fetchUsers();
}, []);
if (loading) return Loading...;
if (error) return Error: {error};
return (
<div>
<h2>Users</h2>
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
export default UserList;
After: Refactored with Redux and Thunk
Step 1: Create Action Types
// src/actions/types.js
export const FETCH_USERS_START = 'FETCH_USERS_START';
export const FETCH_USERS_SUCCESS = 'FETCH_USERS_SUCCESS';
export const FETCH_USERS_FAILURE = 'FETCH_USERS_FAILURE';
Step 2: Create Action Creators and Thunk
// src/actions/userActions.js
import {
FETCH_USERS_START,
FETCH_USERS_SUCCESS,
FETCH_USERS_FAILURE
} from './types';
// Regular action creators
export const fetchUsersStart = () => ({
type: FETCH_USERS_START
});
export const fetchUsersSuccess = (users) => ({
type: FETCH_USERS_SUCCESS,
payload: users
});
export const fetchUsersFailure = (error) => ({
type: FETCH_USERS_FAILURE,
payload: error
});
// Thunk action creator
export const fetchUsers = () => {
return async (dispatch) => {
dispatch(fetchUsersStart());
try {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const data = await response.json();
dispatch(fetchUsersSuccess(data));
} catch (error) {
dispatch(fetchUsersFailure(error.message));
}
};
};
Step 3: Create a Reducer
// src/reducers/userReducer.js
import {
FETCH_USERS_START,
FETCH_USERS_SUCCESS,
FETCH_USERS_FAILURE
} from '../actions/types';
const initialState = {
users: [],
loading: false,
error: null
};
const userReducer = (state = initialState, action) => {
switch (action.type) {
case FETCH_USERS_START:
return {
...state,
loading: true,
error: null
};
case FETCH_USERS_SUCCESS:
return {
...state,
loading: false,
users: action.payload
};
case FETCH_USERS_FAILURE:
return {
...state,
loading: false,
error: action.payload
};
default:
return state;
}
};
export default userReducer;
Step 4: Add the Reducer to Your Root Reducer
// src/reducers/index.js
import { combineReducers } from 'redux';
import userReducer from './userReducer';
const rootReducer = combineReducers({
users: userReducer,
// other reducers...
});
export default rootReducer;
Step 5: Refactor the Component to Use Redux
// src/components/UserList.js
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { fetchUsers } from '../actions/userActions';
function UserList() {
const dispatch = useDispatch();
const { users, loading, error } = useSelector(state => state.users);
useEffect(() => {
dispatch(fetchUsers());
}, [dispatch]);
if (loading) return Loading...;
if (error) return Error: {error};
return (
<div>
<h2>Users</=h2>
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
export default UserList;
Benefits of the Refactored Approach
- Separation of Concerns: Components focus on rendering, not data fetching
- Reusability: The same thunk can be called from multiple components
- Centralized State Management: All user data and loading states are in one place
- Easier Testing: Components, actions, and reducers can be tested separately
- Caching and Memoization: Data can be cached in the Redux store
- Debugging: Redux DevTools and Redux Logger provide clear visibility into state changes
7. Complete Practical Example: Todo Application
Let's implement a complete todo application with Redux, Thunk, and API integration.
Project Structure
src/
├── actions/
│ ├── types.js
│ └── todoActions.js
├── reducers/
│ ├── index.js
│ └── todoReducer.js
├── store/
│ └── index.js
├── components/
│ ├── TodoForm.js
│ ├── TodoItem.js
│ └── TodoList.js
└── App.js
Setting Up the Store with Middleware
// src/store/index.js
import { createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import logger from 'redux-logger';
import rootReducer from '../reducers';
// Setup for Redux DevTools Extension
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
// Create store with middleware
const store = createStore(
rootReducer,
composeEnhancers(
applyMiddleware(thunk, logger)
)
);
export default store;
Action Types
// src/actions/types.js
export const FETCH_TODOS_START = 'FETCH_TODOS_START';
export const FETCH_TODOS_SUCCESS = 'FETCH_TODOS_SUCCESS';
export const FETCH_TODOS_FAILURE = 'FETCH_TODOS_FAILURE';
export const ADD_TODO_START = 'ADD_TODO_START';
export const ADD_TODO_SUCCESS = 'ADD_TODO_SUCCESS';
export const ADD_TODO_FAILURE = 'ADD_TODO_FAILURE';
export const TOGGLE_TODO_START = 'TOGGLE_TODO_START';
export const TOGGLE_TODO_SUCCESS = 'TOGGLE_TODO_SUCCESS';
export const TOGGLE_TODO_FAILURE = 'TOGGLE_TODO_FAILURE';
export const DELETE_TODO_START = 'DELETE_TODO_START';
export const DELETE_TODO_SUCCESS = 'DELETE_TODO_SUCCESS';
export const DELETE_TODO_FAILURE = 'DELETE_TODO_FAILURE';
Todo Actions with Thunks
// src/actions/todoActions.js
import * as types from './types';
// API URL
const API_URL = 'https://jsonplaceholder.typicode.com/todos';
// Fetch Todos
export const fetchTodos = () => async (dispatch) => {
dispatch({ type: types.FETCH_TODOS_START });
try {
const response = await fetch(API_URL);
if (!response.ok) throw new Error(`API error: ${response.status}`);
const data = await response.json();
// Limit to first 10 todos for this example
const todos = data.slice(0, 10);
dispatch({ type: types.FETCH_TODOS_SUCCESS, payload: todos });
} catch (error) {
dispatch({ type: types.FETCH_TODOS_FAILURE, payload: error.message });
}
};
// Add Todo
export const addTodo = (text) => async (dispatch) => {
dispatch({ type: types.ADD_TODO_START });
try {
const response = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: text,
completed: false,
userId: 1 // Placeholder
})
});
if (!response.ok) throw new Error(`API error: ${response.status}`);
const newTodo = await response.json();
dispatch({ type: types.ADD_TODO_SUCCESS, payload: newTodo });
} catch (error) {
dispatch({ type: types.ADD_TODO_FAILURE, payload: error.message });
}
};
// Toggle Todo
export const toggleTodo = (id) => async (dispatch, getState) => {
dispatch({ type: types.TOGGLE_TODO_START, payload: id });
try {
// Find the todo in the current state
const { todos } = getState().todos;
const todo = todos.find(todo => todo.id === id);
if (!todo) throw new Error(`Todo with id ${id} not found`);
// Call API to update the todo
const response = await fetch(`${API_URL}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
completed: !todo.completed
})
});
if (!response.ok) throw new Error(`API error: ${response.status}`);
const updatedTodo = await response.json();
dispatch({ type: types.TOGGLE_TODO_SUCCESS, payload: updatedTodo });
} catch (error) {
dispatch({ type: types.TOGGLE_TODO_FAILURE, payload: { id, error: error.message } });
}
};
// Delete Todo
export const deleteTodo = (id) => async (dispatch) => {
dispatch({ type: types.DELETE_TODO_START, payload: id });
try {
const response = await fetch(`${API_URL}/${id}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error(`API error: ${response.status}`);
// Most APIs return no content for DELETE operations
dispatch({ type: types.DELETE_TODO_SUCCESS, payload: id });
} catch (error) {
dispatch({ type: types.DELETE_TODO_FAILURE, payload: { id, error: error.message } });
}
};
Todo Reducer
// src/reducers/todoReducer.js
import * as types from '../actions/types';
const initialState = {
todos: [],
loading: false,
error: null,
// Track which todos are being modified
pendingTodos: []
};
const todoReducer = (state = initialState, action) => {
switch (action.type) {
// Fetch todos cases
case types.FETCH_TODOS_START:
return {
...state,
loading: true,
error: null
};
case types.FETCH_TODOS_SUCCESS:
return {
...state,
loading: false,
todos: action.payload
};
case types.FETCH_TODOS_FAILURE:
return {
...state,
loading: false,
error: action.payload
};
// Add todo cases
case types.ADD_TODO_START:
return {
...state,
loading: true
};
case types.ADD_TODO_SUCCESS:
return {
...state,
loading: false,
todos: [...state.todos, action.payload]
};
case types.ADD_TODO_FAILURE:
return {
...state,
loading: false,
error: action.payload
};
// Toggle todo cases
case types.TOGGLE_TODO_START:
return {
...state,
pendingTodos: [...state.pendingTodos, action.payload]
};
case types.TOGGLE_TODO_SUCCESS:
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload.id
? action.payload
: todo
),
pendingTodos: state.pendingTodos.filter(id => id !== action.payload.id)
};
case types.TOGGLE_TODO_FAILURE:
return {
...state,
error: action.payload.error,
pendingTodos: state.pendingTodos.filter(id => id !== action.payload.id)
};
// Delete todo cases
case types.DELETE_TODO_START:
return {
...state,
pendingTodos: [...state.pendingTodos, action.payload]
};
case types.DELETE_TODO_SUCCESS:
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.payload),
pendingTodos: state.pendingTodos.filter(id => id !== action.payload)
};
case types.DELETE_TODO_FAILURE:
return {
...state,
error: action.payload.error,
pendingTodos: state.pendingTodos.filter(id => id !== action.payload.id)
};
default:
return state;
}
};
export default todoReducer;
Root Reducer
// src/reducers/index.js
import { combineReducers } from 'redux';
import todoReducer from './todoReducer';
const rootReducer = combineReducers({
todos: todoReducer
// Add more reducers here as your app grows
});
export default rootReducer;
Todo Components
Now let's implement the React components that will use our Redux store:
Todo Form Component
// src/components/TodoForm.js
import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { addTodo } from '../actions/todoActions';
const TodoForm = () => {
const [text, setText] = useState('');
const dispatch = useDispatch();
const loading = useSelector(state => state.todos.loading);
const handleSubmit = (e) => {
e.preventDefault();
if (text.trim()) {
dispatch(addTodo(text));
setText('');
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add a new todo..."
disabled={loading}
/>
<button type="submit" disabled={loading || !text.trim()}>
{loading ? 'Adding...' : 'Add Todo'}
</button>
</form>
);
};
export default TodoForm;
Todo Item Component
// src/components/TodoItem.js
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { toggleTodo, deleteTodo } from '../actions/todoActions';
const TodoItem = ({ todo }) => {
const dispatch = useDispatch();
const pendingTodos = useSelector(state => state.todos.pendingTodos);
const isPending = pendingTodos.includes(todo.id);
return (
<li
style={{
textDecoration: todo.completed ? 'line-through' : 'none',
opacity: isPending ? 0.5 : 1
}}
>
dispatch(toggleTodo(todo.id))}
disabled={isPending}
/>
<span>{todo.title}</span>
<button
onClick={() => dispatch(deleteTodo(todo.id))}
disabled={isPending}
>
Delete
</button>
</li>
);
};
export default TodoItem;
Todo List Component
// src/components/TodoList.js
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { fetchTodos } from '../actions/todoActions';
import TodoItem from './TodoItem';
import TodoForm from './TodoForm';
const TodoList = () => {
const dispatch = useDispatch();
const { todos, loading, error } = useSelector(state => state.todos);
useEffect(() => {
dispatch(fetchTodos());
}, [dispatch]);
if (loading && todos.length === 0) {
return Loading todos...;
}
if (error && todos.length === 0) {
return Error: {error};
}
return (
<div>
<h2>Todo List
<TodoForm />
{todos.length === 0 ? (
<p>No todos yet. Add one above!
) : (
<ul>
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
)}
</div>
);
};
export default TodoList;
App Component
// src/App.js
import React from 'react';
import { Provider } from 'react-redux';
import store from './store';
import TodoList from './components/TodoList';
function App() {
return (
<Provider store={store}>
<div className="App">
<header>
<h1>Redux Thunk Todo App
</header>
<main>
<TodoList />
</main>
</div>
</Provider>
);
}
export default App;
8. Best Practices and Common Pitfalls
Best Practices
-
Use Action Type Constants
Define action types as constants in a separate file to avoid typos and make maintenance easier.
-
Follow the Action Creator Pattern
Create separate functions for each action type to improve readability and reusability.
-
Handle Loading and Error States
Always track loading and error states in your reducers to provide proper feedback to users.
-
Use Selectors
Create selector functions to extract data from your store, making your components more resilient to store structure changes.
-
Keep Thunks Focused
Each thunk should have a single responsibility. Break complex operations into multiple thunks if needed.
-
Return Promises from Thunks
This allows components to chain actions after thunk completion if needed.
-
Use getState Sparingly
While useful, overusing getState can lead to complex dependencies between actions and state.
-
Separate API Logic
Consider creating a separate API utility layer for complex applications.
Common Pitfalls
-
Not Handling Errors Properly
Always include error handling in your thunks using try/catch blocks.
// Bad const fetchData = () => async (dispatch) => { dispatch({ type: 'FETCH_START' }); const data = await fetch('/api/data').then(res => res.json()); dispatch({ type: 'FETCH_SUCCESS', payload: data }); }; // Good const fetchData = () => async (dispatch) => { dispatch({ type: 'FETCH_START' }); try { const response = await fetch('/api/data'); if (!response.ok) throw new Error(`API error: ${response.status}`); const data = await response.json(); dispatch({ type: 'FETCH_SUCCESS', payload: data }); } catch (error) { dispatch({ type: 'FETCH_FAILURE', payload: error.message }); } }; -
Forgetting to Check Response Status
Always check if the response is ok before processing it.
-
Mutating State in Reducers
Remember that Redux requires immutable state updates.
// Bad (mutates state) case 'ADD_ITEM': state.items.push(action.payload); return state; // Good (creates new state) case 'ADD_ITEM': return { ...state, items: [...state.items, action.payload] }; -
Not Handling Pending States for Multiple Items
Keep track of which items are being modified to provide proper UI feedback.
-
Excessive Re-renders
Use memoization and careful selector design to prevent unnecessary component re-renders.
-
Not Cancelling Outdated Requests
For search functionality or rapid navigation, implement request cancellation using AbortController.
const searchUsers = (query) => async (dispatch, getState) => { // Cancel previous search if it exists if (getState().search.controller) { getState().search.controller.abort(); } const controller = new AbortController(); dispatch({ type: 'SET_SEARCH_CONTROLLER', payload: controller }); try { const response = await fetch(`/api/search?q=${query}`, { signal: controller.signal }); // Process response... } catch (error) { if (error.name !== 'AbortError') { dispatch({ type: 'SEARCH_ERROR', payload: error.message }); } } };
9. Performance Optimization Techniques
1. Batch Multiple Dispatches
When you need to dispatch multiple actions at once, consider using a custom middleware like redux-batched-actions.
// Install the package
npm install redux-batched-actions
// In your store configuration
import { batchActions, enableBatching } from 'redux-batched-actions';
import { createStore } from 'redux';
const store = createStore(enableBatching(reducer));
// In your thunk
dispatch(batchActions([
{ type: 'ACTION_ONE', payload: data1 },
{ type: 'ACTION_TWO', payload: data2 },
{ type: 'ACTION_THREE', payload: data3 }
]));
2. Implement Request Caching
Avoid redundant API calls by caching results:
const fetchUserById = (userId) => async (dispatch, getState) => {
// Check if we already have this user in the cache
const cachedUser = getState().users.byId[userId];
// If cached and not too old, use the cached version
if (cachedUser && (Date.now() - cachedUser.fetchedAt < 5 * 60 * 1000)) {
return cachedUser;
}
// Otherwise, fetch from API
dispatch({ type: 'FETCH_USER_START', payload: userId });
try {
const response = await fetch(`/api/users/${userId}`);
const userData = await response.json();
// Add timestamp to cache
const userWithTimestamp = {
...userData,
fetchedAt: Date.now()
};
dispatch({
type: 'FETCH_USER_SUCCESS',
payload: userWithTimestamp
});
return userWithTimestamp;
} catch (error) {
dispatch({
type: 'FETCH_USER_FAILURE',
payload: { userId, error: error.message }
});
throw error;
}
};
Practice Exercises
Exercise 1: Add Middleware to Redux Store
Create a Redux store with both thunk and logger middleware. Configure the logger to show collapsed logs in development mode only.
Exercise 2: Write a Basic Thunk
Create a thunk action creator that fetches a list of products from a REST API and handles loading, success, and error states.
Exercise 3: Refactor Component to Use Redux
Take a React component that uses useState and useEffect to fetch data, and refactor it to use Redux with Thunk instead. Make sure to create appropriate action types, action creators, and reducers.
Exercise 4: Implement Optimistic Updates
Extend the Todo application from this tutorial to implement optimistic updates for the toggle and delete operations. Make sure to handle error cases by reverting the UI if the API call fails.
Exercise 5: Add Caching to API Calls
Modify a thunk action creator to implement a caching strategy that avoids making redundant API calls for data that was recently fetched. Add a timestamp to each item and only fetch new data if the cached data is older than 5 minutes.
Redux Thunk Glossary
- Action
- A plain JavaScript object that describes a change to the Redux store's state. Must have a
typeproperty. - Action Creator
- A function that creates and returns an action object. Used to standardize and encapsulate action creation.
- Middleware
- A higher-order function that composes a dispatch function to allow handling of async actions, among other things. Middleware intercepts actions before they reach the reducer.
- Thunk
- A function that wraps an expression to delay its evaluation. In Redux, a thunk is a function that is returned by an action creator instead of an action object.
- Reducer
- A pure function that takes the current state and an action, and returns the next state. Reducers specify how the application's state changes in response to actions.
- Store
- The object that holds the application state and provides methods to access it, dispatch actions, and register listeners.
- Dispatch
- The method used to send actions to the Redux store, which triggers state changes.
- getState
- A function available within thunks that returns the current state of the Redux store.
- Async Action
- An action that includes asynchronous operations, such as API calls. In Redux Thunk, these are implemented as functions that return a function (thunk) rather than an action object.
- Side Effect
- Any change to state or behavior that can be observed outside its local scope, such as API calls, modifications to global variables, or logging. Thunks are primarily used to handle side effects in Redux.
Frequently Asked Questions
When should I use Redux Thunk vs Redux Saga?
Use Redux Thunk when:
- You need simple async operations
- You're new to Redux middleware
- Your application has modest complexity
- You prefer simpler code with fewer concepts
Use Redux Saga when:
- You need complex operations like race conditions, throttling, or debouncing
- You want to handle complex flows of async actions
- You need more powerful error handling
- Your application is large and has complex state interactions
Can I use Redux Thunk with Redux Toolkit?
Yes! Redux Toolkit includes Redux Thunk by default when you use its configureStore function. You don't need to add it manually. Redux Toolkit also provides a createAsyncThunk utility that simplifies creating thunks for async operations with automatic action type creation.
Should I use Redux for all async operations?
Not necessarily. For simple components with local state or components that don't share data with others, using React's built-in useState and useEffect hooks might be simpler. Use Redux with Thunk when you need to:
- Share the async data or loading states across multiple components
- Cache data between component mounts
- Handle complex sequences of async operations
- Decouple data fetching logic from your UI components
How do I test Redux Thunks?
You can test Redux Thunks using Jest, with or without the Redux Mock Store library. Here's a basic approach:
// Basic thunk test with Jest and redux-mock-store
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import fetchMock from 'jest-fetch-mock';
import { fetchUsers } from './userActions';
// Configure mock store
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
// Set up fetch mock
fetchMock.enableMocks();
describe('User Actions', () => {
beforeEach(() => {
fetchMock.resetMocks();
});
it('creates FETCH_USERS_SUCCESS when fetching users has been done', async () => {
// Mock the API response
fetchMock.mockResponseOnce(JSON.stringify([
{ id: 1, name: 'John Doe' }
]));
// Expected actions
const expectedActions = [
{ type: 'FETCH_USERS_START' },
{
type: 'FETCH_USERS_SUCCESS',
payload: [{ id: 1, name: 'John Doe' }]
}
];
// Create a mock store
const store = mockStore({ users: [] });
// Dispatch the action
await store.dispatch(fetchUsers());
// Check if the correct actions were dispatched
expect(store.getActions()).toEqual(expectedActions);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith('/api/users');
});
it('creates FETCH_USERS_FAILURE when the API call fails', async () => {
// Mock a failed API call
fetchMock.mockRejectOnce(new Error('Network Error'));
// Expected actions
const expectedActions = [
{ type: 'FETCH_USERS_START' },
{
type: 'FETCH_USERS_FAILURE',
payload: 'Network Error'
}
];
// Create a mock store
const store = mockStore({ users: [] });
// Dispatch the action
await store.dispatch(fetchUsers());
// Check if the correct actions were dispatched
expect(store.getActions()).toEqual(expectedActions);
});
});
How can I handle authentication with Redux Thunk?
Redux Thunk is great for handling authentication flows. Here's a typical pattern:
// Action Types
const LOGIN_REQUEST = 'LOGIN_REQUEST';
const LOGIN_SUCCESS = 'LOGIN_SUCCESS';
const LOGIN_FAILURE = 'LOGIN_FAILURE';
const LOGOUT = 'LOGOUT';
// Action Creators
const loginRequest = () => ({ type: LOGIN_REQUEST });
const loginSuccess = (user) => ({ type: LOGIN_SUCCESS, payload: user });
const loginFailure = (error) => ({ type: LOGIN_FAILURE, payload: error });
const logout = () => ({ type: LOGOUT });
// Thunk Action Creator for Login
const login = (email, password) => async (dispatch) => {
dispatch(loginRequest());
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Login failed');
}
const userData = await response.json();
// Store the token in localStorage or a secure cookie
localStorage.setItem('authToken', userData.token);
// Dispatch success action
dispatch(loginSuccess(userData.user));
return userData;
} catch (error) {
dispatch(loginFailure(error.message));
throw error;
}
};
// Thunk Action Creator for Logout
const logoutUser = () => (dispatch) => {
// Clear the token from storage
localStorage.removeItem('authToken');
// Dispatch logout action
dispatch(logout());
// Redirect to login page or home
// (You might use a router here in a real app)
};
// Thunk to check if user is already logged in (e.g., on app startup)
const checkAuthStatus = () => async (dispatch) => {
const token = localStorage.getItem('authToken');
if (!token) return;
dispatch(loginRequest());
try {
// Validate the token with your API
const response = await fetch('/api/auth/validate', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Invalid token');
}
const userData = await response.json();
dispatch(loginSuccess(userData));
} catch (error) {
// If token validation fails, log the user out
dispatch(logoutUser());
}
};
11. Real-World Example: News API Dashboard
Let's look at a complete real-world example of building a News API dashboard with Redux and Thunk.
Setting Up the Project
# Create a new React app
npx create-react-app news-dashboard
# Install dependencies
cd news-dashboard
npm install redux react-redux redux-thunk redux-logger axios
# Start the development server
npm start
Project Structure
src/
├── api/
│ └── newsApi.js
├── store/
│ ├── actions/
│ │ ├── actionTypes.js
│ │ └── newsActions.js
│ ├── reducers/
│ │ ├── index.js
│ │ └── newsReducer.js
│ └── index.js
├── components/
│ ├── NewsFilter.js
│ ├── NewsItem.js
│ ├── NewsList.js
│ └── Pagination.js
├── App.js
└── index.js
API Setup
// src/api/newsApi.js
import axios from 'axios';
const API_KEY = 'your_news_api_key';
const BASE_URL = 'https://newsapi.org/v2';
const newsApi = {
getTopHeadlines: (country = 'us', category = '', page = 1, pageSize = 10) => {
return axios.get(`${BASE_URL}/top-headlines`, {
params: {
country,
category: category || undefined,
page,
pageSize,
apiKey: API_KEY
}
});
},
searchNews: (query, from, to, page = 1, pageSize = 10) => {
return axios.get(`${BASE_URL}/everything`, {
params: {
q: query,
from,
to,
page,
pageSize,
apiKey: API_KEY
}
});
}
};
export default newsApi;
Redux Setup
// src/store/actions/actionTypes.js
export const FETCH_NEWS_START = 'FETCH_NEWS_START';
export const FETCH_NEWS_SUCCESS = 'FETCH_NEWS_SUCCESS';
export const FETCH_NEWS_FAILURE = 'FETCH_NEWS_FAILURE';
export const SET_FILTER = 'SET_FILTER';
export const SET_PAGE = 'SET_PAGE';
// src/store/actions/newsActions.js
import * as types from './actionTypes';
import newsApi from '../../api/newsApi';
// Action creators
export const fetchNewsStart = () => ({
type: types.FETCH_NEWS_START
});
export const fetchNewsSuccess = (data) => ({
type: types.FETCH_NEWS_SUCCESS,
payload: data
});
export const fetchNewsFailure = (error) => ({
type: types.FETCH_NEWS_FAILURE,
payload: error
});
export const setFilter = (filter) => ({
type: types.SET_FILTER,
payload: filter
});
export const setPage = (page) => ({
type: types.SET_PAGE,
payload: page
});
// Thunk action creators
export const fetchNews = () => async (dispatch, getState) => {
dispatch(fetchNewsStart());
try {
const { filter, page } = getState().news;
const { country, category, query, from, to } = filter;
let response;
if (query) {
// Search endpoint
response = await newsApi.searchNews(
query,
from,
to,
page,
10
);
} else {
// Top headlines endpoint
response = await newsApi.getTopHeadlines(
country,
category,
page,
10
);
}
dispatch(fetchNewsSuccess({
articles: response.data.articles,
totalResults: response.data.totalResults
}));
} catch (error) {
dispatch(fetchNewsFailure(
error.response?.data?.message || error.message
));
}
};
export const updateFilter = (newFilter) => (dispatch) => {
// Reset to page 1 when filter changes
dispatch(setPage(1));
dispatch(setFilter(newFilter));
dispatch(fetchNews());
};
export const changePage = (newPage) => (dispatch) => {
dispatch(setPage(newPage));
dispatch(fetchNews());
};
// src/store/reducers/newsReducer.js
import * as types from '../actions/actionTypes';
const initialState = {
articles: [],
totalResults: 0,
loading: false,
error: null,
page: 1,
filter: {
country: 'us',
category: '',
query: '',
from: '',
to: ''
}
};
const newsReducer = (state = initialState, action) => {
switch (action.type) {
case types.FETCH_NEWS_START:
return {
...state,
loading: true,
error: null
};
case types.FETCH_NEWS_SUCCESS:
return {
...state,
loading: false,
articles: action.payload.articles,
totalResults: action.payload.totalResults
};
case types.FETCH_NEWS_FAILURE:
return {
...state,
loading: false,
error: action.payload
};
case types.SET_FILTER:
return {
...state,
filter: {
...state.filter,
...action.payload
}
};
case types.SET_PAGE:
return {
...state,
page: action.payload
};
default:
return state;
}
};
export default newsReducer;
// src/store/reducers/index.js
import { combineReducers } from 'redux';
import newsReducer from './newsReducer';
const rootReducer = combineReducers({
news: newsReducer
});
export default rootReducer;
// src/store/index.js
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import logger from 'redux-logger';
import rootReducer from './reducers';
const middleware = [thunk];
// Only add logger in development
if (process.env.NODE_ENV === 'development') {
middleware.push(logger);
}
const store = createStore(
rootReducer,
applyMiddleware(...middleware)
);
export default store;
React Components
// src/components/NewsFilter.js
import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { updateFilter } from '../store/actions/newsActions';
const NewsFilter = () => {
const dispatch = useDispatch();
const currentFilter = useSelector(state => state.news.filter);
const [filter, setFilter] = useState(currentFilter);
const handleChange = (e) => {
setFilter({
...filter,
[e.target.name]: e.target.value
});
};
const handleSubmit = (e) => {
e.preventDefault();
dispatch(updateFilter(filter));
};
return (
<form onSubmit={handleSubmit} className="news-filter">
<div className="filter-group">
<label>
Search:
<input
type="text"
name="query"
value={filter.query}
onChange={handleChange}
placeholder="Search term"
/>
</label>
</div>
<div className="filter-group">
<label>
Country:
<select
name="country"
value={filter.country}
onChange={handleChange}
disabled={!!filter.query}
>
<option value="us">United States</option>
<option value="gb">United Kingdom</option>
<option value="ca">Canada</option>
<option value="au">Australia</option>
</select>
</label>
</div>
<div className="filter-group">
<label>
Category:
<select
name="category"
value={filter.category}
onChange={handleChange}
disabled={!!filter.query}
>
<option value="">All</option>
<option value="business">Business</option>
<option value="technology">Technology</option>
<option value="science">Science</option>
<option value="health">Health</option>
<option value="sports">Sports</option>
<option value="entertainment">Entertainment</option>
</select>
</label>
</div>
<div className="filter-group">
<label>
From:
<input
type="date"
name="from"
value={filter.from}
onChange={handleChange}
disabled={!filter.query}
/>
</label>
</div>
<div className="filter-group">
<label>
To:
<input
type="date"
name="to"
value={filter.to}
onChange={handleChange}
disabled={!filter.query}
/>
</label>
</div>
<button type="submit">Apply Filters</button>
</form>
);
};
export default NewsFilter;
// src/components/NewsItem.js
import React from 'react';
const NewsItem = ({ article }) => {
const {
title,
description,
url,
urlToImage,
source,
publishedAt,
author
} = article;
// Format date
const formattedDate = new Date(publishedAt).toLocaleDateString();
return (
<div className="news-item">
{urlToImage && (
<div className="news-image">
<img src={urlToImage} alt={title} />
</div>
)}
<div className="news-content">
<h3>
<a href={url} target="_blank" rel="noopener noreferrer">
{title}
</a>
</h3>
<div className="news-meta">
{source?.name && <span className="source">{source.name}</span>}
<span className="date">{formattedDate}</span>
{author && <span className="author">By {author}</span>}
</div>
<p className="description">{description}</p>
<a
href={url}
className="read-more"
target="_blank"
rel="noopener noreferrer"
>
Read more
</a>
</div>
</div>
);
};
export default NewsItem;
// src/components/NewsList.js
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { fetchNews } from '../store/actions/newsActions';
import NewsItem from './NewsItem';
import NewsFilter from './NewsFilter';
import Pagination from './Pagination';
const NewsList = () => {
const dispatch = useDispatch();
const { articles, loading, error, totalResults } = useSelector(
state => state.news
);
useEffect(() => {
dispatch(fetchNews());
}, [dispatch]);
return (
<div className="news-container">
<h1>News Dashboard</h1>
<NewsFilter />
{error && <div className="error-message">Error: {error}</div>}
{loading ? (
<div className="loading">Loading...</div>
) : (
<>
<div className="results-info">
Found {totalResults} results
</div>
<div className="news-list">
{articles.length > 0 ? (
articles.map((article, index) => (
<NewsItem key={index} article={article} />
))
) : (
<div className="no-results">No articles found</div>
)}
</div>
<Pagination total={totalResults} />
</>
)}
</div>
);
};
export default NewsList;
// src/components/Pagination.js
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { changePage } from '../store/actions/newsActions';
const Pagination = ({ total }) => {
const dispatch = useDispatch();
const { page } = useSelector(state => state.news);
const pageSize = 10;
const totalPages = Math.ceil(total / pageSize);
const handlePageChange = (newPage) => {
dispatch(changePage(newPage));
// Scroll to top
window.scrollTo(0, 0);
};
// Generate page numbers
const pageNumbers = [];
const maxPageButtons = 5;
let startPage = Math.max(1, page - Math.floor(maxPageButtons / 2));
let endPage = Math.min(totalPages, startPage + maxPageButtons - 1);
// Adjust if we're near the end
if (endPage - startPage + 1 < maxPageButtons) {
startPage = Math.max(1, endPage - maxPageButtons + 1);
}
for (let i = startPage; i <= endPage; i++) {
pageNumbers.push(i);
}
if (totalPages <= 1) {
return null;
}
return (
<div className="pagination">
<button
onClick={() => handlePageChange(1)}
disabled={page === 1}
className="pagination-button"
>
« First
</button>
<button
onClick={() => handlePageChange(page - 1)}
disabled={page === 1}
className="pagination-button"
>
‹ Prev
</button>
{pageNumbers.map(num => (
<button
key={num}
onClick={() => handlePageChange(num)}
className={`pagination-button ${page === num ? 'active' : ''}`}
>
{num}
</button>
))}
<button
onClick={() => handlePageChange(page + 1)}
disabled={page === totalPages}
className="pagination-button"
>
Next ›
</button>
<button
onClick={() => handlePageChange(totalPages)}
disabled={page === totalPages}
className="pagination-button"
>
Last »
</button>
</div>
);
};
export default Pagination;
// src/App.js
import React from 'react';
import { Provider } from 'react-redux';
import store from './store';
import NewsList from './components/NewsList';
import './App.css';
function App() {
return (
<Provider store={store}>
<div className="App">
<NewsList />
</div>
</Provider>
);
}
export default App;
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
This example demonstrates a complete React application using Redux with Thunk middleware to fetch and display news articles from a third-party API, with filtering and pagination functionality. It showcases many of the patterns and best practices covered in this tutorial.