paint-brush
Handling Loading Actions: The Proper Way in Reduxby@mtroskot
23,761 reads
23,761 reads

Handling Loading Actions: The Proper Way in Redux

by mtroskotJanuary 7th, 2020
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

Don’t put UI logic into reducers instead put it into a separate reducer. When handling asynchronous actions in your application, most of the time you want to let users know that their request is being executed in form of some loading indicator. The problem is that in our case news can be FETCHED, PUBLISHED, UPDATED, DELETED, REFRESHED and to display loading indicators for each action on different parts of the screen we would need to have 5 flags. Having booleans in. reducers for loading flags has its limitations. Everytime we want to start and stop a loading action we have to do it manually.

Company Mentioned

Mention Thumbnail
featured image - Handling Loading Actions: The Proper Way in Redux
mtroskot HackerNoon profile picture

TLDR; Don’t put UI logic into reducers instead put it into a separate reducer.

When handling asynchronous actions in your application, most of the time you want to let users know that their request is being executed in form of some loading indicator.

Adding UI logic to reducers is like mixing apples and pears, and is also against SoC (Separation of Concerns).

Let’s first start with the usual (bad) way of handling loading actions

In our reducer we declare requestInProgress and refreshing property.

import { newsActionTypes } from 'src/constants/store/actionTypes';

const initialState = {
  cachedNews: {},
  lastUpdate: 0,
  requestInProgress: false,
  refreshing: false,
  error: null
};

const newsReducer = (state = initialState, { type, payload }) => {
  switch (type) {
    case newsActionTypes.UPDATE_NEWS:
    case newsActionTypes.DELETE_NEWS:
    case newsActionTypes.PUBLISH_NEWS:
    case newsActionTypes.FETCH_NEWS:
      return {
        ...state,
        refreshing: payload.refreshing,
        requestInProgress: true,
        error: null
      };
    case newsActionTypes.FETCH_NEWS_SUCCESS:
    case newsActionTypes.PUBLISH_NEWS_SUCCESS:
    case newsActionTypes.DELETE_NEWS_SUCCESS:
    case newsActionTypes.UPDATE_NEWS_SUCCESS:
      return {
        ...state,
        cachedNews: payload.news,
        lastUpdate: payload.lastUpdate,
        refreshing: false,
        requestInProgress: false,
        error: null
      };
    case newsActionTypes.PUBLISH_NEWS_ERROR:
    case newsActionTypes.DELETE_NEWS_ERROR:
    case newsActionTypes.UPDATE_NEWS_ERROR:
    case newsActionTypes.FETCH_NEWS_ERROR:
      return {
        ...state,
        refreshing: false,
        requestInProgress: false,
        error: payload.error
      };
    default:
      return state;
  }
};

export default newsReducer;

So every time we dispatch a FETCH_NEWS action, requestInProgress is set to true. And every time we dispatch a FETCH_NEWS_SUCCESS or FETCH_NEWS_ERROR action requestInProgress is set to false.

All you have to do now is to check in your component where you are displaying news, if requestInProgress is true and render the loading indicator based on it.

import { fetchNews } from 'src/store/actions/newsActions';

const NewsComponent= props =>{
  const {news}=props;
  const {requestInProgress, refreshing, cachedNews}=news;
  
  useEffect(() => {
    props.fetchNews();
  }, []);
  
  const onRefresh = () => {
    const refreshing = true;
    props.fetchNews(refreshing);
  };
  
  if (requestInProgress && !refreshing) {
    return <Loader text="Loading news"}/>;
  }
  return (
     <ScrollView
      refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
     >
      <News cachedNews={cachedNews}/>
    </ScrollView> 
  );
}

const mapStateToProps = state => ({
  news: state.news,
});

When the component mounts, it will call the fetchNews action and a loading indicator will be displayed in the middle of the screen. If we swipe down when ScrollView is at

scrollY: 0,
onRefresh is called, which calls fetchNews, but this time we are refreshing news and the loading indicator is displayed above ScrollView.

That’s all good for now, but where is the problem?

The problem is that in our case news can be FETCHED, PUBLISHED, UPDATED, DELETED, REFRESHED and to display loading indicators for each action on different parts of the screen we would need to have 5 flags, for each action one, in our newsReducer. And there is one more problem, having booleans in reducers for loading flags has its limitations.

What if you want to show a loading indicator on a specific news in the list?
To achieve this, we will need to use, instead of boolean, an object that receives the id of the news being deleted.

const initialState = { 
    ...
    deleteInProgress: { inProgress:false, newsId:’’}
}

Our newsReducer would end up looking like this.

import { newsActionTypes } from 'src/constants/store/actionTypes';

const initialState = {
  cachedNews: {},
  lastUpdate: 0,
  fetchInProgress: false,
  publishInProgress: false,
  updateInProgress: false,
  deleteInProgress: { inProgress:false, newsId:''},
  refreshing: false,
  error: null
};

const newsReducer = (state = initialState, { type, payload }) => {
  switch (type) {
    case newsActionTypes.FETCH_NEWS:
      return {
        ...state,
        refreshing: payload.refreshing,
        fetchInProgress: true,
        error: null
      };
    case newsActionTypes.FETCH_NEWS_SUCCESS:
      return {
        ...state,
        cachedNews: payload.news,
        lastUpdate: payload.lastUpdate,
        refreshing: false,
        fetchInProgress: false,
        error: null
      };
    case newsActionTypes.FETCH_NEWS_ERROR:
      return {
        ...state,
        refreshing: false,
        fetchInProgress: false,
        error: payload.error
      };
     case newsActionTypes.PUBLISH_NEWS:
      return {
        ...state,
        publishInProgress: true
      };
    case newsActionTypes.PUBLISH_NEWS_SUCCESS:
      return {
        ...state,
        cachedNews: payload.news,
        lastUpdate: payload.lastUpdate,
        publishInProgress: false
      };
       case newsActionTypes.PUBLISH_NEWS_ERROR:
      return {
        ...state,
        publishInProgress: false
      };
    case newsActionTypes.UPDATE_NEWS:
      return {
        ...state,
        updateInProgress: true
      };
    case newsActionTypes.UPDATE_NEWS_SUCCESS:
      return {
        ...state,
        cachedNews: payload.news,
        lastUpdate: payload.lastUpdate,
        updateInProgress: false
      };
    case newsActionTypes.UPDATE_NEWS_ERROR:
      return {
        ...state,
        updateInProgress: false
      };
    case newsActionTypes.DELETE_NEWS:
      return {
        ...state,
        deleteInProgress: {
          inProgress: true,
          newsId: payload.newsId 
        }
      };
    case newsActionTypes.DELETE_NEWS_SUCCESS:
      return {
        ...state,
        cachedNews: payload.news,
        lastUpdate: payload.lastUpdate,
        deleteInProgress: initialState.deleteInProgress
      };
    case newsActionTypes.DELETE_NEWS_ERROR:
      return {
        ...state,
        deleteInProgress: initialState.deleteInProgress
      };
    default:
      return state;
  }
};

export default newsReducer;

The size of the reducer has grown, and readability is reduced because of unnecessary code which doesn’t belong to newsReducer. This scenario would likely repeat itself in some other reducer and we would end up repeating code (!DRY).

Let’s separate UI logic from reducers and create uiActions, uiReducer

Our ui action creators will look like:

import { uiActionTypes } from 'src/constants/store/actionTypes';

export const startAction = (name, params) => ({
  type: uiActionTypes.START_ACTION,
  payload: {
    action: {
      name,
      params
    }
  }
});

export const stopAction = name => ({
  type: uiActionTypes.STOP_ACTION,
  payload: { name }
});

export const refreshActionStart = refreshAction => ({
  type: uiActionTypes.REFRESH_ACTION_START,
  payload: { refreshAction }
});

export const refreshActionStop = refreshAction => ({
  type: uiActionTypes.REFRESH_ACTION_STOP,
  payload: { refreshAction }
});

Our uiReducer will look like:

import { uiActionTpyes } from 'src/constants/store/actionTypes';

const initialState = {
  loader: {
    actions: [],
    refreshing: []
  }
};

const uiReducer = (state = initialState, { type, payload }) => {
  const { loader } = state;
  const { actions, refreshing } = loader;
  switch (type) {
    case uiActionTpyes.START_ACTION:
      return {
        ...state,
        loader: {
          ...loader,
          actions: [...actions, payload.action]
        }
      };
    case uiActionTpyes.STOP_ACTION:
      return {
        ...state,
        loader: {
          ...loader,
          actions: actions.filter(action => action.name !== payload.name)
        }
      };
    case uiActionTpyes.REFRESH_ACTION_START:
      return {
        ...state,
        loader: {
          ...loader,
          refreshing: [...refreshing, payload.refreshAction]
        }
      };
    case uiActionTpyes.REFRESH_ACTION_STOP:
      return {
        ...state,
        loader: {
          ...loader,
          refreshing: refreshing.filter(refresh => refresh !== payload.refreshAction)
        }
      };
    default:
      return state;
  }
};

export default uiReducer;

Our newsSaga will look like:

import { call, put, select, takeLeading } from 'redux-saga/effects';
import {
  deleteNewsSuccess,
  fetchNewsError,
  fetchNewsSuccess,
  publishNewsSuccess,
  updateNewsSuccess
} from 'src/store/actions/newsActions';
import { ApiService } from 'src/services';
import { newsActionTypes } from 'src/constants/store/actionTypes';
import {
  startAction,
  stopAction,
  refreshActionStart,
  refreshActionStop
} from 'src/store/actions/uiActions';

export function* fetchNewsSaga({ type, payload }) {
  try {
    const { refreshing } = payload;
    yield put(refreshing ? refreshActionStart(type) : startAction(type));
    const response = yield call(ApiService.getNews);
    yield put(fetchNewsSuccess(response));
    }
  } catch (error) {
    console.log('fetchNewsSaga error',error);
  } finally {
    yield put(payload.refreshing ? refreshActionStop(type) : stopAction(type));
  }
}

export function* watchFetchNewsSaga() {
  yield takeLeading(newsActionTypes.FETCH_NEWS, fetchNewsSaga);
}

export function* publishNewsSaga({ type, payload }) {
  try {
    yield put(startAction(type));
    const { newsData } = payload;
    const response = yield call(ApiService.publishNews, newsData);
    yield put(publishNewsSuccess(response));
  } catch (error) {
    console.log('publishNewsSaga error', error);
  } finally {
    yield put(stopAction(type));
  }
}

export function* watchPublishNewsSaga() {
  yield takeLeading(newsActionTypes.PUBLISH_NEWS, publishNewsSaga);
}

export function* updateNewsSaga({ type, payload }) {
  try {
    yield put(startAction(type));
    const { newsId, newsData } = payload;
    yield call(ApiService.updateNews, newsId, newsData);
    const response = yield call(ApiService.getNews);
    yield put(updateNewsSuccess(response));
  } catch (error) {
    console.log('updateNewsSaga error', error);
  } finally {
    yield put(stopAction(type));
  }
}

export function* watchUpdateNewsSaga() {
  yield takeLeading(newsActionTypes.UPDATE_NEWS, updateNewsSaga);
}

export function* deleteNewsSaga({ type, payload }) {
  try {
    const { newsId } = payload;
    yield put(startAction(type, { newsId }));
    yield call(ApiService.deleteNews, newsId);
    const response = yield call(ApiService.getNews);
    yield put(deleteNewsSuccess(response));
  } catch (error) {
    console.log('updateNewsSaga error', error);
  } finally {
    yield put(stopAction(type));
  }
}

export function* watchDeleteNewsSaga() {
  yield takeLeading(newsActionTypes.DELETE_NEWS, deleteNewsSaga);
}

Our newsReducer will look like:

import { newsActionTypes } from 'src/constants/store/actionTypes';

const initialState = {
  cachedNews: {},
  lastUpdate: 0,
  error: null
};

const newsReducer = (state = initialState, { type, payload }) => {
  switch (type) {
    case newsActionTypes.FETCH_NEWS_SUCCESS:
    case newsActionTypes.PUBLISH_NEWS_SUCCESS:
    case newsActionTypes.DELETE_NEWS_SUCCESS:
    case newsActionTypes.UPDATE_NEWS_SUCCESS:
      return {
        ...state,
        cachedNews: payload.news,
        lastUpdate: payload.lastUpdate,
        error: null
      };
    case newsActionTypes.FETCH_NEWS_ERROR:
      return {
        ...state,
        error: payload.error
      };
    default:
      return state;
  }
};

export default newsReducer;

Look how much newsReducer is cleaner, after we have removed the UI part from it. Now it’s doing what it was supposed to do and that’s managing news related state.

And final but not least our selectors:

export const checkIfLoading = (store, ...actionsToCheck) =>
  store.ui.loader.actions.some(action => actionsToCheck.includes(action.name));

export const checkIfRefreshing = (store, actionToCheck) =>
  store.ui.loader.refreshing.some(action => action === actionToCheck);

export const getDeletingNewsId = (store, actionToCheck) => {
  let newsId = undefined;
  for (let i = 0; i < store.ui.loader.actions.length; i++) {
    const action = store.ui.loader.actions[i];
    if (action.name === actionToCheck) {
      newsId = action.params.newsId;
      break;
    }
  }
  return newsId;
};

Our NewsComponent has to just call the selectors to check which part is currently loading.

import { deleteNews, fetchNews } from 'src/store/actions/newsActions';
import { checkIfLoading, checkIfRefreshing, getDeletingNewsId, getUserData } from 'src/store/selectors';

const NewsComponent = props => {
  const { news, isLoading, refreshing, deletingNewsId } = props;
  const { cachedNews, error } = news;

  useEffect(() => {
     props.fetchNews();
  }, []);
  
  const deleteNews = newsId => {
    props.deleteNews(newsId);
  };

  const onRefresh = () => {
     const refreshing = true;
     props.fetchNews(refreshing);
  };

  if (isLoading && !refreshing) {
    return <Loader text={locales.loadingNews} />;
  }
  return (
    <ScrollView
      refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
    >
      <NewsList
        {...{ cachedNews, deleteNews, deletingNewsId }}
      />
    </ScrollView>
  );
};

const mapStateToProps = state => ({
  news: state.news,
  deletingNewsId: getDeletingNewsId(state, newsActionTypes.DELETE_NEWS),
  isLoading: checkIfLoading(state, newsActionTypes.FETCH_NEWS),
  refreshing: checkIfRefreshing(state, newsActionTypes.FETCH_NEWS)
});

const mapDispatchToProps = {
  fetchNews,
  deleteNews
};

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(NewsComponent);

What about displaying a loading indicator on a specific news in the list?

We are passing the deletingNewsId, which we get from the getDeletingNewsId selector, to the function which renders the delete button and if the deletingNewsId matches the id of the rendered news, instead of the delete button, a loading indicator is rendered.

function renderDeleteButton(deletingNewsId, deleteNewsAlert, newsId) {
  if (deletingNewsId === newsId) {
    return <ActivityIndicator />;
  }
  return (
    <CustomButton
      text={locales.delete}
      onPress={() => deleteNewsAlert(newsId)}
    />
  );
}

In PublishNews component we show the loading indicator if news are published or updated. Again we will use the checkIfLoading selector for that, but this time we are passing multiple actionTypes to it.

import { newsActionTypes } from 'src/constants/store/actionTypes';
import { checkIfLoading } from 'src/store/selectors';

function renderButton(isLoading){
  return isLoading? <ActivityIndicator/> : <Button/>;
}
  
const PublishNews = props =>{
  const { isLoading } = props;
return (
  <View>
    <TextInput/> 
    {renderButton(isLoading)} 
  </View>
}
const mapStateToProps = state => ({
 isLoading: checkIfLoading(state, newsActionTypes.PUBLISH_NEWS, newsActionTypes.UPDATE_NEWS)
});

Summing up

No approach is perfect so let’s address some of the downsides. Everytime we want to start and stop a loading action we have to do it manually. This introduces boilerplate code in our redux-sagas. At the beginning of the saga, we start the loading action and before the saga finishes we are stoping it, but this is needed to have maximum flexibility, i.e. deciding when the action starts and stops.

Manually starting and stoping loading actions could be prevented if we would refactor our uiReducer like this:

const uiReducer = (state = {}, action) => {
  const { type } = action;
  const matches = /(.*)_(REQUEST|SUCCESS|ERROR)/.exec(type);

  // not a *_REQUEST / *_SUCCESS /  *_FAILURE actions, so we ignore them
  if (!matches) {
    return state;
  }

  const [requestName, requestPrefix, requestState] = matches;
  return {
    ...state,
    // Store whether a request is happening at the moment or not
    // e.g. will be true when receiving FETCH_NEWS_REQUEST
    //      and false when receiving FETCH_NEWS_SUCCESS / FETCH_NEWS_ERROR
    [requestPrefix]: requestState === 'REQUEST'
  };
};

export default uiReducer;

Now every action type that ends with _REQUEST is added to actions object and set to true. The action type is set to false on every _SUCCESS or _ERROR action type, that matches the previous action type name. If you go with this approach you will need to cover edge cases like REFRESHING news (should go into separate object) and DELETING news (the id of the news that is being deleted needs to be stored).

That’s still possible, but your uiReducer would become messy and you wouldn’t be able to decide when exactly will the action start and stop, because it is handled automatically in uiReducer. Maybe you don’t want to start a loading action until some conditions aren’t met, again you can’t control that using this approach. That’s why I prefer the manually way of starting loading actions.

NOTE: don’t put all loading logic into redux blindly, only if the data you are fetching is stored into redux , then the loading logic should be handled in redux-saga,thunk etc. If the data is stored in component state, then the loading flags should also be stored in component state. One example is an autocomplete search component. It should not use redux for storing loading flags, instead it should use component state.

Feel free to leave your comments, questions, suggestions 😃.