javascript - How to deal with relational data in Redux? -
the app i'm creating has lot of entities , relationships (database relational). idea, there're 25+ entities, type of relations between them (one-to-many, many-to-many).
the app react + redux based. getting data store, we're using reselect library.
the problem i'm facing when try entity relations store.
in order explain problem better, i've created simple demo app, has similar architecture. i'll highlight important code base. in end i'll include snippet (fiddle) in order play it.
demo app
business logic
we have books , authors. 1 book has 1 author. 1 author has many books. simple possible.
const authors = [{ id: 1, name: 'jordan enev', books: [1] }]; const books = [{ id: 1, name: 'book 1', category: 'programming', authorid: 1 }];
redux store
store organized in flat structure, compliant redux best practices - normalizing state shape.
here initial state both books , authors stores:
const initialstate = { // keep entities, id: // { 1: { name: '' } } byids: {}, // keep entities ids allids:[] };
components
the components organized containers , presentations.
<app />
component act container (gets 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 demo. pushes dummy data store , renders presentation components <author />, <book />
.
selectors
for simple selectors, looks straightforward:
/** * books store entity */ const getbooks = ({books}) => books; /** * books */ const getbooksselector = createselector(getbooks, (books => books.allids.map(id => books.byids[id]) )); /** * authors store entity */ const getauthors = ({authors}) => authors; /** * authors */ const getauthorsselector = createselector(getauthors, (authors => authors.allids.map(id => authors.byids[id]) ));
it gets messy, when have selector, computes / queries relational data. demo app includes following examples:
- getting authors, have @ least 1 book in specific category.
- getting same authors, books.
here nasty selectors:
/** * array of authors ids, * 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; }) )); /** * array of authors, * have books in 'health' category */ const gethealthauthorsselector = createselector([gethealthauthorsidsselector, getauthors], (filteredids, authors) => ( filteredids.map(id => authors.byids[id]) )); /** * array of authors, books, * 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
- as can see, computing / querying relational data in selectors gets complicated.
- loading child relations (author->books).
- filtering child entities (
gethealthauthorswithbooksselector()
).
- there many selector parameters, if entity has lot of child relations. checkout
gethealthauthorswithbooksselector()
, imagine if author has lot of more relations.
so how deal relations in redux?
it looks common use case, surprisingly there aren't practices round.
*i checked redux-orm library , looks promising, api still unstable , i'm not sure production ready.
const { component } = react const { combinereducers, createstore } = redux const { connect, provider } = reactredux const { createselector } = reselect /** * initial state books , authors stores */ const initialstate = { byids: {}, allids:[] } /** * book action creator , 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 , 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 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 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 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 */ /** * books store entity */ const getbooks = ({ books }) => books /** * books */ const getbooksselector = createselector(getbooks, books => books.allids.map(id => books.byids[id])) /** * authors selectors */ /** * authors store entity */ const getauthors = ({ authors }) => authors /** * authors */ const getauthorsselector = createselector(getauthors, authors => authors.allids.map(id => authors.byids[id])) /** * array of authors ids, * 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 }) )) /** * array of authors, * have books in 'health' category */ const gethealthauthorsselector = createselector([gethealthauthorsidsselector, getauthors], (filteredids, authors) => ( filteredids.map(id => authors.byids[id]) )) /** * array of authors, books, * 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>
this reminds me of how started 1 of projects data highly relational. think still backend way of doing things, gotta start thinking of more of js way of doing things (a scary thought some, sure).
1) normalized data in state
you've done job of normalizing data, really, it's normalized. why that?
... books: [1] ... ... authorid: 1 ...
you have same conceptual data stored in 2 places. can become out of sync. example, let's receive new books server. if have authorid
of 1, have modify book , add ids it! that's lot of work doesn't need done. , if isn't done, data out of sync.
one general rule of thumb redux style architecture never store (in state) can compute. includes relation, computed authorid
.
2) denormalized data in selectors
we mentioned having normalized data in state not good. denormalizing in selectors ok right? well, is. question is, needed? did same thing doing now, getting selector act backend orm. "i want able call author.books
, books!" may thinking. easy able loop through author.books
in react component, , render each book, right?
but, want normalize every piece of data in state? react doesn't need that. in fact, increase memory usage. why that?
because have 2 copies of same author
, instance:
const authors = [{ id: 1, name: 'jordan enev', books: [1] }];
and
const authors = [{ id: 1, name: 'jordan enev', books: [{ id: 1, name: 'book 1', category: 'programming', authorid: 1 }] }];
so gethealthauthorswithbooksselector
creates new object each author, not ===
1 in state.
this not bad. it's not ideal. on top of redundant (<- keyword) memory usage, it's better have 1 single authoritative reference each entity in store. right now, there 2 entities each author same conceptually, program views them totally different objects.
so when @ mapstatetoprops
:
const mapstatetoprops = state => ({ books: getbooksselector(state), authors: getauthorsselector(state), healthauthors: gethealthauthorsselector(state), healthauthorswithbooks: gethealthauthorswithbooksselector(state) });
you providing component 3-4 different copies of same data.
thinking solutions
first, before making new selectors , make fast , fancy, let's make naive solution.
const mapstatetoprops = state => ({ books: getbooksselector(state), authors: getauthors(state), });
ahh, data component needs! books
, , authors
. using data therein, can compute needs.
notice changed getauthorsselector
getauthors
? because data need computing in books
array, , can pull authors id
1 have them!
remember, we're not worrying using selectors yet, let's think problem in simple terms. so, inside component, let's build "index" of books author.
const { books, authors } = this.props; const healthbooksbyauthor = books.reduce((indexedbooks, book) => { if (book.category === 'health') { if (!(book.authorid in indexedbooks)) { indexedbooks[book.authorid] = []; } indexedbooks[book.authorid].push(book); } return indexedbooks; }, {});
and how use it?
const healthyauthorids = object.keys(healthbooksbyauthor); ... healthyauthorids.map(authorid => { const author = authors.byids[authorid]; return (<li>{ author.name } <ul> { healthbooksbyauthor[authorid].map(book => <li>{ book.name }</li> } </ul> </li>); }) ...
etc etc.
but but mentioned memory earlier, that's why didn't denormalize stuff gethealthauthorswithbooksselector
, right? correct! in case aren't taking memory redundant information. in fact, every single entity, books
, author
s, reference original objects in store! means new memory being taken container arrays/objects themselves, not actual items in them.
i've found kind of solution ideal many use cases. of course, don't keep in component above, extract reusable function creates selectors based on criteria. although, i'll admit haven't had problem same complexity yours, in have filter specific entity, through entity. yikes! still doable.
let's extract our indexer function reusable function:
const indexlist = fieldsby => list => { // don't have create property keys inside loop const indexedbase = fieldsby.reduce((obj, field) => { obj[field] = {}; return obj; }, {}); return list.reduce( (indexeddata, item) => { fieldsby.foreach((field) => { const value = item[field]; if (!(value in indexeddata[field])) { indexeddata[field][value] = []; } indexeddata[field][value].push(item); }); return indexeddata; }, indexedbase, ); };
now looks kind of monstrosity. must make parts of our code complex, can make many more parts clean. clean how?
const getbooksindexed = createselector([getbooksselector], indexlist(['category', 'authorid'])); const getbooksindexedincategory = category => createselector([getbooksindexed], booksindexedby => { return indexlist(['authorid'])(booksindexedby.category[category]) }); // can abstract more! ... later day ... const mapstatetoprops = state => ({ booksindexedby: getbooksindexedincategory('health')(state), authors: getauthors(state), }); ... const { booksindexedby, authors } = this.props; const healthyauthorids = object.keys(booksindexedby.authorid); healthyauthorids.map(authorid => { const author = authors.byids[authorid]; return (<li>{ author.name } <ul> { healthbooksbyauthor[authorid].map(book => <li>{ book.name }</li> } </ul> </li>); }) ...
this not easy understand of course, because relies on composing these functions , selectors build representations of data, instead of renormalizing it.
the point is: we're not looking recreate copies of state normalized data. we're trying *create indexed representations (read: references) of state digested components.
the indexing i've prevented here reuseable, not without problems (i'll let else figure out). don't expect use it, expect learn it: rather trying coerce selectors give backend-like, orm-like nested versions of data, use inherent ability link data using tools have: ids , object references.
these principles can applied current selectors. rather create bunch of highly specialized selectors every conceivable combination of data... 1) create functions create selectors based on parameters 2) create functions can used resultfunc
of many different selectors
indexing isn't everyone, i'll let others suggest other methods.
Comments
Post a Comment