Wednesday, 27 June 2018

How to deal with relational data in Redux?

The app I'm creating has a lot of entities and relationships (database is relational). To get an idea, there're 25+ entities, with any type of relations between them (one-to-many, many-to-many).

The app is React + Redux based. For getting data from the Store, we're using Reselect library.

The problem I'm facing is when I try to get an entity with its relations from the Store.

In order to explain the problem better, I've created a simple demo app, that has similar architecture. I'll highlight the most important code base. In the end I'll include a snippet (fiddle) in order to play with it.

Demo app

Business logic

We have Books and Authors. One Book has one Author. One Author has many Books. As simple as possible.

const authors = [{
  id: 1,
  name: 'Jordan Enev',
  books: [1]
}];

const books = [{
  id: 1,
  name: 'Book 1',
  category: 'Programming',
  authorId: 1
}];

Redux Store

Store is organized in flat structure, compliant with Redux best practices - Normalizing State Shape.

Here is the initial state for both Books and Authors Stores:

const initialState = {
  // Keep entities, by id:
  // { 1: { name: '' } }
  byIds: {},
  // Keep entities ids
  allIds:[]
};

Components

The components are organized as Containers and Presentations.

<App /> component act as Container (gets all needed data):

const mapStateToProps = state => ({
  books: getBooksSelector(state),
  authors: getAuthorsSelector(state),
  healthAuthors: getHealthAuthorsSelector(state),
  healthAuthorsWithBooks: getHealthAuthorsWithBooksSelector(state)
});

const mapDispatchToProps = {
  addBooks, addAuthors
}

const App = connect(mapStateToProps, mapDispatchToProps)(View);

<View /> component is just for the demo. It pushes dummy data to the Store and renders all Presentation components as <Author />, <Book />.

Selectors

For the simple selectors, it looks straightforward:

/**
 * Get Books Store entity
 */
const getBooks = ({books}) => books;

/**
 * Get all Books
 */
const getBooksSelector = createSelector(getBooks,
    (books => books.allIds.map(id => books.byIds[id]) ));


/**
 * Get Authors Store entity
 */
const getAuthors = ({authors}) => authors;

/**
 * Get all Authors
 */
const getAuthorsSelector = createSelector(getAuthors,
    (authors => authors.allIds.map(id => authors.byIds[id]) ));

It gets messy, when you have a selector, that computes / queries relational data. The demo app includes the following examples:

  1. Getting all Authors, which have at least one Book in specific category.
  2. Getting the same Authors, but together with their Books.

Here are the nasty selectors:

/**
 * Get array of Authors ids,
 * which have books in 'Health' category
 */  
const getHealthAuthorsIdsSelector = createSelector([getAuthors, getBooks],
    (authors, books) => (
    authors.allIds.filter(id => {
      const author = authors.byIds[id];
      const filteredBooks = author.books.filter(id => (
        books.byIds[id].category === 'Health'
      ));

      return filteredBooks.length;
    })
)); 

/**
 * Get array of Authors,
 * which have books in 'Health' category
 */   
const getHealthAuthorsSelector = createSelector([getHealthAuthorsIdsSelector, getAuthors],
    (filteredIds, authors) => (
    filteredIds.map(id => authors.byIds[id])
)); 

/**
 * Get array of Authors, together with their Books,
 * which have books in 'Health' category
 */    
const getHealthAuthorsWithBooksSelector = createSelector([getHealthAuthorsIdsSelector, getAuthors, getBooks],
    (filteredIds, authors, books) => (
    filteredIds.map(id => ({
        ...authors.byIds[id],
      books: authors.byIds[id].books.map(id => books.byIds[id])
    }))
));


Summing up

  1. As you can see, computing / querying relational data in selectors gets too complicated.
    1. Loading child relations (Author->Books).
    2. Filtering by child entities (getHealthAuthorsWithBooksSelector()).
  2. There will be too many selector parameters, if an entity has a lot of child relations. Checkout getHealthAuthorsWithBooksSelector() and imagine if the Author has a lot of more relations.

So how do you deal with relations in Redux?

It looks like a common use case, but surprisingly there aren't any good practices round.

*I checked redux-orm library and it looks promising, but its API is still unstable and I'm not sure is it production ready.

const { Component } = React
const { combineReducers, createStore } = Redux
const { connect, Provider } = ReactRedux
const { createSelector } = Reselect

/**
 * Initial state for Books and Authors stores
 */
const initialState = {
  byIds: {},
  allIds:[]
}

/**
 * Book Action creator and Reducer
 */

const addBooks = payload => ({
  type: 'ADD_BOOKS',
  payload
})

const booksReducer = (state = initialState, action) => {
  switch (action.type) {
  case 'ADD_BOOKS':
    let byIds = {}
    let allIds = []

    action.payload.map(entity => {
      byIds[entity.id] = entity
      allIds.push(entity.id)
    })

    return { byIds, allIds }
  default:
    return state
  }
}

/**
 * Author Action creator and Reducer
 */

const addAuthors = payload => ({
  type: 'ADD_AUTHORS',
  payload
})

const authorsReducer = (state = initialState, action) => {
  switch (action.type) {
  case 'ADD_AUTHORS':
    let byIds = {}
    let allIds = []

    action.payload.map(entity => {
      byIds[entity.id] = entity
      allIds.push(entity.id)
    })

    return { byIds, allIds }
  default:
    return state
  }
}

/**
 * Presentational components
 */
const Book = ({ book }) => <div>{`Name: ${book.name}`}</div>
const Author = ({ author }) => <div>{`Name: ${author.name}`}</div>

/**
 * Container components
 */

class View extends Component {
  componentWillMount () {
    this.addBooks()
    this.addAuthors()
  }

  /**
   * Add dummy Books to the Store
   */
  addBooks () {
    const books = [{
      id: 1,
      name: 'Programming book',
      category: 'Programming',
      authorId: 1
    }, {
      id: 2,
      name: 'Healthy book',
      category: 'Health',
      authorId: 2
    }]

    this.props.addBooks(books)
  }

  /**
   * Add dummy Authors to the Store
   */
  addAuthors () {
    const authors = [{
      id: 1,
      name: 'Jordan Enev',
      books: [1]
    }, {
      id: 2,
      name: 'Nadezhda Serafimova',
      books: [2]
    }]

    this.props.addAuthors(authors)
  }

  renderBooks () {
    const { books } = this.props

    return books.map(book => <div key={book.id}>
      {`Name: ${book.name}`}
    </div>)
  }

  renderAuthors () {
    const { authors } = this.props

    return authors.map(author => <Author author={author} key={author.id} />)
  }

  renderHealthAuthors () {
    const { healthAuthors } = this.props

    return healthAuthors.map(author => <Author author={author} key={author.id} />)
  }

  renderHealthAuthorsWithBooks () {
    const { healthAuthorsWithBooks } = this.props

    return healthAuthorsWithBooks.map(author => <div key={author.id}>
      <Author author={author} />
      Books:
      {author.books.map(book => <Book book={book} key={book.id} />)}
    </div>)
  }

  render () {
    return <div>
      <h1>Books:</h1> {this.renderBooks()}
      <hr />
      <h1>Authors:</h1> {this.renderAuthors()}
      <hr />
      <h2>Health Authors:</h2> {this.renderHealthAuthors()}
      <hr />
      <h2>Health Authors with loaded Books:</h2> {this.renderHealthAuthorsWithBooks()}
    </div>
  }
};

const mapStateToProps = state => ({
  books: getBooksSelector(state),
  authors: getAuthorsSelector(state),
  healthAuthors: getHealthAuthorsSelector(state),
  healthAuthorsWithBooks: getHealthAuthorsWithBooksSelector(state)
})

const mapDispatchToProps = {
  addBooks, addAuthors
}

const App = connect(mapStateToProps, mapDispatchToProps)(View)

/**
 * Books selectors
 */

/**
 * Get Books Store entity
 */
const getBooks = ({ books }) => books

/**
 * Get all Books
 */
const getBooksSelector = createSelector(getBooks,
  books => books.allIds.map(id => books.byIds[id]))

/**
 * Authors selectors
 */

/**
 * Get Authors Store entity
 */
const getAuthors = ({ authors }) => authors

/**
 * Get all Authors
 */
const getAuthorsSelector = createSelector(getAuthors,
  authors => authors.allIds.map(id => authors.byIds[id]))

/**
 * Get array of Authors ids,
 * which have books in 'Health' category
 */
const getHealthAuthorsIdsSelector = createSelector([getAuthors, getBooks],
  (authors, books) => (
    authors.allIds.filter(id => {
      const author = authors.byIds[id]
      const filteredBooks = author.books.filter(id => (
        books.byIds[id].category === 'Health'
      ))

      return filteredBooks.length
    })
  ))

/**
 * Get array of Authors,
 * which have books in 'Health' category
 */
const getHealthAuthorsSelector = createSelector([getHealthAuthorsIdsSelector, getAuthors],
  (filteredIds, authors) => (
    filteredIds.map(id => authors.byIds[id])
  ))

/**
 * Get array of Authors, together with their Books,
 * which have books in 'Health' category
 */
const getHealthAuthorsWithBooksSelector = createSelector([getHealthAuthorsIdsSelector, getAuthors, getBooks],
  (filteredIds, authors, books) => (
    filteredIds.map(id => ({
      ...authors.byIds[id],
      books: authors.byIds[id].books.map(id => books.byIds[id])
    }))
  ))

// Combined Reducer
const reducers = combineReducers({
  books: booksReducer,
  authors: authorsReducer
})

// Store
const store = createStore(reducers)

const render = () => {
  ReactDOM.render(<Provider store={store}>
    <App />
  </Provider>, document.getElementById('root'))
}

render()
<div id="root"></div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react-dom.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-core/5.8.24/browser.js"></script>
<script src="https://npmcdn.com/reselect@3.0.1/dist/reselect.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux/3.3.1/redux.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-redux/4.4.6/react-redux.min.js"></script>

JSFiddle.



from How to deal with relational data in Redux?

8 comments:

  1. This happens in the frameworks foundation and isn't yet exceptionally mainstream with a large portion of the organizations. Data Analytics Courses

    ReplyDelete
  2. I am looking for and I love to post a comment that "The content of your post is awesome" Great work!

    data science course

    ReplyDelete
  3. I have to search sites with relevant information on given topic and provide them to teacher our opinion and the article.

    Simple Linear Regression

    Correlation vs Covariance

    ReplyDelete
  4. very well explained .I would like to thank you for the efforts you had made for writing this awesome article. This article inspired me to read more. keep it up.
    Simple Linear Regression
    Correlation vs covariance
    data science interview questions
    KNN Algorithm
    Logistic Regression explained

    ReplyDelete
  5. This is a wonderful article, Given so much info in it, These type of articles keeps the users interest in the website, and keep on sharing more ... good luck.

    data science interview questions

    ReplyDelete
  6. very well explained .I would like to thank you for the efforts you had made for writing this awesome article. This article inspired me to read more. keep it up.
    Simple Linear Regression
    Correlation vs covariance
    data science interview questions
    KNN Algorithm
    Logistic Regression explained

    ReplyDelete
  7. I am looking for and I love to post a comment that "The content of your post is awesome" Great work!

    Simple Linear Regression

    Correlation vs Covariance

    ReplyDelete
  8. Really nice and interesting post. I was looking for this kind of information and enjoyed reading this one. Keep posting. Thanks for sharing.Data Analytics Course

    ReplyDelete