Mobx powered library inspired by the best parts of Backbone.js and Ember.js
It started as an experiment in using domain-driven design and separation of concerns in the frontend. The idea was to separate the Model, Model Collection, and Persistence (Transport) into three distinct parts.
- Model should be an object that mostly just carries data
- Collection should implement business logic, and act as aggregation root for the Model
- Transport should only be concerned with persisting data.
After some trial and error, I've ended up with a library that I think, satisfies these concerns.
The Collection
part of the library is inspired by Backbone.js while the Model
part of the library is inspired by Ember.js
Mobx is used for the reactivity so we don't concern ourselves with integrating the library with different frontend frameworks.
This abstraction on top of the Mobx will cost you an additional ~4.3KB, and I think it's worth it.
npm i @fuerte/core
To showcase the basic usage and capabilities of the library, we are going to create a simple todo app.
- Todo
Model
(todo item) - Model collection (
Collection
of todo items) - Factory function for creating new models
- Transport for persisting the models ( local storage, REST API, etc..)
First, we will create a Model
.
Models usually carry the data, but they can also have their methods to manipulate the data or perform other tasks.
The model
will have an id
, done
, and a task
properties.
Every model
needs to have a serialize
method which will be used to serialize the model to storage, and track if the model is dirty (has changed properties).
import {Model} from '@fuerte/core`
import { makeObservable, action, observable } from 'mobx'
class Todo extends Model {
public done: boolean
constructor(public task: string, public id?: number) {
super()
this.done = false
makeObservable(this, {
task: observable,
done: observable,
setTask: action,
})
}
//data to be saved when the model is persisted
serialize() {
return {
id: this.id,
task: this.task,
done: this.done,
}
}
setTask(task: string) {
this.task = task
}
}
Next, we need a model factory
. This is a function whose purpose is to create the new Model
instance, the function can be asynchronous.
import type { FactoryFn } from '@fuerte/core'
export type ModelDTO = { task: string; id?: number }
const modelFactory: FactoryFn = (data: ModelDTO) => {
return new Todo(data.task, data.id ?? Math.random())
}
Next, we create the transport layer. The transport layer is used for transporting (persisting) the data. Usually, the transport layer will use fetch
, localStorage
, or IndexedDB
. In this simple example, we are going to save the data in memory.
Transport class needs to implement Transport
interface, which requires three methods:
load
: loads the data from somewhere, this is usually used when the app first starts. This method does not create the model instances, it just returns the raw data (ModelDTO
in the model section) that will later be used by the factory function to construct the models.save
: for saving or updating the modeldelete
: for deleting the model
import type { Transport } from '@fuerte/core'
//sample data
const firstTodo = { id: 1, task: 'Remember the milk' }
const secondTodo = { id: 2, task: 'Return books to the library' }
//simple in memory storage
const storage: Map<string, ModelDTO> = new Map()
//populate the storage
storage.set(firstTodo.id, firstTodo)
storage.set(secondTodo.id, secondTodo)
//transport implementation
export class MemoryTransport implements Transport {
load(): Promise<{ data: ModelDTO[] }> {
// return everything from the storage
return Promise.resolve({ data: [...storage.values()] })
}
save(model: Todo) {
storage.set(model.id, model.payload)
}
delete(model: Todo) {
storage.delete(model.id)
}
}
One final missing piece and also the most important one is the Collection
.
Business logic should generally be set on the collection. You can think of the collection as the Mobx store
.
To construct the Collection
class instance we need both the factory
and the transport
.
import { Collection } from '@fuerte/core'
import { makeObservable computed } from 'mobx'
import {modelFactory} from './modelFactory'
import {MemoryTransport} from './memoryTransport'
class TodoCollection extends Collection<
Todo,
typeof modelFactory,
MemoryTransport
> {
constructor(factory: typeof modelFactory, transport: MemoryTransport) {
super(factory, transport)
makeObservable(this, {
doneTasks: computed
})
}
get doneTasks() {
return this.models.filter((model) => model.done === true)
}
}
Now that we have all the pieces that we need. We can build our Todo app.
import { modelFactory } from './modelFactory'
import { MemoryTransport } from './memoryTransport'
import { TodoCollection } from './todoCollection'
// create the collection instance
const collection = new TodoCollection(modelFactory, MemoryTransport)
//load all the models (calls MemoryTransport.load under the hood)
const loadResult = await collection.load()
//get first task from the collection
const todoOne = collection.models[0]
todoOne.task // 'remember the milk'
todoOne.isDirty // false (model hasn't changed)
todoOne.setTask('remember the icecream instead')
todoOne.isDirty //true (model.task has changed)
//save the model to the storage (MemoryTransport)
const result = await todoOne.save()
//"done" tasks
collection.doneTasks // []
todoOne.done = true
todoOne.isDirty // true - model is dirty again
collection.doneTasks // [todoOne]
//Let's create a completely new todo
const newTodo = collection.create({ id: '3', task: 'buy cat food' })
newTodo.isNew //true
newTodo.isDirty // false Note new models are not dirty!
//just add the model to the collection without saving it (Transport.save is not called)
collection.add(newTodo)
//save the model (this will not add the model a second time, it will just save it via Transport.save)
await collection.save(newTodo)
And that is the gist of it. It's important to note that both the collection
and the model
have properties that are reactive (via Mobx) so you can use them directly in your React components, and the components will be rendered when the data is changed. All standard Mobx rules apply.
Some of the react properties are
model.isSaving // true - if the model is in the process of saving
model.isSyncing //true - if model is saving or deleting
collection.isSyncing // true - the collection has models that are currently saving or deleting
collection.saving // [model] - returns all the models that are currently in the process of saving
collection.syncing //[model] - returns all the models that are currently deleting or saving
model.isDirty // when model data differs from the last saved data
All these properties are documented at the Model properties and Collection properties sections of the readme.
Model is generally used to carry data, although there is no reason not to carry its own logic in form of custom methods.
The two most important pieces of the Model class are the serialize
method and the indentityKey
static property.
serialize
method is used to determine what properties of the object will be used by the transport for saving the object and for determining if the model isDirty
(has changed properties). This method must be implemented by every class that extends the Model
class.
collection. Identity key
should be heavily used by the transport
layer, for persisting the models.
indentityKey
is a static property that holds the name
of the property that will be unique for every model. This unique property is used by the collection to check if the model with the same value is already present, remember Collection
can only have models with unique values for identityKey
.
Transport
layer can also use this key to persist data. In case you have some kind of transport that adheres to REST principles the transport can then construct API endpoints by utilizing the identityKey
.
For example:
POST /todos/${todo.identity}
DELETE /todos/${todo.identity}
Check out the Fetch transport recepie
In the next example, Book
model has the isbn
property set as the indentityKey
class Book extends Model {
static indentityKey = 'isbn'
constructor(public isbn: string) {}
}
const book = new Book('123')
collection.add(book)
collection.getByIdentity(book.isbn) // book
book.getIdentityKey() // isbn
book.identity // 123
All model getter properties are reactive (via Mobx). And they reflect the state of the model.
isSaving
: true when the model is in the process of saving.isDeleting
: true when the model is in the process of deleting.isSyncing
: true if the model is either saving or deleting.isDeleted
: if the model is deleted by transport.isDestroyed
: true if the modeldestroy
method has been called.identity
: model identity value.indentityKey
: model identity key.cid
: model client identity (used internally by collection).payload
: data that is returned by theserialize
method. The transport layer should use this property to save the model.isDirty
: true when the current model data is different than the last model data that has been saved.saveError
: error when theTransport.save
method fails to save the model.deleteError
: errorTransport.delete
method fails to delete the model.hasErrors
: true if eithersaveError
ordeleteError
is truthy.lastSavedData
: last successfully saved data.
setIdentity(newValue:string)
: set new identity value (indentityKey
value will be changed)setIsNew(isNew:boolean)
: SetisNew
property on the model. This method should generally not be used by the client code. The transport layer can check this property to determine if it should usePOST
orPATCH
methods for persistencegetCollection
- returns theCollection
that the model is part of. This could be undefined if the model is created but not yet added to the collection.destroy()
: stops model internal processes. This method should be used when you want to completely remove the model from the app and release the memory used by the model.
The model supports various callbacks in the form of methods on a class. It's important to note that you should not call super[method name]
on any of the callbacks. They are all fire and forget.
import type {
ModelDeleteErrorCallback,
ModelDeleteStartCallback,
ModelDeleteSuccessCallback,
ModelSaveErrorCallback,
ModelSaveStartCallback,
ModelSaveSuccessCallback
} from '@fuerte/core'
import { TodoCollection } from './TodoCollection'
import { TodoTransport } from './TodoTransport'
export class TodoModel extends Model<TodoCollection> {
// called when the model is added to the collection.
override onAdded(collection: TodoCollection): void {}
// called when the model is removed from the collection
override onRemoved(collection: TodoCollection): void {}
// called when model is about to be saved by the collection
override onSaveStart(data: ModelSaveStartCallback<TodoTransport>): void {}
//called when model is successfully saved by the collection
override onSaveSuccess(data: ModelSaveSuccessCallback<TodoTransport>): void {}
//called when collection has failed to save the model
override onSaveError(
data: ModelSaveErrorCallback<TodoModel, TodoTransport>
): void {}
// called when collection is about to delete the model
override onDeleteStart(data: ModelDeleteStartCallback<TodoTransport>): void {}
// called when collection has successfully deleted the model
override onDeleteSuccess(
data: ModelDeleteSuccessCallback<TodoTransport>
): void {}
// called when collecton has failed to delete the model
override onDeleteError(data: ModelDeleteErrorCallback<TodoTransport>): void {}
// called when the model is destroyed. `Model.destroy() method has been called
override onDestroy(): void {}
}
For more info check out the Model API docs
Collection class as the name suggests is used for collecting (manipulating) the models and acting as an aggregation root for the models. It has methods like add
, remove
, save
etc...
Business logic that concerns the models in the collection should generally be set on the collection. You can think of the collection as the Mobx store.
The collection constructor has three dependencies.
- factory: used for creating the models
- transport: used for persisting the models
- configuration: an optional configuration that determines the default behavior for the collection
import { Collection } from '@fuerte/core'
import { todoFactory } from './todoFactory'
import { TodoModel } from './TodoModel'
import { TodoTransport } from './TodoTransport'
export class TodoCollection extends Collection<
TodoModel,
typeof todoFactory,
TodoTransport
> {}
const todoCollection = new TodoCollection(todoFactory, new TodoTransport())
//create uses the factory function internally
const newTodo = todoCollection.create({ task: 'Buy milk' })
//save uses the transport class internally
todoCollection.save(newTodo)
There are a few key concepts for working with the Collection
.
When you first start your app, the collection will probably be empty. You need a way to immediately populate the collection when the app starts. You can use the Collection.load
method for that.
When you call the Collection.load
method, under the hood it will call the Transport.load
method.
Transport.load
method should return raw model data (that will be directly passed to the modelFactory
function), then that data is iterated over and passed to the modelFactory
function, which in turn creates Model
instances.
Collection.load
method also returns a Promise that resolves to all of the models that were created and added.
const todoCollection = new TodoCollection(todoFactory, new TodoTransport())
const result = await todoCollection.load()
//result.added - new models
In the case of calling load
multiple times and loading the same model data (models with the same value for the indentityKey
property), there will be an issue when the model with the same value is already present in the collection and you will need to decide what to do with that model when that happens, you can keep the old model, keep the new model, or keep both.
import { DuplicateModelStrategy, ModelCompareResult } from '@fuerte/core'
collection.load({
//keep the new models
duplicateModelStrategy: DuplicateModelStrategy.KEEP_NEW
})
collection.load({
//keep the model that is already present in the collection
duplicateModelStrategy: DuplicateModelStrategy.KEEP_OLD
})
collection.load({
// compare new and old model and then decide
duplicateModelStrategy: DuplicateModelStrategy.COMPARE,
compareFn: (newModel: TodoModel, oldModel: TodoModel) => {
//here you have access to the new and old models
// keep the new model
return ModelCompareResult.KEEP_NEW
// keep the old model
return ModelCompareResult.KEEP_OLD
// keep both models!
return ModelCompareResult.KEEP_BOTH
}
})
In the case of the ModelCompareResult.KEEP_BOTH
you need to make sure to change the identity of one of the models, otherwise Collection.load
will throw an error. As mentioned earlier, there can't be two models in the collection with the same value for identity
property.
There is also an option to empty the collection (reset) before adding new models. After the load
method completes, only newly loaded models will be present in the collection.
const result = todoCollection.load({ reset: true })
Any time in the lifecycle of the app you can empty the collection (reset) and optionally add new models by providing data for the factory.
//empty the collection
collection.reset()
//empty the collection and add new models
collection.reset([{ task: 'Buy crypto' }, { task: 'by cinema tickets' }])
There are various callbacks in form of methods on a class. It it's important to note that you should not call super[method name]
on any of the callbacks. They are all fire and forget.
import {
DeleteErrorCallback,
DeleteStartCallback,
DeleteSuccessCallback,
FactoryData,
LoadErrorCallback,
LoadStartCallback,
LoadSuccessCallback,
SaveErrorCallback,
SaveStartCallback,
SaveSuccessCallback
Collection
} from '@fuerte/core'
import { todoFactory } from './todoFactory'
import { TodoModel } from './TodoModel'
import { TodoTransport } from './TodoTransport'
export class TestCollection extends Collection<
TodoModel,
typeof todoFactory,
TodoTransport
> {
//called when collecton is reset
override onReset(
added: TodoModel[],
removed: TodoModel[],
fromLoad = false
): void {}
//called when the model is removed from the collection
override onRemoved(model: TodoModel): void {}
//called when the model is added to the collection
override onAdded(model: TodoModel): void {}
//called when model save process is about to start
override onSaveStart(
data: SaveStartCallback<TodoModel, TodoTransport>
): void {}
//called when the model is saved successfully
override onSaveSuccess(
data: SaveSuccessCallback<TodoModel, TodoTransport>
): void {}
//called when model save process fails
override onSaveError(
data: SaveErrorCallback<TodoModel, TodoTransport>
): void {}
//called when model delete process starts
override onDeleteStart(
data: DeleteStartCallback<TodoModel, TodoTransport>
): void {}
//called when model delete process completes successfully
override onDeleteSuccess(
data: DeleteSuccessCallback<TodoModel, TodoTransport>
): void {}
//called when model delete process fails
override onDeleteError(
data: DeleteErrorCallback<TodoModel, TodoTransport>
): void {}
//called when collection load process starts
override onLoadStart(
data: LoadStartCallback<TodoModel, TodoTransport>
): void {}
//called when collection load process completes successfully
override onLoadSuccess(
data: LoadSuccessCallback<TodoModel, TodoTransport>
): void {}
//called when collection load process fails
override onLoadError(
data: LoadErrorCallback<TodoModel, TodoTransport>
): void {}
/**
* called when `Collection.serialize` method executes
* If you want to add additional data to the serialization, return the data from the callback, and it will be
* added to the serialized object.
*/
override onSerialize():Record<string, any> | void {}
//called when collection `destroy` method is called
override onDestroy():void {}
/**
* Callback for when the collection is about to create a new model.
* This callback fires only on `Collection.reset` and `Collection.load` methods.
* It will not fire when the model is created via `Collection.create`
* Here, you can return modified data for model creation.
* If `undefined` is returned model creation will be skipped
*/
protected override onModelCreateData(
data: FactoryData<typeof todoFactory>
): void | {
foo?: string | undefined
bar?: string | undefined
id?: string | undefined
} {}
}
For more info check out the Collection API docs
Autosave collection inherits from the Collection
. Its main differentiator is that it can automatically save the model whenever the model payload
changes.
import { AutosaveCollection } from '@fuerte/core'
import { todoFactory } from './todoFactory'
import { TodoModel } from './TodoModel'
import { TodoTransport } from './TodoTransport'
const collection = new AutosaveCollection(todoFactory, new TodoTransport(), {
autoSave: {
enabled: true //immediately enable autosave
}
})
const model = collection.add(collection.create({ task: 'buy milk' }))
model.setTask('Buy orange juice') // autosave triggers automatically
You can enable or disable autosave only for certain models or for all of the collection at once.
collection.startAutosave(modelOne)
collection.startAutosave([modelTwo, modelThree])
collection.startAutosave() //start for all the models in the collection
collection.stopAutosave(modelOne)
collection.stopAutosave([modelTwo, modelThree])
collection.stopAutosave() //start for all the models in the collection
- In addition to all the callbacks of the
Collection
class,AutosaveCollection
has it's own callbacks.
import { AutosaveCollection } from '@fuerte/core'
import { testModelFactory } from './TodoFactory'
import { TestModel } from './TestModel'
import { TestTransport } from './TestTransport'
export class TodoAutosaveCollection extends AutosaveCollection<
TestModel,
typeof testModelFactory,
TestTransport
> {
/**
* Callback for when {@link AutosaveCollection.stopAutoSave} method has been executed.
* @param models - the array of models for which the auto-save process has been stopped.
*/
onStartAutoSave(models: TestModel[]): void {}
/**
* Callback for when {@link AutosaveCollection.startAutoSave} method is executed.
* @param models - the array of models for which the auto-save process has been started
*/
onStopAutoSave(models: TestModel[]): void {}
}
There is no Transport
class in @fuerte/core
there is only an Interface
that classes that want to act as transport
layer need to implement. The current transport interface consists of these methods:
export interface Transport<TModel extends Model = Model, TDTO = any> {
/**
* Loads the model data. This method should just return the data that the factory requires to construct
* the models.
* @param config - transport config
* @returns array of model data for model construction
*/
load(config?: any): Promise<{ data: TDTO[] }>
/**
* Saves the model
* @param model - model to save
* @param config - save the configuration
* @returns Object with optional "data" property to pass back to collection
*/
save(model: TModel, config?: any): Promise<{ data?: any } | void>
/**
* Deletes the model
* @param model - model to delete
* @param config - delete configuration
* @returns Object with optional "data" property to pass back to collection
*/
delete(model: TModel, config?: any): Promise<{ data?: any } | void>
}
This simplest transport, which saves the data in-memory could look like this:
import type { Transport } from '@fuerte/core'
//sample data
const firstTodo = { id: 1, task: 'Remember the milk' }
const secondTodo = { id: 2, task: 'Return books to library' }
//simple in memory storage
const storage: Map<string, ModelDTO> = new Map()
//populate the storage
storage.set(firstTodo.id, firstTodo)
storage.set(secondTodo.id, secondTodo)
export class MemoryTransport implements Transport {
load(): Promise<{ data: ModelDTO[] }> {
// return everything from the storage (load the collection)
return Promise.resolve({ data: [...storage.values()] })
}
save(model: Todo) {
storage.set(model.id, model.payload)
}
delete(model: Todo) {
storage.delete(model.id)
}
}
Head over to the recipes section to see more examples of Transport
implementations.
Sometimes you don't want to expose all the methods of the Collection
to the client code. Or do you want to have
method names that better reflect your business logic, so instead of using Collection.add
and Collection.create
you would like to have MyTodoStore.addTodo()
.
Composition is a perfect candidate for something like this.
In the next example, we are going to create a class that instead of inheriting from the Collection
will use the Collection
as its protected property.
import { todoFactory } from './TodoFactory'
import { TodoModel } from './TodoModel'
import { TodoTransport } from './TodoTransport'
import { FactoryData, Collection } from '@fuerte/core'
export class MyTodoStore {
protected collection = new Collection(todoFactory, new TodoTransport())
addTodo(data: FactoryData<typeof todoFactory>): TodoModel {
const todo = this.collection.create(data)
this.collection.add(todo)
return todo
}
get todos() {
return this.collection.models
}
}
Bonus:
If you are into Dependency injection (and you should be), then you can modify the previous example so that the MyTodoStore
class
accepts already created collections.
import { todoFactory } from './TodoFactory'
import { TodoModel } from './TodoModel'
import { TodoTransport } from './TodoTransport'
import { FactoryData, Collection, Transport } from '@fuerte/core'
type TodoFactory = typeof todoFactory
export class MyTodoStore {
constructor(
protected collection: Collection<TodoModel, TodoFactory, Transport>
) {}
}
This transport recipe uses fetch
to communicate with the restful API.
import type { Transport } from '@fuerte/core'
export class FetchTransport implements Transport {
async load(data?: { page?: string }): Promise<{ data: ModelDTO[] }> {
//maybe we have pagination enabled, check to see if we are using pagination
const query = data?.page ? `?query=${data.page}` : ''
const response = await fetch(`/some/api/todos${query}`)
const data = await response.json()
return data
}
async save(model: Todo) {
// if the model is new use POST otherwise use PUT
const method = model.isNew ? 'POST' : 'PUT'
const response = await fetch(`/some/api/todos/${model.identity}`, {
method,
headers: {
'Content-Type': 'application/json;charset=utf-8'
},
body: JSON.stringify(model)
})
}
async delete(model: Todo) {
const response = await fetch(`/some/api/todos/${model.identity}`, {
method: 'DELETE'
})
}
}