Redux Thunk
Thunk middleware for Redux. Enables async actions (making http calls from actions).
Overview
- redux-thunkis separate package/library
- enables async actions (AJAX calls)
- implemented as middleware for extending redux to handle async actions
- is only 15 lines of code- https://github.com/reduxjs/redux-thunk/blob/master/src/index.js
- why separate? ...following the Unix philosophy of doing one thing and doing it well
 
Async
Async Actions
- actions normally dispatch an object
- Redux Thunk allows actions to return functions
- the dispatched function is called a thunk
- async is handled by creating a pair of actions- one to start the request
- one to handle the complete response (success or failure)
 
Thunk
- a thunk is a function that is returned from another function
- a thunk function takes dispatch (and getState) as parameters
- a thunk function- initially dispatches an action to say the request started then
- waits for the ajax call to return and then dispatches another action (either success or failure)
 
- the reducer only processes dispatched objects (actions)
- the thunk middleware processes dispatched functions (thunks)
- both actions and thunks are created by action creator functions- the results of creators are passed to dispatch
- there is no distinction between action creators and thunk creators- thunk creators often end up in an actions file and look just like an action creator
 
 
Installation
npm install redux-thunk
Then, to enable Redux Thunk, use applyMiddleware()
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './reducers/index';
// Note: this API requires redux@>=3.1.0
const store = createStore(rootReducer, applyMiddleware(thunk));
Note: the registration of Redux Thunk is slightly different in the demos below because we are not using ES Modules yet.
Demos
- In the following demonstrations we will use Redux with no UI (so no React), just console.log
- We will refactor the HTTP Demo(s) to use Redux with Redux Thunk
1. Your First Thunk
const baseUrl = 'http://localhost:3000';
class PhotoAPI {
  url = `${baseUrl}/photos`;
  constructor() {}
  getAll(page = 1, limit = 100) {
    return fetch(`${this.url}?_page=${page}&_limit=${limit}`)
      .then(this.checkStatus)
      .then(this.parseJSON);
  }
  static translateStatusToErrorMessage(status) {
    switch (status) {
      case 401:
        return 'Please login again.';
      case 403:
        return 'You do not have permission to view the photos.';
      default:
        return 'There was an error retrieving the photos. Please try again.';
    }
  }
  checkStatus(response) {
    if (response.status >= 200 && response.status < 300) {
      return response;
    } else {
      const httpErrorInfo = {
        status: response.status,
        statusText: response.statusText,
        url: response.url,
      };
      console.log(
        `logging http details for debugging: ${JSON.stringify(httpErrorInfo)}`
      );
      let errorMessage = PhotoAPI.translateStatusToErrorMessage(
        httpErrorInfo.status
      );
      throw new Error(errorMessage);
    }
  }
  parseJSON(response) {
    return response.json();
  }
}
const LOAD_PHOTOS_REQUEST = 'LOAD_PHOTOS_REQUEST';
const LOAD_PHOTOS_SUCCESS = 'LOAD_PHOTOS_SUCCESS';
const LOAD_PHOTOS_FAILURE = 'LOAD_PHOTOS_FAILURE';
const initialState = {
  photos: [],
  processing: false,
  error: null,
};
function reducer(state = initialState, action) {
  switch (action.type) {
    case LOAD_PHOTOS_REQUEST:
      return { ...state, processing: true };
    case LOAD_PHOTOS_SUCCESS:
      return { ...state, processing: false, photos: action.payload };
    case LOAD_PHOTOS_FAILURE:
      return { ...state, processing: false, error: action.payload.message };
    default:
      return state;
  }
}
//action creator becomes thunk creator
//instead of dispatching an action object (see commented code)
//dispatch a thunk function (a function that returns another function)
//inside the thunk have that function dispatch the initial request object that sets the loading
//and eventually dispatches success and failure actions
//by returning a function (thunk) you are now able to have the action creator do multiple dispatches over time
function loadPhotos() {
  //   return { type: LOAD_PHOTOS_REQUEST };
  return function thunk(dispatch, getState) {
    let photoAPI = new PhotoAPI();
    dispatch({ type: LOAD_PHOTOS_REQUEST });
    return photoAPI
      .getAll(1)
      .then((data) => {
        dispatch({ type: LOAD_PHOTOS_SUCCESS, payload: data });
      })
      .catch((error) => {
        dispatch({ type: LOAD_PHOTOS_FAILURE, payload: error });
      });
  };
}
var ReduxThunk = window.ReduxThunk;
const store = Redux.createStore(reducer, Redux.applyMiddleware(ReduxThunk));
function logState() {
  console.log(JSON.stringify(store.getState(), null, ' '));
}
store.subscribe(logState);
async function test() {
  await store.dispatch(loadPhotos());
  console.log('loaded photos');
}
test();
2. CRUD
- GET (Read)
- POST (Add)
- PUT (Update)
- DELETE (Delete)
const baseUrl = 'http://localhost:3000';
class PhotoAPI {
  url = `${baseUrl}/photos`;
  constructor() {}
  getAll(page = 1, limit = 100) {
    return fetch(`${this.url}?_page=${page}&_limit=${limit}`)
      .then(this.checkStatus)
      .then(this.parseJSON);
  }
  add(photo) {
    return fetch(`${this.url}`, {
      method: 'POST',
      body: JSON.stringify(photo),
      headers: {
        'Content-Type': 'application/json',
      },
    })
      .then(this.checkStatus)
      .then(this.parseJSON);
  }
  update(photo) {
    return fetch(`${this.url}/${photo.id}`, {
      method: 'PUT',
      body: JSON.stringify(photo),
      headers: {
        'Content-Type': 'application/json',
      },
    })
      .then(this.checkStatus)
      .then(this.parseJSON);
  }
  delete(id) {
    return fetch(`${this.url}/${id}`, {
      method: 'DELETE',
      headers: {
        'Content-Type': 'application/json',
      },
    })
      .then(this.checkStatus)
      .then(this.parseJSON);
  }
  static translateStatusToErrorMessage(status) {
    switch (status) {
      case 401:
        return 'Please login again.';
      case 403:
        return 'You do not have permission to view the photos.';
      default:
        return 'There was an error retrieving the photos. Please try again.';
    }
  }
  checkStatus(response) {
    if (response.status >= 200 && response.status < 300) {
      return response;
    } else {
      const httpErrorInfo = {
        status: response.status,
        statusText: response.statusText,
        url: response.url,
      };
      console.log(
        `logging http details for debugging: ${JSON.stringify(httpErrorInfo)}`
      );
      let errorMessage = PhotoAPI.translateStatusToErrorMessage(
        httpErrorInfo.status
      );
      throw new Error(errorMessage);
    }
  }
  parseJSON(response) {
    return response.json();
  }
}
//action types
const LOAD_PHOTOS_REQUEST = 'LOAD_PHOTOS_REQUEST';
const LOAD_PHOTOS_SUCCESS = 'LOAD_PHOTOS_SUCCESS';
const LOAD_PHOTOS_FAILURE = 'LOAD_PHOTOS_FAILURE';
const ADD_PHOTO_REQUEST = 'ADD_PHOTO_REQUEST';
const ADD_PHOTO_SUCCESS = 'ADD_PHOTO_SUCCESS';
const ADD_PHOTO_FAILURE = 'ADD_PHOTO_FAILURE';
const UPDATE_PHOTO_REQUEST = 'UPDATE_PHOTO_REQUEST';
const UPDATE_PHOTO_SUCCESS = 'UPDATE_PHOTO_SUCCESS';
const UPDATE_PHOTO_FAILURE = 'UPDATE_PHOTO_FAILURE';
const DELETE_PHOTO_REQUEST = 'DELETE_PHOTO_REQUEST';
const DELETE_PHOTO_SUCCESS = 'DELETE_PHOTO_SUCCESS';
const DELETE_PHOTO_FAILURE = 'DELETE_PHOTO_FAILURE';
//state (initial)
const initialState = {
  photos: [],
  processing: false,
  error: null,
};
//reducer
function reducer(state = initialState, action) {
  switch (action.type) {
    case LOAD_PHOTOS_REQUEST:
      return { ...state, processing: true };
    case LOAD_PHOTOS_SUCCESS:
      return { ...state, processing: false, photos: action.payload };
    case LOAD_PHOTOS_FAILURE:
      return { ...state, processing: false, error: action.payload.message };
    case ADD_PHOTO_REQUEST:
      return { ...state, processing: true };
    case ADD_PHOTO_SUCCESS:
      return {
        ...state,
        processing: false,
        photos: [...state.photos, action.payload],
      };
    case ADD_PHOTO_FAILURE:
      return { ...state, processing: false, error: action.payload.message };
    case UPDATE_PHOTO_REQUEST:
      return { ...state, processing: true };
    case UPDATE_PHOTO_SUCCESS:
      return {
        ...state,
        processing: false,
        photos: state.photos.map((photo) => {
          return photo.id === action.payload.id
            ? Object.assign({}, photo, action.payload)
            : photo;
        }),
      };
    case UPDATE_PHOTO_FAILURE:
      return { ...state, processing: false, error: action.payload.message };
    case DELETE_PHOTO_REQUEST:
      return { ...state, processing: true };
    case DELETE_PHOTO_SUCCESS:
      return {
        ...state,
        processing: false,
        photos: state.photos.filter((photo) => photo.id !== action.payload.id),
      };
    case DELETE_PHOTO_FAILURE:
      return { ...state, processing: false, error: action.payload.message };
    default:
      return state;
  }
}
//action creators
function loadPhotos() {
  return (dispatch) => {
    let photoAPI = new PhotoAPI();
    dispatch({ type: LOAD_PHOTOS_REQUEST });
    return photoAPI
      .getAll(1)
      .then((data) => {
        dispatch({ type: LOAD_PHOTOS_SUCCESS, payload: data });
      })
      .catch((error) => {
        dispatch({ type: LOAD_PHOTOS_FAILURE, payload: error });
      });
  };
}
function addPhoto(photo) {
  return (dispatch) => {
    let photoAPI = new PhotoAPI();
    dispatch({ type: ADD_PHOTO_REQUEST });
    return photoAPI
      .add(photo)
      .then((data) => {
        dispatch({ type: ADD_PHOTO_SUCCESS, payload: data });
      })
      .catch((error) => {
        dispatch({ type: ADD_PHOTO_FAILURE, payload: error });
      });
  };
}
function updatePhoto(photo) {
  return (dispatch) => {
    let photoAPI = new PhotoAPI();
    dispatch({ type: UPDATE_PHOTO_REQUEST });
    return photoAPI
      .update(photo)
      .then((data) => {
        dispatch({ type: UPDATE_PHOTO_SUCCESS, payload: data });
      })
      .catch((error) => {
        dispatch({ type: UPDATE_PHOTO_FAILURE, payload: error });
      });
  };
}
function deletePhoto(photoId) {
  return (dispatch) => {
    let photoAPI = new PhotoAPI();
    dispatch({ type: DELETE_PHOTO_REQUEST });
    return photoAPI
      .delete(photoId)
      .then((data) => {
        dispatch({ type: DELETE_PHOTO_SUCCESS, payload: data });
      })
      .catch((error) => {
        dispatch({ type: DELETE_PHOTO_FAILURE, payload: error });
      });
  };
}
var ReduxThunk = window.ReduxThunk;
//when we just needed Redux DevTools extension enabled but no middleware
// function enableDevTools() {
//   return (
//     window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
//   );
// }
// const store = Redux.createStore(reducer, enableDevTools());
// if you just need ReduxThunk
// const store = Redux.createStore(reducer, Redux.applyMiddleware(ReduxThunk));
// if you need Redux DevTools enabled & ReduxThunk middleware you use a composer
// don't pass enableDevTools, the __REDUX_DEVTOOLS_EXTENSION_COMPOSE__ has already added it
const compose = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || Redux.compose;
const store = Redux.createStore(
  reducer,
  compose(Redux.applyMiddleware(...[ReduxThunk]))
);
function logState() {
  console.log(JSON.stringify(store.getState(), null, ' '));
}
store.subscribe(logState);
const newPhoto = {
  albumId: 1,
  title: 'Added Photo',
  url: 'https://via.placeholder.com/600/b0f7cc',
  thumbnailUrl: 'https://via.placeholder.com/150/b0f7cc',
};
const updatedPhoto = {
  id: '1',
  albumId: 1,
  title: 'Updated Photo',
  url: 'https://via.placeholder.com/600/b0f7cc',
  thumbnailUrl: 'https://via.placeholder.com/150/b0f7cc',
};
async function test() {
  await store.dispatch(loadPhotos());
  console.log('loaded photos');
  await store.dispatch(addPhoto(newPhoto));
  console.log('added photo');
  const id = store.getState().photos[0].id;
  updatedPhoto.id = id;
  await store.dispatch(updatePhoto(updatedPhoto));
  console.log('updated photo');
  await store.dispatch(deletePhoto(id));
  console.log('deleted photo');
}
test();
Middleware & Enhancers
- Store enhancers are a formal mechanism for adding capabilities to Redux itself. Most people will never need to write one.
- To use middleware in Redux, we use the applyMiddleware() function exported by the Redux library.
- applyMiddleware is itself a store enhancer that lets us change how dispatch() works.
Reference
Thunk
Why Reducers need to be Pure
- Anti-Pattern: Async in Reducer
- Why Reducers Need to be Pure
- same input, same result- what if the api was down?
 
Middleware & Enhancers
Testing
Diagram
