Redux with Thunk and Fetching

A comprehensive guide for JavaScript web developers

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 fetch request 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:

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:

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:

  1. The action type and timestamp
  2. The state before the action
  3. The action object itself
  4. 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:

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:

  1. "Check if you have eggs"
  2. "If not, go to the store and buy eggs"
  3. "Beat the eggs in a bowl"
  4. "Heat the pan and add butter"
  5. "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

  1. Separation of Concerns: Components focus on rendering, not data fetching
  2. Reusability: The same thunk can be called from multiple components
  3. Centralized State Management: All user data and loading states are in one place
  4. Easier Testing: Components, actions, and reducers can be tested separately
  5. Caching and Memoization: Data can be cached in the Redux store
  6. 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

  1. Use Action Type Constants

    Define action types as constants in a separate file to avoid typos and make maintenance easier.

  2. Follow the Action Creator Pattern

    Create separate functions for each action type to improve readability and reusability.

  3. Handle Loading and Error States

    Always track loading and error states in your reducers to provide proper feedback to users.

  4. Use Selectors

    Create selector functions to extract data from your store, making your components more resilient to store structure changes.

  5. Keep Thunks Focused

    Each thunk should have a single responsibility. Break complex operations into multiple thunks if needed.

  6. Return Promises from Thunks

    This allows components to chain actions after thunk completion if needed.

  7. Use getState Sparingly

    While useful, overusing getState can lead to complex dependencies between actions and state.

  8. Separate API Logic

    Consider creating a separate API utility layer for complex applications.

Common Pitfalls

  1. 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 });
      }
    };
                        
  2. Forgetting to Check Response Status

    Always check if the response is ok before processing it.

  3. 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]
      };
                        
  4. Not Handling Pending States for Multiple Items

    Keep track of which items are being modified to provide proper UI feedback.

  5. Excessive Re-renders

    Use memoization and careful selector design to prevent unnecessary component re-renders.

  6. 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 type property.
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"
    >
      &laquo; First
    </button>
    
    <button 
      onClick={() => handlePageChange(page - 1)}
      disabled={page === 1}
      className="pagination-button"
    >
      &lsaquo; 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 &rsaquo;
    </button>
    
    <button 
      onClick={() => handlePageChange(totalPages)}
      disabled={page === totalPages}
      className="pagination-button"
    >
      Last &raquo;
    </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.