Code To Learn logo

Code To Learn

M6: State ManagementRedux Toolkit Path

L5: Async Thunk for Fetching

Create fetchListings async thunk to fetch data from the API

Now let's handle async operations in Redux! We'll create an async thunk to fetch listings from the API.

The Problem with Async in Redux

Redux reducers must be synchronous and pure:

// ❌ CAN'T do this in a reducer
reducers: {
  fetchListings: async (state, action) => {
    const response = await api.get('/listings');  // ❌ Async!
    state.items = response.data;
  }
}

Why not?

  • Reducers must be pure functions (same input → same output)
  • Async operations have side effects
  • Redux can't track async state changes properly

The Solution: Async Thunks

Redux Toolkit provides createAsyncThunk to handle async logic:

export const fetchListings = createAsyncThunk(
  'listings/fetchListings',
  async () => {
    const response = await api.get('/listings');  // ✅ Async allowed here!
    return response.data;
  }
);

What is a thunk?

A thunk is a function that returns a function. It delays execution:

// Regular function
const add = (a, b) => a + b;
add(2, 3);  // Returns 5 immediately

// Thunk
const addThunk = (a, b) => () => a + b;
const delayed = addThunk(2, 3);  // Returns a function
delayed();  // Call it later - returns 5

Redux thunks let you dispatch async actions!

How Async Thunks Work

An async thunk automatically dispatches three actions:

Pending Action (Before API call)

dispatch(fetchListings());

Automatically dispatches:

{ type: 'listings/fetchListings/pending' }

Use this to set loading state.

Fulfilled Action (Success)

// API returns data successfully

Automatically dispatches:

{ 
  type: 'listings/fetchListings/fulfilled',
  payload: [/* API data */]
}

Use this to store the fetched data.

Rejected Action (Error)

// API call fails

Automatically dispatches:

{ 
  type: 'listings/fetchListings/rejected',
  error: { message: 'Network error' }
}

Use this to store error message.

The thunk handles all three automatically! You just write the async logic.

What We're Building

We'll create fetchListings thunk that:

  1. Calls the API to fetch listings
  2. Returns the data on success
  3. Handles errors automatically

Step 1: Import Dependencies

Open src/state/slices/listingsSlice.js and update imports:

src/state/slices/listingsSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import api from '@/api';

What did we add?

Step 2: Create Async Thunk

Add the async thunk before the slice definition:

src/state/slices/listingsSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import api from '@/api';

const initialState = {
  items: [],
  favorites: [],
  status: 'idle',
  error: null,
};

export const fetchListings = createAsyncThunk(
  'listings/fetchListings',
  async (filters = {}) => {
    const response = await api.get('/listings', {
      params: filters,
    });
    return response.data;
  }
);

const listingsSlice = createSlice({
  name: 'listings',
  initialState,
  reducers: {
    toggleFavorite: (state, action) => {
      const id = action.payload;
      
      if (state.favorites.includes(id)) {
        state.favorites = state.favorites.filter(favoriteId => favoriteId !== id);
      } else {
        state.favorites.push(id);
      }
    },
  },
});

export const { toggleFavorite } = listingsSlice.actions;
export default listingsSlice.reducer;

What's happening here?

Understanding the Async Flow

When you dispatch fetchListings():

Component Dispatches Thunk

dispatch(fetchListings({ search: 'beach' }));

Pending Action Dispatched

Redux automatically dispatches:

{ type: 'listings/fetchListings/pending' }

State updates to:

{ status: 'loading' }  // We'll add this in next lesson

Async Function Executes

const response = await api.get('/listings', {
  params: { search: 'beach' }
});
return response.data;

API call happens, returns data.

Fulfilled Action Dispatched

Redux automatically dispatches:

{
  type: 'listings/fetchListings/fulfilled',
  payload: [/* listings data */]
}

State updates to:

{
  items: [/* listings data */],
  status: 'succeeded'
}

If the API call fails, rejected action is dispatched instead:

{
  type: 'listings/fetchListings/rejected',
  error: { message: 'Network error' }
}

Thunk vs Regular Action

Regular Synchronous Action:

// Action creator
const addTodo = (text) => ({
  type: 'todos/add',
  payload: text
});

// Dispatch
dispatch(addTodo('Buy milk'));

// Reducer handles immediately
reducers: {
  addTodo: (state, action) => {
    state.push(action.payload);
  }
}

Async Thunk:

// Async thunk
const fetchTodos = createAsyncThunk(
  'todos/fetch',
  async () => {
    const response = await api.get('/todos');
    return response.data;
  }
);

// Dispatch (same syntax!)
dispatch(fetchTodos());

// Handle with extraReducers (next lesson!)
extraReducers: (builder) => {
  builder.addCase(fetchTodos.fulfilled, (state, action) => {
    state.items = action.payload;
  });
}

Key differences:

  • Thunks handle async operations
  • Thunks generate 3 actions (pending/fulfilled/rejected)
  • Thunks need extraReducers not reducers

Testing the Thunk

We can test the thunk is created correctly by adding a log:

src/state/slices/listingsSlice.js
export const fetchListings = createAsyncThunk(
  'listings/fetchListings',
  async (filters = {}) => {
    console.log('Fetching listings with filters:', filters);
    const response = await api.get('/listings', {
      params: filters,
    });
    console.log('Fetch succeeded:', response.data);
    return response.data;
  }
);

Remove these logs after testing! We'll handle the data properly in the next lesson.

Error Handling in Thunks

Async thunks handle errors automatically:

export const fetchListings = createAsyncThunk(
  'listings/fetchListings',
  async (filters = {}) => {
    try {
      const response = await api.get('/listings', {
        params: filters,
      });
      return response.data;
    } catch (error) {
      // Error is automatically caught and dispatched as rejected action
      throw error;
    }
  }
);

You don't need try/catch! Redux Toolkit handles it:

  • If promise resolves → fulfilled action
  • If promise rejects → rejected action with error

For custom error messages:

export const fetchListings = createAsyncThunk(
  'listings/fetchListings',
  async (filters = {}, { rejectWithValue }) => {
    try {
      const response = await api.get('/listings', {
        params: filters,
      });
      return response.data;
    } catch (error) {
      return rejectWithValue(error.response?.data || 'Failed to fetch listings');
    }
  }
);

We'll keep it simple for now - default error handling works great!

Complete Code

Here's the complete slice with the async thunk:

src/state/slices/listingsSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import api from '@/api';

const initialState = {
  items: [],
  favorites: [],
  status: 'idle',
  error: null,
};

export const fetchListings = createAsyncThunk(
  'listings/fetchListings',
  async (filters = {}) => {
    const response = await api.get('/listings', {
      params: filters,
    });
    return response.data;
  }
);

const listingsSlice = createSlice({
  name: 'listings',
  initialState,
  reducers: {
    toggleFavorite: (state, action) => {
      const id = action.payload;
      
      if (state.favorites.includes(id)) {
        state.favorites = state.favorites.filter(favoriteId => favoriteId !== id);
      } else {
        state.favorites.push(id);
      }
    },
  },
});

export const { toggleFavorite } = listingsSlice.actions;
export default listingsSlice.reducer;

What's Next?

Great! The async thunk is created. In the next lesson, we'll:

  1. Add extraReducers - Handle pending/fulfilled/rejected actions
  2. Update state - Set loading/error/success states
  3. Store fetched data - Put listings in state.items

✅ Lesson Complete! You've created an async thunk to fetch listings from the API!

Key Takeaways

  • Reducers must be synchronous - Can't use async/await
  • createAsyncThunk handles async operations in Redux
  • ✅ Thunks automatically dispatch 3 actions: pending, fulfilled, rejected
  • ✅ Return value becomes the fulfilled payload
  • ✅ Errors automatically trigger rejected action
  • ✅ Export thunk to dispatch from components
  • ✅ Use filters parameter for flexible API queries