State
One of the common challenges in a isomorphic application is the consistency of data between server and client. After the first page view (rendered server side), the Mithril SPA takes the control of the page, mounting itself into the document body and replacing the existing DOM with the one it generates. The first two important requirements are:
- we want the real DOM generated by Mithril's virtual DOM to match the one rendered server-side, before mounting our
SPA in the
document.body
replacing it - we want to avoid duplicated data fetching (the first on the server and the same again on the client)
A way to solve both issues is to adopt a shared state between server and client, where to keep track of the fetched data and anything else that has already been elaborated server-side in the controller of the main component.
In our approach we chose to use a simple custom state manager (app/stateman.js
) to store and retrieve data. This
also comes useful to achieve communication across different components.
In server routes
We add our state manager to the required modules at the beginning of app/server/index.js
:
const stateManager = require('../stateman.js');
The Express routes need to be modified, passing down a new instance of the state at every request in the attrs
:
Object.keys(routes).forEach((route) => {
router.get(route, (req, res, next) => {
const module = routes[route];
const onmatch = module.onmatch || (() => module);
const render = module.render || (a => a);
// Istantiate a new state at every request
const stateman = Object.create(stateManager);
stateman.init();
const attrs = Object.assign({}, req.params, req.query, { stateman });
Promise.resolve()
.then(() => m(onmatch(attrs, req.url) || 'div', attrs))
.then(render)
.then(toHTML)
.then((html) => {
res.type('html').send(html);
})
.catch(next);
});
});
In components
We change our main components to use the state manager, which can be accessed via the vnode.state
property:
oninit: vnode => new Promise((resolve) => {
const stateman = vnode.attrs.stateman;
if (!stateman.get('home.content')) {
vnode.state.loading = true;
resources.getSection('index')
.then((content) => {
vnode.state.content = content;
stateman.set('home.content', content);
vnode.state.loading = false;
m.redraw();
resolve();
})
.catch((err) => {
vm.error = err;
m.redraw();
resolve();
});
} else {
vnode.state.content = stateman.get(statePrefix + '.content');
resolve();
}
})
Instead of calling the resources.getSection()
async fetch function directly as before, we first check if there's a hit
for the key home.content
in our state, using our state manager method stateman.get()
:
stateman.get('home.content')
Think it as a cache, where we avoid to fetch the data if we already have it. If the fetch is successful, we store
the fetched data with our state manager method stateman.set()
:
stateman.set('home.content', content)
This won't affect the execution of the oninit
on the server side because, as we said, we instance a new state at every
request, so we will always have to perform the fetch. It will instead let the client avoid skip the fetch when the data
is already available in the state, both if it comes from the server through the shared state or if it has already been
fetched before in the SPA.
In server layout
On the server side, once the Promise of the async oninit
of our main components is resolved, the state won't undergo
further changes. At that point it's ready to be shared with the client, passing the data to the SPA.
As previously anticipated in the Layout section, this line of code does the trick:
m('script', `window.__preloadedState = ${vnode.attrs.stateman._getString()}`),
We are using here the stateman._getString()
method of our custom state manager, which simply returns a stringified
version of our state's object. In this way we can inject it as a string into the HTML and make it readable as a script
by the SPA.
In client routes
As we did with the Express routes on the server side, we need to edit the Mithril routes on the client side to pass the state down to our main components. The difference here is that we are creating a single istance of the state at the init of our app to be used across the components:
const m = require('mithril');
const routes = require('./routes.js');
const stateManager = require('./stateman.js');
const resources = require('./resources.js');
const sharedState = window.__preloadedState || {};
// Get app state from server shared state
const stateman = Object.create(stateManager);
stateman.init(sharedState);
const clientRoutes = {};
const attrs = { stateman };
Object.keys(routes).forEach((route) => {
clientRoutes[route] = {
onmatch: (args, requestedPath) => routes[route].onmatch ? routes[route].onmatch(attrs, requestedPath) : routes[route],
render: (vnode) => {
Object.assign(vnode.attrs, attrs);
return vnode;
}
};
});
m.route.prefix('');
m.route(document.body, '/', clientRoutes);
The stateman.get()
and stateman.get()
methods, when called on the common state manager passed down via vnode.state
in our main components as seen before, can then be used as cache manager and also to implement cross component
communication.