libx
v1.0.0-alpha.4
Published
Collection + Model library for MobX
Downloads
2,249
Maintainers
Readme
libx
Collection + Model infrastructure for MobX applications. Written in TypeScript.
Install
# Depends on MobX, it's BYOD (Bring Your Own Dependencies)
npm install --save libx mobx
Table of Contents
- libx
- Table of Contents
- Why?
- Examples
- Concepts
- Let's build an app!
- API documentation
collection([opts])
- Collection object
collection.items
collection.length
collection.add(models)
collection.create(data, [opts])
collection.get(id)
collection.set(data, [opts])
collection.clear()
collection.remove(modelOrId)
collection.move(fromIndex, toIndex)
collection.referenceOne(ids, field?)
collection.referenceMany(ids, field)
- The
Model
class - The
Store
class createRootStore(obj)
- Projects using LibX
- See Also
- Author
Why?
Maintaining large application state is hard. Maintaining single references to entities for a single source of truth is hard. But it doesn't have to be.
LibX is inspired by Backbone's notion of Collections and Models, and makes it sexy by using MobX to manage state, instead of using events.
TL;DR: Maintaining only a single instance of a model is a chore. With LibX, it's not.
Examples
See the TypeScript example and Babel example for runnable examples (in Node).
Concepts
LibX concepts are similar to those of Backbone: Models and Collections are used to represent your domain data. LibX also adds the Flux concept of Stores.
The Root Store
Just a fancy name for an object (or instance of a class) that references all your stores. Yes, it will work with Server Side Rendering, too. A root store is by no means required to build apps with LibX - it's just convenient.
Example:
import TodoStore from './TodoStore'
import UserStore from './UserStore'
class RootStore {
constructor() {
this.userStore = new UserStore({ rootStore: this })
this.todoStore = new TodoStore({ rootStore: this })
}
}
Store
A store maintains "top-level" state, like collections, and whether or not some sidebar is visible.
It also contains actions: a means of mutating state. While not required, it is recommended that all actions and logic is implemented in stores.
Example:
import { action } from 'mobx'
import { Store } from 'libx'
import Todo from '../models/Todo'
export default class TodoStore extends Store {
todos = this.collection({
model: Todo,
})
@action
createTodo(text) {
return http
.post('/todos', {
text: text,
})
.then((data) => {
this.todos.set(data)
})
}
@action
toggle(todo) {
const completed = !todo.completed
todo.set({ completed: completed })
return (
http
.patch('/todos/' + todo.id, {
completed: todo.completed,
})
// this will update the todo model, because it's already
// in the collection with the same ID.
.then(this.todos.set)
)
}
}
Collection
Maintains a collection of models, making sure we only have a single instance of an entity in memory. That way, updates to an entity will propagate to the entire system without us having to do anything at all.
Example: see the Store
example.
Model
Represents a concept of your own domain, like a User
, a Todo
, a Product
or whatever your domain deals with.
Models usually have observable and computed properties, and no actions other than the built-in set()
. If you want to, you can give your models actions, but it is recommended to keep all actions in the stores.
Example:
import { observable } from 'mobx'
import { Model } from 'libx'
export default class Todo extends Model {
@observable text = ''
@observable completed = false
}
If you are not a fan of using classes, you can also use the model
builder pattern.
Example:
import { model } from 'libx'
export const Todo = (attrs, opts) => {
return model()
.extendObservable({
text: '',
completed: false,
})
.set(attrs, opts)
}
Let's build an app!
This section is mostly going to break down the Babel example for easier digestion. :smile:
We are building a Todo app - how original - but there's a twist:
Each Todo
has a creator
, which is a User
. It's like erhh, let's say "Todos for Teams", hehe...
So that means we have 2 entities in our system: Todo
and User
. But more on those later.
Note: basic knowledge of MobX and Promises will help.
Step 1: the Root Store
To make it easier for your application parts to communicate, we need some glue. In LibX, that glue is called the Root Store.
The root store is by no means required to use parts of LibX (Models, Collections), but it greatly simplifies writing applications.
Features of the root store:
- References all the other stores
- Only a single instance per session.
By using the root store pattern instead of singleton stores, you can do server-side rendering pretty easily.
Let's create our root store.
class RootStore {
constructor() {
// We're gonna need 3 stores.
// Each store wants a reference to the root store
// so they can talk to each other, as well as pass it
// along to our `Todo` and `User` instances.
this.todoStore = new TodoStore({ rootStore: this })
this.userStore = new UserStore({ rootStore: this })
// used for UI state
this.todosScreenStore = new TodosScreenStore({ rootStore: this })
}
}
// And we're ready!
const rootStore = new RootStore()
There's no rocket science there, so if you want to type less, you can use createRootStore
.
import { createRootStore } from 'libx'
// if using Server Side Rendering, do this once per request.
const rootStore = createRootStore({
todoStore: TodoStore,
userStore: UserStore,
// used for UI state
todosScreenStore: TodosScreenStore,
})
That was the root store. Now let's implement the TodoStore
.
Step 2: the TodoStore
and Todo
model
A Store in LibX is just a glorified state container. It can contain your data, UI state, whatever. However the most common case is to store a collection of models.
Let's implement the TodoStore
.
import { Store } from 'libx'
class TodoStore extends Store {
// This is the most powerful part of LibX: Models and Collections.
// Whenever a `Todo` model is created, the `rootStore` is passed to it.
todos = this.collection({
// Whenever we try to add stuff to this collection,
// transform it to a Todo model.
model: Todo,
})
// Fetch todos from our server.
fetchTodos() {
return (
fetch('/api/todos')
.then((r) => r.json())
// `set` is a MobX action, no need to wrap it.
// Also, it does not care about `this` context.
.then(this.todos.set)
)
}
addNewTodo(text) {
return fetch('/api/todos', {
method: 'post',
body: JSON.stringify({
text,
}),
})
.then((r) => r.json())
.then(this.todos.set) // resolves to a Todo model.
}
toggleCompleted(id, completed) {
return (
fetch(`/api/todos/${id}`, {
method: 'patch',
body: JSON.stringify({
completed,
}),
})
.then((r) => r.json())
// resolves to our existing Todo model, because
// when `set` is called, it recognizes the `id` and sets
// the new values on the existing todo. Magical!
.then(this.todos.set)
)
}
}
Notice that whenever we needed to update our todos state, we just
called this.todos.set(someObjectOrArray)
and LibX automagically adds and updates
the models.
Now to the Todo
model.
import { Model } from 'libx'
import { observable, computed } from 'mobx'
import moment from 'moment' // just to show off, hehe
class Todo extends Model {
@observable id
@observable text = ''
@observable completed = false
@observable creatorId = null // the ID of the user that created this todo
@observable createdAt
@computed
get creator() {
// References the creator by ID. If you change the `creatorId`
// this will automagically update.
return this.rootStore.userStore.users.referenceOne(this.creatorId)
}
parse(json) {
// Let's imagine the API response looks like
// {
// "id": 1,
// "text": "Buy milk"
// "completed": false,
// "createdAt": "2017-20-02T14:45:12Z",
// "creator": {
// "_id": "abcd",
// "name": "Jeff Hansen"
// }
// }
// We want to set the `creator` ourselves, so slice it out of
// the `json`.
const { creator, ...rest } = json
// Set the user in the user store, get a User (or undefined) back.
// When created through a Store collection, models have access
// to the Root Store.
this.rootStore.userStore.users.set(creator)
return {
// All fields except `creator`.
...rest,
// Assign the creator ID so we can look it up.
creatorId: creator._id,
// We want all our dates as `Moment`s.
createdAt: moment(json.createdAt),
}
}
}
We leveraged the fact that we can communicate with the user store, and
parse
is called whenever we try to set
some JSON in the collection.
Under the hood, the collection does this for new items:
// call the parse function before setting on the model
new Todo(data, { parse: true, rootStore: rootStore })
And for existing items:
// call the parse function before setting on the model
todo.set(data, { parse: true })
Step 3: the UserStore
and User
model
Same as the Todo store, except if you paid attention to the JSON example,
you might have noticed that users have an _id
attribute instead of the conventional id
.
This is not a problem at all, we just need to tell the collection what ID to look at.
import { Store } from 'libx'
class UserStore extends Store {
users = this.collection({
model: User,
idAttribute: '_id', // easy - to the pub!
})
}
Since we don't deal with any user API, that's all we need. But even if we did, it would follow the same formula as the Todo store.
On to the User
model!
import { Model } from 'libx'
class User extends Model {
@observable _id
@observable name
// Let's create a getter for the user's created todos.
// .. cause we can!
@computed
get todos() {
return this.rootStore.todoStore.todos.referenceMany(this._id, 'creatorId')
}
}
Since we don't do any fancy parsing, that's all we need!
Step 4: the UI
Like with MobX, you can use any UI library you want. I like React, so I'll use that.
Firstly, I want to implement the TodosScreenStore
- our UI state.
import { Store } from 'libx'
import { observable, action, computed } from 'mobx'
// Most of this is plain MobX.
class TodosScreenStore extends Store {
@observable loading = false
@observable filter = 'ALL'
@observable text = ''
@computed
get todos() {
// Stores have access to the root store.
const { todoStore } = this.rootStore
const { todos } = todoStore
// Fun fact: collections implement a few collection functions,
// like `filter`, `map` and more.
switch (this.filter) {
case 'COMPLETED':
return todos.filter((x) => x.completed)
case 'INCOMPLETE':
return todos.filter((x) => !x.completed)
default:
return todos.slice() // coerce to array
}
}
@action
setText(text) {
this.text = text
}
@action
setLoading(loading) {
this.loading = loading
}
@action
setFilter(filter) {
this.filter = filter
}
// Called by the UI whenever it wants to activate
// this state from scratch.
activate() {
this.setLoading(true)
this.setText('') // clear the text on activate.
this.rootStore.todoStore.fetchTodos().then(() => this.setLoading(false))
}
addTodo() {
const text = this.text
this.setText('') // clear the text
return this.rootStore.todoStore.addNewTodo(text)
}
toggle(todo) {
this.rootStore.todoStore.toggleCompleted(todo.id, !todo.completed)
}
}
That's it for the TodosScreenStore
- our UI state.
Now for the React part.
import React from 'react'
import { render } from 'react-dom'
import { observer } from 'mobx-react'
@observer
class TodosApp extends React.Component {
get store() {
return this.props.rootStore.todosScreenStore
}
componentWillMount() {
this.store.activate()
}
render() {
if (this.store.loading) {
return <div>Loading todos, please hold...</div>
}
return (
<div>
<input
type="text"
value={this.store.text}
placeholder="What needs to be done?"
onChange={(e) => this.store.setText(e.target.value)}
/>
<button onClick={() => this.store.addTodo()}>Add</button>
<ul>
{this.store.todos.map((todo) => (
<li key={todo.id} onClick={() => this.store.toggle(todo)}>
<p>
{todo.text}
{todo.completed && <span> (COMPLETED)</span>}
</p>
<p>- created by {todo.creator.name}</p>
</li>
))}
</ul>
<select
value={this.store.filter}
onChange={(e) => this.store.setFilter(e.target.value)}
>
<option value="ALL">All</option>
<option value="COMPLETED">Completed</option>
<option value="INCOMPLETE">Incomplete</option>
</select>
</div>
)
}
}
render(
// Get the root store reference from somewhere.
<TodosApp rootStore={rootStore} />,
document.body,
)
And that concludes our guide. All that's left is to slap some Stripe integration on it and make millions. You're welcome.
API documentation
This is documentation for the exported modules.
import { collection, Model, Store, createRootStore } from 'libx'
collection([opts])
Creates a collection with the given options. The collection ensures no duplicate objects are inserted based on an identification field and a bit of config.
The collection
function is used internally by Store.collection
(which provides
aforementioned config), and can be used with plain objects. If you wish to use it
stand-alone, go right ahead.
Params:
opts.idAttribute
- defaults to"id"
, the property used to determine whether to insert or update an item using the defaultgetModelId
andgetDataId
.opts.create
- function used to transform the input object to something else. Defaults to a function that just returns the input.- Signature:
(data, opts) => stuffToAddToCollection
, withopts
being the collection options.
- Signature:
opts.update
- function used to merge input onto an existing object. Defaults to(existing, input) => Object.assign(existing, input)
- Signature:
(existing, data, opts) => void
, withopts
being the collection options.
- Signature:
opts.getModelId
- function used to get the ID from an existing object in the collection. Defaults to(model) => model[idAttribute]
.opts.getDataId
- function used to get the ID from a raw object wanting to join the collection. Defaults to(data) => data[idAttribute]
.
Example:
// Example todo "model" not using LibX's Model class.
function todo(props) {
const self = observable({
text: '',
completed: false,
...props,
set: (data) => Object.assign(self, data),
toggle: () => {
self.completed = !self.completed
},
})
return self
}
const todos = collection({
create: todo,
update: (existing, data) => existing.set(data),
})
// Add an item
const todo1 = todos.set({
// id = the magic sauce that makes it work
id: 1,
text: 'Install LibX',
completed: false,
})
// Add the same item again, just updates the current one
const todo1Instance2 = todos.set({
id: 1,
text: 'Install LibX and follow @jeffijoe on Twitter',
})
console.log(todo1 === todo1Instance2) // true
console.log(todo1.text) // "Install LibX and follow @jeffijoe on Twitter"
console.log(todo1.completed) // false
todo1Instance2.toggle() // it's the same instance!
console.log(todo1.completed) // true
// Add multiple items
todos.set([
{
id: 1,
},
{
id: 2,
text: 'Build a great app',
},
{
id: 3,
text: 'Profit',
},
])
console.log(todos.length) // 3, because the first was updated
Collection object
The object returned from collection()
has the following properties and functions.
Note: All functions accepting an opts
will have the collection's options merged into them when calling functions like create
, update
, getModelId
and getDataId
.
collection.items
A MobX observable array of items.
collection.length
Getter that returns the length of the items
array.
collection.add(models)
Adds one or more models to the end of collection (does not call create
). No updating is
done here, existing models (based on referential equality) are not added again.
Supports 2 variants: add(model)
and add([model1, model2])
.
Returns: the collection.
collection.create(data, [opts])
Like set
, but will add regardless of whether an id is present or not.
This has the added risk of resulting multiple model instances if you don't make sure
to update the existing model once you do have an id. The model id is what makes the whole
one-instance-per-entity work.
Supports 2 variants: create(obj)
and create([obj1, obj2])
.
collection.get(id)
Gets items by ids. Supports 2 variants:
collection.get(id)
- returns the found model, or undefined.collection.get([id1, id2])
- returns an array of found models. If a model isn't found, it is set toundefined
in the result array (as in[model1, undefined, model3]
).
Internally uses getModelId
.
collection.set(data, [opts])
Given an object or an array of objects, intelligently adds or updates models.
If a model representing the given input exists in the collection
(based on getDataId
and getModelId
), the update
is called. If not, the
create
function is called and the result is added to the internal items
array.
Returns: the added/existing model(s), same style as collection.get
.
collection.clear()
Clears the internal items
array.
collection.remove(modelOrId)
Removes a model based on ID or the model itself.
collection.move(fromIndex, toIndex)
Moves an item from one index to another. Delegates to the inner Observable Array's move
function.
Returns: the collection.
collection.referenceOne(ids, field?)
Given a single or list of ids and a collection with models, returns the model(s) the IDs represent.
If field
is specified, it will be used instead of the source collection model ID.
Only the first matching model per ID is returned.
For "one/many-to-many" type references, use referenceMany
.
Returns: the found item or null when called with a single ID, or an array when called with multiple.
Example: bi-directional relationships using referenceOne
and referenceMany
.
class Member {
@observable name = null
@observable houseId = null
@computed
get house() {
return families.referenceOne(this.houseId)
}
}
class House {
@observable name = null
@observable words = null
@computed
get members() {
return members.referenceMany(this.id, 'houseId')
}
}
const houses = collection({
model: House,
})
houses.set([
{
id: 'lannister',
name: 'House Lannister',
words: 'A Lannister Always Pays His Debts',
},
{ id: 'stark', name: 'House Stark', words: 'Winter Is Coming' },
])
const members = collection({
model: Member,
})
members.set([
{ id: 1, name: 'Tyrion Lannister', houseId: 'lannister' },
{ id: 2, name: 'Jamie Lannister', houseId: 'lannister' },
{ id: 3, name: 'Eddard Stark', houseId: 'stark' },
])
const lannister = houses.get('lannister')
const stark = houses.get('lannister')
const tyrion = members.get(1)
const jamie = members.get(2)
const eddard = members.get(3)
expect(tyrion.house).toBe(lannister)
expect(jamie.house).toBe(lannister)
expect(eddard.house).toBe(stark)
expect(lannister.members).toContain(tyrion)
expect(lannister.members).toContain(jamie)
expect(lannister.members).not.toContain(eddard)
collection.referenceMany(ids, field)
Given a single or list of ids and a collection with models, returns the models that match field
.
All matching models are returned and flattened.
For "one-to-one" type references, use referenceOne
.
Returns: an array with found items.
The Model
class
If you don't mind using ES6 classes, extend from this. It provides some nice things, such as...
constructor (attributes, opts)
Calls this.set
with the attributes and options, and also sets the rootStore
on the model if it was passed in.
Params:
attributes
- an object that will get assigned onto the model using ´set`.opts
- model options. These are passed to the initialset
as well.opts.parse
- if true, callsthis.parse(attributes, opts)
and assigns the result to the model.opts.stripUndefined
- if true, strips out any undefined values before assigning to the model.opts.rootStore
- if set, will be assigned to the model.
.rootStore
A convenient reference to the root store (if it was passed to the constructor opts).
.set (attributes, opts)
Assigns the attributes to the model instance.
If opts.parse
is true
, calls this.parse(attributes, opts)
and assigns the result onto the object.
If opts.stripUndefined
is true
, removes all undefined values from the result.
Params: same as constructor
.
Returns: the model instance.
Example: transform data before assignment using parse
class Todo extends Model {
parse(attributes, opts) {
return {
...attributes,
completedAt: moment(attributes.completedAt),
}
}
}
const todo = new Todo({ text: 'Install LibX' })
todo.set(
{
completed: true,
completedAt: '2017-02-29T12:00:00Z',
},
{
parse: true,
},
)
Example: strip out undefined values
const todo = new Todo({
text: 'Install LibX',
})
todo.set(
{
text: undefined,
},
{
stripUndefined: true,
},
)
console.log(todo.text) // "Install LibX"
.parse (attributes, opts)
Called by set
when parse: true
is passed to it. Gives the model a chance to massage the data into something it wants to work it. Commonly used to transform embedded data (denormalized) into references (normalized).
The parse
function is responsible for 2 things:
- Update any other data stores in case of embedded data
- Return props to be merged onto the new/existing in-memory model instance.
Example: normalization
Imagine there being a root store + a user store.
class Todo extends Model {
parse(attributes, opts) {
// The attributes has a `creator` object, we want to normalize it.
const creator = this.rootStore.userStore.users.set(attributes.creator)
return {
...attributes,
creator,
}
}
}
const todo = new Todo(
{
text: 'Install MobX',
creator: {
id: 1,
name: 'Jeff Hansen',
},
},
{
parse: true,
},
)
Parent -> Child -> Parent parsing
Let's say you have the following input JSON:
{
"id": "post123",
"title": "Upgrading your JS Life",
"author": "Abraham Lincoln",
"category": {
"id": "category123",
"name": "Developer Tips",
"latestPost": {
"id": "post123",
"title": "Upgrading your JS Life"
}
}
}
And a set of models and stores for those 2 entities (stores left out for brevity):
class Post extends Model {
parse({ category, ...json }) {
return {
...json,
category: this.rootStore.categoryStore.categories.set(category),
}
}
}
class Category extends Model {
parse({ latestPost, ...json }) {
return {
...json,
latestPost: this.rootStore.postStore.posts.set(latestPost),
}
}
}
In LibX 0.1.x, this would wrongfully result in 2 Post
instances, because:
- Check if we have a post with id
post123
- We don't, parse the data into a new
Post
instance- Check if we have a category with id
category123
- We don't, parse the data into a new
Category
instance- Check if we have a post with id
post123
- We don't, parse the data into a new
Post
instance - Add the created
Post
to the collection
- We don't, parse the data into a new
- Check if we have a post with id
- Add the created
Category
to the collection
- We don't, parse the data into a new
- Check if we have a category with id
- Add the created
Post
to the collection
- We don't, parse the data into a new
As of version 0.2.0, parsing a 3+ level deep parent->child->parent structure no longer results in duplicate models. This works by checking the collection after parsing to see if a model with the same ID was added to the collection. If it was, parse the data again but while updating the existing model.
The Store
class
If you don't mind using ES6 classes, you'll get a lot by using the Store
as a base class for your stores:
- it sets a reference to the
rootStore
- it has a nice
collection()
function.
store.collection(opts)
Creates a collection
, but configured for use with
a Model
. It also passes in the store's rootStore
reference to any created models.
Params:
opts
- options passed tocollection(opts)
opts.model
- class to instantiate for new modelsopts.stripUndefined
- default is nowtrue
opts.parse
- default is nowtrue
Returns: a collection.
Example:
class TodoStore extends Store {
// ES7 property initializer
todos = this.collection({
model: Todo,
stripUndefined: false,
})
}
const root = { awesome: true }
const store = new TodoStore({ rootStore: root })
const todo = store.todos.set({ id: 1, text: 'Install LibX' })
console.log(todo instanceof Todo) // true
console.log(todo.rootStore.awesome) // true
createRootStore(obj)
Easiest way to create a simple root store. A root store, as described earlier, isn't that magical; it's just an object that holds references to all other stores.
Params:
obj
- example:{ userStore: UserStore }
Returns: the root store object.
Example:
class UserStore extends Store { ... }
class TodoStore extends Store { ... }
const rootStore = createRootStore({
userStore: UserStore,
todoStore: TodoStore
})
console.log(rootStore.userStore instanceof UserStore) // true
Projects using LibX
- Posish (GitHub repo) — a tool to generate code based on string positions
Are you using LibX in your project and want it listed here? Submit a PR! :rocket:
See Also
Author
Jeff Hansen - @Jeffijoe