3

I don´t think this issue is bound to a specific framework or library, but applies to all store based application following the action - reducer pattern.

For clarity, I am using Angular and @ngrx.

In the application I am working on we need to track the loading state of individual resources.

The way we handle other async requests is by this, hopefully familiar, pattern:

Actions

  • GET_RESOURCE
  • GET_RESOURCE_SUCCESS
  • GET_RESOURCE_FAILURE

Reducer

switch(action.type)
  case GET_RESOURCE:
    return {
      ...state,
      isLoading = true
    };
  case GET_RESOURCE_SUCCESS:
  case GET_RESOURCE_FAILURE:
    return {
      ...state,
      isLoading = false
    };
  ...

This works well for async calls where we want to indicate the loading state globally in our application.

In our application we fetch some data, say BOOKS, that contains a list of references to other resources, say CHAPTERS. If the user wants to view a CHAPTER he/she clicks the CHAPTER reference that trigger an async call. To indicate to the user that this specific CHAPTER is loading, we need something more than just a global isLoading flag in our state.

The way we have solved this is by creating a wrapping object like this:

interface AsyncObject<T> {
  id: string;
  status: AsyncStatus;
  payload: T;
}

where AsyncStatus is an enum like this:

enum AsyncStatus {
  InFlight,
  Success,
  Error
}

In our state we store the CHAPTERS like so:

{
  chapters: {[id: string]: AsyncObject<Chapter> }
}

However, I feel like this 'clutter' the state in a way and wonder if someone has a better solution / different approach to this problem.

Questions

  • Are there any best practices for how to handle this scenario?
  • Is there a better way of handling this?
amu
  • 778
  • 6
  • 16

1 Answers1

0

I have faced several times this kind of situation but the solution differs according to the use case.

One of the solution would be to have nested reducers. It is not an antipattern but not advised because it is hard to maintain but it depends on the usecase.

The other one would be the one I detail below.

Based on what you described, your fetched data should look like this:

  [
    {
      id: 1,
      title: 'Robinson Crusoe',
      author: 'Daniel Defoe',
      references: ['chp1_robincrusoe', 'chp2_robincrusoe'],
    },
    {
      id: 2,
      title: 'Gullivers Travels',
      author: 'Jonathan Swift',
      references: ['chp1_gulliverstravels', 'chp2_gulliverstravels', 'chp3_gulliverstravels'],
    },
  ]

So according to your data, your reducers should look like this:

  {
    books: {
      isFetching: false,
      isInvalidated: false,
      selectedBook: null,
      data: {
        1: { id: 1, title: 'Robinson Crusoe', author: 'Daniel Defoe' },
        2: { id: 2, title: 'Gullivers Travels', author: 'Jonathan Swift' },
      }
    },

    chapters: {
      isFetching: false,
      isInvalidated: true,
      selectedChapter: null,
      data: {
        'chp1_robincrusoe': { isFetching: false, isInvalidated: true, id: 'chp1_robincrusoe', bookId: 1, data: null },
        'chp2_robincrusoe': { isFetching: false, isInvalidated: true, id: 'chp2_robincrusoe', bookId: 1, data: null },
        'chp1_gulliverstravels': { isFetching: false, isInvalidated: true, id: 'chp1_gulliverstravels', bookId: 2, data: null },
        'chp2_gulliverstravels': { isFetching: false, isInvalidated: true, id: 'chp2_gulliverstravels', bookId: 2, data: null },
        'chp3_gulliverstravels': { isFetching: false, isInvalidated: true, id: 'chp3_gulliverstravels', bookId: 2, data: null },
      },
    }
  }

With this structure you won't need isFetching and isInvalidated in your chapter reducers as every chapter is a separated logic.

Note: I could give you a bonus details later on on how we can leverage the isFetching and isInvalidated in a different way.

Below the detailed code:


Components

BookList

  import React from 'react';    
  import map from 'lodash/map';

  class BookList extends React.Component {
    componentDidMount() {
      if (this.props.isInvalidated && !this.props.isFetching) {
        this.props.actions.readBooks();
      }
    }

    render() {
      const {
        isFetching,
        isInvalidated,
        data,
      } = this.props;

      if (isFetching || (isInvalidated && !isFetching)) return <Loading />;
      return <div>{map(data, entry => <Book id={entry.id} />)}</div>;
    }
  }

Book

import React from 'react';
import filter from 'lodash/filter';
import { createSelector } from 'reselect';
import map from 'lodash/map';
import find from 'lodash/find';

class Book extends React.Component {
  render() {
    const {
      dispatch,
      book,
      chapters,
    } = this.props;

    return (
      <div>
        <h3>{book.title} by {book.author}</h3>
        <ChapterList bookId={book.id} />
      </div>
    );
  }
}

const foundBook = createSelector(
  state => state.books,
  (books, { id }) => find(books, { id }),
);

const mapStateToProps = (reducers, props) => {
  return {
    book: foundBook(reducers, props),
  };
};

export default connect(mapStateToProps)(Book);

ChapterList

  import React from 'react';
  import { connect } from 'react-redux';
  import { createSelector } from 'reselect';
  import map from 'lodash/map';
  import find from 'lodash/find';

  class ChapterList extends React.Component {
    render() {
      const { dispatch, chapters } = this.props;
      return (
        <div>
          {map(chapters, entry => (
            <Chapter
              id={entry.id}
              onClick={() => dispatch(actions.readChapter(entry.id))} />
          ))}
        </div>
      );
    }
  }

  const bookChapters = createSelector(
    state => state.chapters,
    (chapters, bookId) => find(chapters, { bookId }),
  );

  const mapStateToProps = (reducers, props) => {
    return {
      chapters: bookChapters(reducers, props),
    };
  };

  export default connect(mapStateToProps)(ChapterList);

Chapter

  import React from 'react';
  import { connect } from 'react-redux';
  import { createSelector } from 'reselect';
  import map from 'lodash/map';
  import find from 'lodash/find';

  class Chapter extends React.Component {
    render() {
      const { chapter, onClick } = this.props;

      if (chapter.isFetching || (chapter.isInvalidated && !chapter.isFetching)) return <div>{chapter.id}</div>;

      return (
        <div>
          <h4>{chapter.id}<h4>
          <div>{chapter.data.details}</div>  
        </div>
      );
    }
  }

  const foundChapter = createSelector(
    state => state.chapters,
    (chapters, { id }) => find(chapters, { id }),
  );

  const mapStateToProps = (reducers, props) => {
    return {
      chapter: foundChapter(reducers, props),
    };
  };

  export default connect(mapStateToProps)(Chapter);

Book Actions

  export function readBooks() {
    return (dispatch, getState, api) => {
      dispatch({ type: 'readBooks' });
      return fetch({}) // Your fetch here
        .then(result => dispatch(setBooks(result)))
        .catch(error => dispatch(addBookError(error)));
    };
  }

  export function setBooks(data) {
    return {
      type: 'setBooks',
      data,
    };
  }

  export function addBookError(error) {
    return {
      type: 'addBookError',
      error,
    };
  }

Chapter Actions

  export function readChapter(id) {
    return (dispatch, getState, api) => {
      dispatch({ type: 'readChapter' });
      return fetch({}) // Your fetch here - place the chapter id
        .then(result => dispatch(setChapter(result)))
        .catch(error => dispatch(addChapterError(error)));
    };
  }

  export function setChapter(data) {
    return {
      type: 'setChapter',
      data,
    };
  }

  export function addChapterError(error) {
    return {
      type: 'addChapterError',
      error,
    };
  }

Book Reducers

  import reduce from 'lodash/reduce';
  import { combineReducers } from 'redux';

  export default combineReducers({
    isInvalidated,
    isFetching,
    items,
    errors,
  });

  function isInvalidated(state = true, action) {
    switch (action.type) {
      case 'invalidateBooks':
        return true;
      case 'setBooks':
        return false;
      default:
        return state;
    }
  }

  function isFetching(state = false, action) {
    switch (action.type) {
      case 'readBooks':
        return true;
      case 'setBooks':
        return false;
      default:
        return state;
    }
  }

  function items(state = {}, action) {
    switch (action.type) {
      case 'readBook': {
        if (action.id && !state[action.id]) {
          return {
            ...state,
            [action.id]: book(undefined, action),
          };
        }

        return state;
      }
      case 'setBooks':
        return {
          ...state,
          ...reduce(action.data, (result, value, key) => ({
            ...result,
            [key]: books(value, action),
          }), {});
        },
      default:
        return state;
    }
  }

  function book(state = {
    isFetching: false,
    isInvalidated: true,

    id: null,
    errors: [],
  }, action) {
    switch (action.type) {
      case 'readBooks':
        return { ...state, isFetching: true };
      case 'setBooks':
        return {
          ...state,
          isInvalidated: false,
          isFetching: false,
          errors: [],
        };
      default:
        return state;
    }
  }

  function errors(state = [], action) {
    switch (action.type) {
      case 'addBooksError':
        return [
          ...state,
          action.error,
        ];
      case 'setBooks':
      case 'setBooks':
        return state.length > 0 ? [] : state;
      default:
        return state;
    }
  }

Chapter Reducers

Pay extra attention on setBooks which will init the chapters in your reducers.

  import reduce from 'lodash/reduce';
  import { combineReducers } from 'redux';

  const defaultState = {
    isFetching: false,
    isInvalidated: true,
    id: null,
    errors: [],
  };

  export default combineReducers({
    isInvalidated,
    isFetching,
    items,
    errors,
  });

  function isInvalidated(state = true, action) {
    switch (action.type) {
      case 'invalidateChapters':
        return true;
      case 'setChapters':
        return false;
      default:
        return state;
    }
  }

  function isFetching(state = false, action) {
    switch (action.type) {
      case 'readChapters':
        return true;
      case 'setChapters':
        return false;
      default:
        return state;
    }
  }

  function items(state = {}, action) {
    switch (action.type) {
      case 'setBooks':
        return {
          ...state,
          ...reduce(action.data, (result, value, key) => ({
            ...result,
            ...reduce(value.references, (res, chapterKey) => ({
              ...res,
              [chapterKey]: chapter({ ...defaultState, id: chapterKey, bookId: value.id }, action),
            }), {}),
          }), {});
        };
      case 'readChapter': {
        if (action.id && !state[action.id]) {
          return {
            ...state,
            [action.id]: book(undefined, action),
          };
        }

        return state;
      }
      case 'setChapters':
        return {
          ...state,
          ...reduce(action.data, (result, value, key) => ({
            ...result,
            [key]: chapter(value, action),
          }), {});
        },
      default:
        return state;
    }
  }

  function chapter(state = { ...defaultState }, action) {
    switch (action.type) {
      case 'readChapters':
        return { ...state, isFetching: true };
      case 'setChapters':
        return {
          ...state,
          isInvalidated: false,
          isFetching: false,
          errors: [],
        };
      default:
        return state;
    }
  }

  function errors(state = [], action) {
    switch (action.type) {
      case 'addChaptersError':
        return [
          ...state,
          action.error,
        ];
      case 'setChapters':
      case 'setChapters':
        return state.length > 0 ? [] : state;
      default:
        return state;
    }
  }

Hope it helps.

yuantonito
  • 1,274
  • 8
  • 17
  • 1
    I not sure I like this approach. I think this makes the `setBooks` reducer too complex. Why should it be the `setBooks` reducers responsibility to init the chapters? What if I only read one of over 100 chapters? I addition, the data model for each chapter (in `chapters.data`) is basically the same structure that I have already. – amu Nov 03 '17 at 15:53
  • That's the whole point of `readBook` or `readChapter` where you can only fetch one data. I believe that the most important here is the philosophy behind that. You can fetch all the chapters at a certain point but you can still invalidate a `chapter` or `book` in order to refetch it. – yuantonito Nov 03 '17 at 16:04