Reactive Records ?
Reactive-records lets you describe your app's domain data and its behaviour in a very expressive and DRY manner.
It relies on the Mobx library to make the records of your model observable and reactive to changes. It can be used to abstract data synchronisation with your backend and also comes with offline capabilities as it helps you to implement the custom persistence strategies your very special app needs.
Goals and assuptions
This lib aims to help you write robust and efficient reactive models for your view layer to consume.
While it tries to be as agnostic as possible concerning your JavaScript stack, it does come with a few assumptions about your data. Reactive-records was clearly built with the relationnal/object model in mind, so in order to do anything, the following is assumed :
- Your business data is composed of ressources objects (Record instances, eg: users, todos, messages, unicorns, ...) that have several poperties you want to display and process.
- These ressources are uniquely identified by some sort of primary key ('id' by default).
Reactive-records tries to stay generic, but also pragmatic in its use. That's why it comes with default implementations that you can ealily extend or override if needed.
Getting started : concepts & features
In this section, you will understand the basics of this library through a simple (yet realistic) music app example.
The core : Records, Collections and Scopes
Data/Ressources/Albums.ts
// This collection acts as a store. // It will contain all the album records instances // and allow to perform operation on the latter as sets and subsets // the collections are typically used as singletons across all the app
Playing with Records and Collections
demo.ts
// 'autorun', 'when', 'reaction' or 'observer' functions provided by mobx // can be used to react to mutations in your collection or in your records// let's program a reaction that prints the name and release year of every album in our // collection every time those pieces of information are updatedreactionalbumCollection.items.mapalbum.nameWithReleaseYear,
let's create a new record : the simplest way is to create it directly form the collection thanks to the 'set' method
albumCollection.set
See console logs
"a simple view of albums in the collection : Nursery Cryme (1970)"
If the provided record representation contains an 'id' (or the primary key you have defined) that is already in the collection, the record will be updated.
albumCollection.set
See console logs
"a simple view of albums in the collection : Nursery Cryme (1971)"
You can get a particular record in the collection by providing its primary key value
You can get meta information about a record's instance, for exemple, _ownAtttributeNames
will retrieve all properties decorated with @ownAttribute
album._ownAttributesNames // ['id', 'name', 'releaseDate']album._ownAttributes // {id: 123, name: 'Nursery Cryme', relaseDate: Fri Nov 12 1971
If you don't provide a primary key value, a temporary identifier value is given
otherAlbum._primaryKeyValue // <random number prefixed by 'optimistic_'>
See console logs
"a simple view of albums in the collection : Nursery Cryme (1971), Foxrot (1972)"
Woops, there's a typo ! Let's correct that :
otherAlbum.name = 'Foxtrot'
See console logs
"a simple view of albums in the collection : Nursery Cryme (1971), Foxtrot (1972)"
For now, otherAlbum
has a temporary identifier.
Let's assume we saved it in our backend and a real identifier is now available
otherAlbum.id = 124
See console logs
NOTHING because only the 'name' and 'releaseDate' properties are
involved in our reaction, so nothing needs to be re-logged !
Using scopes
So as we've seen above, records are held in collections and you can access all the records in a collection with the items
getter :
myCollection.items // [record1, record2, ...]
But what if I want to have the items filtered, or in a specific order ? What if I have multiple views displaying different subsets of my collection ?
It's time to use scopes !
Scopes are just ordered collection subsets.
They have a name
and hold an ordered list of record primary keys, they can help if you doing pagination, search by attribute, etc.
Here's an exemple usage :
albumCollection.set // 'provideScope' will return an existing collection scope or create a new one if it does not existmyScope.itemPrimaryKeys = myScope.items.mapa.name // ['The Man Who Sold The World', 'The Rise And Fall Of Ziggy Stardust And The Spiders From Mars'] myOtherScope.itemPrimaryKeys = myScope.items.mapa.name // ['The Rise And Fall Of Ziggy Stardust And The Spiders From Mars', 'Hunky Dory']
Relationships between Records
Nice ! We have a way to describe the own attributes of our Records. But real-world apps do not work thanks to isolated domain objects, don't they ? So we need to express the relations between the different types of Records we have. Reactive records lets you use decorators that make some properties behave in a convinient manner, and allows you to manipulate your state as a graph, here's how:
"toOne" associations
Data/Ressources/Albums.ts
demo.ts
// let's program two reactions to see what's going on as we do the operationsreactionbandCollection.items, reaction,
The simplest way to associate the album with a new band is by assigning a POJO representation of the latter
album.band =
See console logs
bandCollection : [{"name":"The Warlocks","pkValue":"optimistic_2"}]
album.band.name : The Warlocks, album.band_id : optimistic_2
Notice that a new Band
instance is created within the dedicated collection
album.band
now returns a reference to the Band
instance and album.band_id
matches the optimistic
identifier given to the band (since we did not provide an id).
Let's now assume the band has been saved on the backend and a real identifier is available
album.band.id = 123
See console logs
bandCollection : [{"name":"The Warlocks","pkValue":123}]
album.band.name : The Warlocks, album.band_id : 123
Notice that album.band_id
is kept in sync with band.id
!
So you don't ever have to worry about having to update stale ids yourself.
You could also assign an existing band instance to the album :
album.band = secondBand
See console logs
bandCollection : [{"name":"The Warlocks","pkValue":123},{"name":"The Falling Spikes","pkValue":124}]
album.band.name : The Falling Spikes, album.band_id : 124
The last way of setting up an association is to update the 'foreignKeyAttribute' of the album
album.band_id = thirdBand.id
See console logs
bandCollection : [
{"name":"The Warlocks","pkValue":123},
{"name":"The Falling Spikes","pkValue":124},
{"name":"The Velvet Underground","pkValue":125}
]
album.band.name : The Velvet Underground, album.band_id : 125
"toMany" associations
Data/Ressources/Albums.ts
Data/Ressources/Albums.ts
demo.ts
Let's program two reactions to see what's going on as we do the operations :
reactionalbum.tracks.mapt.name, console.log"album's tracks names: " + trackNames.join', 'reactiontrackCollection.items.map ,
The simplest way to set an album's associated by assigning a POJO representation of the latter.
album.tracks =
See console logs
tracksCollection [
{"pkValue":"optimistic_2","name":"Sunday Morning","album_id":"optimistic_1"},
{"pkValue":"optimistic_3","name":"Venus in Furs","album_id":"optimistic_1"}
]
album's tracks names: Sunday Morning, Venus in Furs
See console logs
tracksCollection [
{"pkValue":"optimistic_2","name":"Sunday Morning","album_id":"optimistic_1"},
{"pkValue":"optimistic_3","name":"Venus in Furs","album_id":"optimistic_1"},
{"pkValue":"optimistic_4","name":"All Tomorrow's Parties"}
]
album.tracks =
See console logs
tracksCollection [
{"pkValue":"optimistic_2","name":"Sunday Morning","album_id":null},
{"pkValue":"optimistic_3","name":"Venus in Furs","album_id":null},
{"pkValue":"optimistic_4","name":"All Tomorrow's Parties","album_id":"optimistic_1"}
]
album's tracks names: All Tomorrow's Parties
Notice how you can pass an exisiting record and how the track list of the album is entirely redifined when assigning an array. You can also perform operations on the array, like push(), splice(), etc
album.tracks.push
See console logs
tracksCollection [
{"pkValue":"optimistic_2","name":"Sunday Morning","album_id":null},
{"pkValue":"optimistic_3","name":"Venus in Furs","album_id":null},
{"pkValue":"optimistic_4","name":"All Tomorrow's Parties","album_id":"optimistic_1"},
{"pkValue":"optimistic_7","name":"There She Goes Again","album_id":"optimistic_1"}
]
album's tracks names: All Tomorrow's Parties, There She Goes Again
But also element replacement by assignation using record instances of record as POJOs
album.tracks = trackCollection.items
See console logs
tracksCollection [
{"pkValue":"optimistic_2","name":"Sunday Morning","album_id":"optimistic_1"},
{"pkValue":"optimistic_3","name":"Venus in Furs","album_id":null},
{"pkValue":"optimistic_4","name":"All Tomorrow's Parties","album_id":null},
{"pkValue":"optimistic_7","name":"There She Goes Again","album_id":"optimistic_1"}
]
album's tracks names: Sunday Morning, There She Goes Again
Getting a state subgraph out of a record
Data/Ressources/Albums.ts
// <imports>
Data/Ressources/Tracks.ts
// <imports>
Data/Ressources/Bands.ts
// <imports>
Data/Ressources/Artists.ts
// <imports>
demo.ts
album._populatesubgraphconsole.logsubgraph
See console logs
{
name: 'Foxtrot',
band: {
name: "Genesis",
members: [
{ fullName: 'Peter Gabriel' },
{ fullName: 'Tony Banks' },
{ fullName: 'Mike Rutherford' },
{ fullName: 'Steve Hackett' },
{ fullName: 'Phil Collins' }
]
},
tracks: [
{id: 11},
{id: 22},
{id: null}, // the third track does not have an id, null is given since it was provided as default
]
label: 'Unknown label'
// album does not have a 'label' prop, default provided in original subgraph is taken
}
Dealing with persistence
Good persistence managment is crucial to every app's user experience. It's mostly about :
- speed : too long loading or processing delays makes a user go away.
- reliability : stale data might be printed on screen and give false information so we want that data to reflect the "truth" as often as possible.
- resilience : What happens if your API is down ? What if the user is in the middle of the desert and he would like to access some information he has seen earlier, when he had some network access ?
In the real world, implementing data access in an app can be a challenge :
- You may have to deal with asynchronicity
- You want to have your data in a coherent state all the time
- You may have to deal with bad network conditions, handeling possible errors that you cannot prevent, etc
Moreover, there are always tradeoffs.
For exemple, if you want your lists of to load super fastly, you might want to implement some sort of client-side caching. You will have to trade off reliability for speed and resisilence, but that can be totally acceptable.
One strategy could be :
- data is loaded form the server at a time T, we store that data in the local storage of the app.
- then the next time the loading of data is required, we can directly return what's been saved in the local storage.
Maybe if current time is less than T + Delay ? It's really up to you to define what's acceptable.
You could also display that data with a visual indication
informing the user that it might not be up to date (like reducing the opacity ?)
In the same time, you could send the API a request to make sure the user eventually gets the good version of the system's state. That way, the user does not have to look at a loading indicator for 5 seconds, when all he wanted to do was viewing previously loaded data. In the same time he knows that he is in offline mode and what he sees might not be perfectly up to date, but at least he can see something !
In the browser, you could leverage APIs like localStorage or sessionStorage, or implement an offline service worker that caches network calls. In React Native, you can leverage persistence APIs like AsyncStorage or Realm DB, SQLite, ...
The way records are persisted in an application is usually specific to the application.
That's why the persistence layer is completely abstracted in this library, thanks to the PersistenceStrategy
interface.
In a traditonnal application, you often want perform basic operations like :
- Retrieve items of a collection from a remote data source (like an API) or a local one (like localStorage or AsyncStorage or whatever is avalable in your app's environement)
- Load, save or destroy individual records and sync changes with the data source(s).
These operations are known as "CRUD" operations (Create, Read, Update, Delete). In order to stay "DRY" but yet flexible, we can write generic persistence strategies that can be shared between our collections (and overriden for a specific collection if necessary).
Before seeing how persistence strategies can be implemented, let's see how to use reactive-records persistence methods that will rely on the latter.
Using peristence methods in collections, scopes and records
await albumCollection.load, 'scope1' // In english : "wait until all the albums that have a band_id equal to 2 // are loaded into the albumCollection's scope named 'scope1'"// It's the PersistenceStrategy responsibility to do whatever is needed to achieve that. // 'load' will call the collection's persistence strategy's 'loadMany' method // passing along the desired collection scope // (which points back to the albumCollection with '.collection' attribute)// and the parameters of the requested subset of albums // You don't have to specify any parameter if you want to load all records in a scope named 'default'await albumCollection.load // You can load a particular record by providing a primaryKey or an existing record instance albumCollection.loadOne123 // You can also use persistence methods on recordsmyAlbum.save // will call 'saveOne' method of the record's collection's persistenceStrategymyAlbum.load // will call 'loadOne' method of the record's collection's persistenceStrategymyAlbum.destroy // will call 'destroyOne' method of the record's collection's persistenceStrategy // There is also shortcut methods on scopess1.load // calls 'load' on the collection with the scope's name and provided params
Notice how we can separate data manipulaton concerns from persistence concerns ? See next section to learn how to deal with those persistence concerns.
Implementing generic peristence strategies
In a traditonnal application, you often want perform basic operations like :
- Retrieve items of a collection from a remote data source (like an API) or a local one (like localStorage or AsyncStorage or whatever is avalable in your app's environement)
- Load, save or destroy individual records and sync changes to the data sources.
These operations are well known as "CRUD" operations (Create, Read, Update, Delete). In order to stay "DRY" but yet flexible, we can write generic persistence strategies that can be shared between our collections (and overriden by some if necessary).
In order to do that, we just have to implement the PersistenceStrategy
interface.
Here's an exemple of a partial implementation that first use a local persistence service before hitting a remote REST API.
Data/PersistenceStrategies/OfflineFirstStrategy.ts
Inspirations
- Awesome Rails' ActiveRecord
- mobx-rest
FAQ
Why the heck all method names that are exposed in Record class start with '_' ?
It's because it would be ugglier to pollute records namespaces with property names that could conflict with your domain data properties. Yes, there is a quite vague convention that says '_' shoud be the prefixer of private fields in order to discourage their use in client code. But that's what appears to be the most convinient way of achieving the pollution reduction objective without affecting the developer experience too much.
Why not use a property called 'attributes' to store data ?
Well, it's just not convinient, just imagine you want to access data on a three-level-deep association :
album.attributes.band.attributes.members[0].attributes.name
instead of
album.band.members[0].name
Which one does the least hurt your brain ?
Contributing
Suggestions are always welcome ! PRs are very welcome too, as long as you test your code :)
MIT License
Copyright (c) 2019 Pierre Genthon
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.