As we know that model instances are designed to be immutable, which means every time when something changes, a new model version would be created. When we have everything immutable, you can live with the assumption that "the data we read would never be changed by careless others", which is amazing.
But sometimes the reality is skinny. Much of existing infrastructure were built in mutable way, and in some cases, in order to work with existing infrastructure, you need the ability to deal with mutable data.
One typical example is setInterval
, which returns an intervalId
, which later could be passed to clearInterval
to stop the interval callback from running again.
In this scenario, you need to store the intervalId
somewhere inside the reachable context for a model instance.
One possible solution is to define a prop named intervalId
and dispatch an action to set the value after setInterval
.
A drawback of this design is that intervalId
is an implementation detail, while model prop could be considered as a public interface to users of the model, because they can directly touch the prop value, but for intervalId
it would be quite confusing for users to see such a prop.
The situation would be even worse in some cases like subscribe
function, usually returns a unsubscribe
function, which could be called later to stop the subscription. The above solution doesn't make any sense in this scenario, as a violation to our eslint rule, it's ridiculous to set a unsubscribe
function prop to a model!
With these examples we realized that having some tools to deal with mutable data would still be necessary, so comes the model services
concept.
Let's start from a piece of code which describes a interval service:
import { createService, createModel } from 'modulajs';
const Model = createModel({
services: {
pollMessages: function createService(getModel) {
// store some state in the closure
let intervalId = null;
let pollCounter = 0;
return {
// this hook will be called when model is added to the model tree
modelDidMount() {
intervalId =
setInterval(() => {
fetchMessages().
then(messages => {
const model = getModel();
model.sendMessagesUpdate(messages);
});
pollCounter = pollCounter + 1;
}, 1000);
},
// this hook will be called when model is removed from the tree
modelWillUnmount() {
if (intervalId !== null) {
clearInterval(intervalId);
}
},
getCounter() {
return pollCounter;
}
};
};
},
sendMessagesUpdate() {
this.getService('pollMessages').getCounter(); // access service methods
this.getService('pollMessages').modelDidMount; // life cycle method can also be accessed, it's useful for testing
}
});
The example above describes a service called pollMessages
, which will call setInterval
when the model is mounted to the tree and clearInterval
with the saved intervalId
while the model is unmounted from the tree.
The service definition for pollMessages
is a function named as createService
, which will be called during model initialization, with a function parameter named getData
in this case. The getData
function when called returns (the latest version of) the model. The object returns by the createService
becomes a service instance, and can be retrieved by calling model.getService('pollMessages')
.
The service instance can define life cycle hooks that has exactly the same signature of model life cycle hooks, including modelDidMount
, modelWillUnmount
and modelDidUpdate
. ModulaJS will be in charge of calling them at right timing. Similar to getCounter
method, those life cycle hooks such as modelDidMount
can also be retrieved directly from the service instance, which will help us to test the service itself (only if the service is anonymous, it's recommended to extract the createService
function out, and we can easily test that function, we'll explain more in the next 'Reusable model services' section).
const service = model.getService('pollMessages');
const sendMessagesUpdate = sinon.stub(model, 'sendMessagesUpdate');
service.modelDidMount();
const clock = sinon.useFakeTimers();
clock.tick(2999);
expect(sendMessagesUpdate.calledTwice).to.be.true;
The beauty of the example service is that it utilize the closure within the createService
to store the intervalId and pollCounter, so the implementation detail is well isolated and could not be accessed directly by model.
Given that usually a service doesn't need to bound to a specific model, it's recommended to extract the service, test it individually, and even export it as a common stuff to be shared by many models, for example the same pollMessages service as above but reuses a common intervalService
:
// interval_service.js
import { createService } from 'modulajs';
export default function intervalService(interval, callback) {
return function createService(getModel) {
let intervalId = null;
let pollCounter = 0;
return {
modelDidMount() {
intervalId =
setInterval(() => {
callback(getModel());
pollCounter = pollCounter + 1;
}, 1000);
},
modelWillUnmount() {
if (intervalId !== null) {
clearInterval(intervalId);
}
},
getCounter() {
return pollCounter;
}
};
};
}
// model.js
import { intervalService } from 'interval_service';
const Model = createModel({
services: {
pollMessages: intervalService(1000, (model) => {
fetchMessages().then(model.sendMessagesUpdate);
})
}
});
One interesting fact for a model tree is that it should be possible to be serialized as a big object, and later be deserialized given the same object.
We can imagine that, existing model life cycle hooks should be skipped when doing the deserialization, to prevent some life cycle hooks from loading new data from server and overriding the restored state.
But the life cycle of services are different stories. The service life cycle should always be called even it's deserializing data, or the service cannot function properly (like the interval service).
It's very useful to take this "serializability" and "deserializability" into account when designing a new service, so that we would be more careful and can spend some time making sure the service would still function properly after the deserialization.