Mobx-state-tree: Using reference setters for normalization, with data and query separation.

Created on 5 Nov 2018  Â·  10Comments  Â·  Source: mobxjs/mobx-state-tree

I'm trying to assess whether there are any performance issues or other downsides to building a store that transparently handles normalization using reference setters.

With the example i've pasted below, a request to the backend to search for books can be put into the store with a single line store.bookSearchResults = data and by the help of reference setters it is automatically put into the correct data collections.

const BookRef = types.reference(Book, {
  get(id, parent) {
    return getRoot(parent).data.books.get(id)
  },
  set(book, parent) {
    if (typeof book !== "string") {
      getRoot(parent).data.books.put(book)
    }
    return book.id
  }
})

const AuthorRef = types.reference(Author, {
  get(id, parent) {
    return getRoot(parent).data.authors.get(id)
  },
  set(author, parent) {
    if (typeof author !== "string") {
      getRoot(parent).data.authors.put(author)
    }
    return author.id
  }
})

const Author = types.model('Author', {
  id: types.identifier,
  name: types.string,
})

const Book = types.model('Book', {
  id: types.identifier,
  title: types.string,
  desc: types.string,
  author: AuthorRef
})

const Store = types.model('Store', {
  data: types.model('Data', {
    books: types.map(Book),
    authors: types.map(Author),
  }),
  bookSearchResults: types.array(BookRef),
  selectedAuthor: AuthorRef
})

There are a few benefits to having a store like this:

  1. Conceptually your data is a separated from your api results using references (an id or array of ids)
  2. Top level data collections allow for easy access by id (no deeply nested origins)
  3. You can insert into a reference and the actual data model gets put into the collection.

Has anyone done something similar to this before or seen any issues based on a structure like this?

question stale

Most helpful comment

I'm currently using a similar approach but with entities collections.
So all my entities are live in the same place i.e.:

Here is my Root store

const RootStore = types.model({
  users: types.optional(UserStore, {}),
  entities: types.optional(EntitiesStore, {
    users: {},
  }),
});

And here is my User store

export const User = types.model({
  id: types.identifier,
  // rest of the fields
});

export const UserStore = types.model({
  list: types.array(types.reference(User)),
});

// and each model I want to store inside the entities store have its own collection store

export const UserCollectionStore = types.model({
  collection: types.map(User),
});

And here is my entities store

const EntitiesStore = types
  .model('EntitiesStore', {
    users: types.optional(UserCollectionStore, {}),
  })

  .actions(store => ({
    merge(normalizedEntities) {
      Object.keys(normalizedEntities).forEach((entityKey) => {
        const storeEntity = store[entityKey];
        const entities = normalizedEntities[entityKey];

        Object.entries(entities).forEach(([key, value]) => {
          storeEntity.collection.set(key, value);
        });
      });
    },
  }));

I'm using merge action of entities store to merge all the items into entities. The normalizr helps me a lot with normalizing and making an array of ids and an object of all the entities I can pass to merge method of entities store.

// defining all the normalizr schemas
const UserSchema = new schema.Entity('users');
const UserCollectionSchema = [UserSchema];

// making an API request somewhere in the user store to fetch users
const response = await Api.fetchUsers(); // returns an array of all the users

// normalizig response. check out normalizr documentation for details
const { result, entities } = normalize(response, UserCollectionSchema);

getRoot(self).entities.merge(entities);
self.list = result; // assigning an array of ids to my refereces array

The main difference here is the facts I don't need any custom references and I always know where my objects live – entities._collectionName_.collection.
Also, I don't care about merging it into entities, I use normalizr and merge method, and it helps me merge even deep and hard response, normalize everything with auto-creating of references for everything.
Also, each CollectionStore is able to manage fetching, i.e. by id or ids when you don't need to store ids and just wanna fetch the object. And I think it's kinda cool to separate responsibility between stores.

If someone is interested in how it all works I can make a code sandbox example.

All 10 comments

I'm currently using a similar approach but with entities collections.
So all my entities are live in the same place i.e.:

Here is my Root store

const RootStore = types.model({
  users: types.optional(UserStore, {}),
  entities: types.optional(EntitiesStore, {
    users: {},
  }),
});

And here is my User store

export const User = types.model({
  id: types.identifier,
  // rest of the fields
});

export const UserStore = types.model({
  list: types.array(types.reference(User)),
});

// and each model I want to store inside the entities store have its own collection store

export const UserCollectionStore = types.model({
  collection: types.map(User),
});

And here is my entities store

const EntitiesStore = types
  .model('EntitiesStore', {
    users: types.optional(UserCollectionStore, {}),
  })

  .actions(store => ({
    merge(normalizedEntities) {
      Object.keys(normalizedEntities).forEach((entityKey) => {
        const storeEntity = store[entityKey];
        const entities = normalizedEntities[entityKey];

        Object.entries(entities).forEach(([key, value]) => {
          storeEntity.collection.set(key, value);
        });
      });
    },
  }));

I'm using merge action of entities store to merge all the items into entities. The normalizr helps me a lot with normalizing and making an array of ids and an object of all the entities I can pass to merge method of entities store.

// defining all the normalizr schemas
const UserSchema = new schema.Entity('users');
const UserCollectionSchema = [UserSchema];

// making an API request somewhere in the user store to fetch users
const response = await Api.fetchUsers(); // returns an array of all the users

// normalizig response. check out normalizr documentation for details
const { result, entities } = normalize(response, UserCollectionSchema);

getRoot(self).entities.merge(entities);
self.list = result; // assigning an array of ids to my refereces array

The main difference here is the facts I don't need any custom references and I always know where my objects live – entities._collectionName_.collection.
Also, I don't care about merging it into entities, I use normalizr and merge method, and it helps me merge even deep and hard response, normalize everything with auto-creating of references for everything.
Also, each CollectionStore is able to manage fetching, i.e. by id or ids when you don't need to store ids and just wanna fetch the object. And I think it's kinda cool to separate responsibility between stores.

If someone is interested in how it all works I can make a code sandbox example.

IMHO the only thing you have to be careful about is to handle what happens when a reference points to an instance that is no longer available. Could be done as a reaction that whenever the data changes then it will remove the removed book from the search results / set the author ref to undefined

Performance wise it should be ok, you are using maps which are O(1) lookups

@xaviergonz are you using something similar to these approaches?

This issue has been automatically marked as stale because it has not had recent activity in the last 10 days. It will be closed in 4 days if no further activity occurs. Thank you for your contributions.

Seems smelly to need two schemas for everything, MST already has the relationship mappings.

I tried doing this but types.reference validation got in my way:

if anyone has a way around it I'd appreciate a help

import { types } from 'mobx-state-tree'
import * as models from '../models'

const entities = Object.values(models).reduce((obj, model) => {
  obj[model.name.toLowerCase()] = types.map(model)
  return obj
}, {})

const EntitiesStore = types
  .model('EntitiesStore', entities)
  .views(self => ({}))
  .actions(self => ({}))

export default EntitiesStore
import { getRoot, types } from 'mobx-state-tree'

const getEntityMap = (target, type) => {
  const root = getRoot(target)
  return root.entities[model.name.toLowerCase()]
}

const sharedReference = (type, id = 'id') =>
  types.maybe(
    types.reference(type, {
      set(value, parent) {
        if (!value) return value
        getEntityMap(parent, type).set(value[id], value)
        return value[id]
      },
      get(identifier, parent) {
        if (!identifier) return identifier
        return getEntityMap(parent, type).get(identifier)
      },
    }),
  )

export default sharedReference
import { flow, types } from 'mobx-state-tree'
import User from '../models/User'
import sharedReference from './sharedReference'

export default types
  .model('AuthStore', {
    user: sharedReference(User),
  })
  .views(self => ({))
  .actions(self => ({
    setUser(user) {
      self.user = user
    },
  }))

I gave up on the MST version of this since it was very much going against the opinions of this library.

That said, i've since done something similar to this in plain-old-Mobx which has allowed me to do some truly superior things I haven't seen in other apps. There have also been no performance issues.

Our API endpoints respond with two fields: a value field and an entities array. The value can be either an ID or an array of ID's, and the entities array is the entity objects that correspond to the value and includes any child or related entities that might be needed, eg the books author in the example above.

The MobX root store then intercepts all API requests and "upserts" the entities into a global Map() of entities. They are all stored here and never anywhere else.

Any stores or models that need to reference an entity store an ID (eg bookId or bookIds) and have a complementary computed getter that resolves the entities:

get book() {
  return this.store.get(this.bookId)
}

or

get books() {
  return this.bookIds && this.bookIds.map(id => this.store.get(id))
}

This makes everything about building an app insanely simple. You no longer need to worry about where things are kept in order to update them or remove them, you just upsert the entity or remove the ID reference and away you go.

AFAIK this is entirely unconventional and I am yet to see someone actually do this in MobX but it became a requirement to simplify building some really complex things.

@ashconnell I am currently doing that with redux and normalizr. Adding things to my generic store is very straight forward, the problem lies in the retrieving side, I either have to denormalize and deal with bad performance or work with the normalized data, wish is a pain.

I am turning to Mobx/MST for a magic alternative. The retrieving side is very simple with MST. The adding part is what I am currently stuck, I dont want to deal with a data tree as MST advices, its just stupid, so much more work.

Maybe I will try raw Mobx with a normalized structure with getters like yours and see how far I can go.

My adventure with this started with redux too and I had the same issues. I find MST to be extremely nice but I like to know I can experiment with anything that comes up so I stuck with MobX.

I would need it too, I was thinking about something like types.resource.

types.resource(  'Books',
  BooksSortingFilteringAndStuffModel,
  types.map(Book),
)

For me more important now (or must have) is to keep domain paths like:

tryResolve(root, '/shelf/books')      # collection store, sorting, filtering, fetching and stuff
tryResolve(root, '/shelf/books/12') # access book
Was this page helpful?
0 / 5 - 0 ratings

Related issues

xgenvn picture xgenvn  Â·  3Comments

mshibl picture mshibl  Â·  3Comments

misantronic picture misantronic  Â·  3Comments

lostfictions picture lostfictions  Â·  4Comments

A-gambit picture A-gambit  Â·  3Comments