visit
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).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.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:’’}
}
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).
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 }
});
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;
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);
}
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;
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;
};
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);
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)
});
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 😃.