July 31, 2019

#Simple State Management in Mithril.js

Mithril.js is a lightweight JavaScript framework that has become a staple in my development stack after I discovered it two years ago. At the time, I was looking for a simpler, zero-dependency alternative to React.js that could help me learn modern JavaScript UI development without needing to simultaneously learn and understand various build tools and framework plugins.

I've since learned React and have come to appreciate it for its influence on modern web development. However, I find that Mithril, a framework that sits at half the size of React whilst containing more features, has remained my go-to.

#Using simple variables

When it comes to state management, Mithril is as unopinionated as they come. You can use Redux, Mobx, Cerebral, some implementation of the SAM pattern, or best of all -- just a plain ol' JavaScript object! Mithril comes with a global, auto-redraw system. The virtual DOM created by Mithril will diff against and synchronize the DOM whenever changes are made to your data layer. Most commonly, the redraws are triggered after an event handler defined in your Mithril application is called. But you can also manually trigger a DOM update with m.redraw.

What this means in practice is that you are free to structure your data however you'd like, and Mithril takes care of the rest. Below is an example of a simple Counter application written with Mithril:

let count = 0;

const Counter = {
  view: () =>
    m('div',
      m('h1', 'Counter'),
      m('p', count),
      m('button', { onclick: () => count += 1 }, '+'),
      m('button', { onclick: () => count -= 1 }, '-')
    )
};

m.mount(document.body, Counter);

Live Example

Our state is just a single primitive variable! For small applications, simple widgets or one-off UI components, the above solution is largely sufficient. What's important about implementing your state management solution is to understand that there is no silver bullet. You will be able to predict your needs more accurately as you work across multiple projects and grow organically. Redux is a brilliant solution for modern UI state management, but the 9/10 times I have attempted to use it out of a desire to do things "the right way", it was absolute overkill. I advise reading this blog post by Dan Abramov, the creator of Redux.

#Using POJOs for state & actions

While the above solution is simple and likely sufficient for small use-cases, it introduces one problem - we are modifying the state directly from within the view. It won't take long before this approach proves unwieldy, and you're scanning your templates trying to find where you wrote the logic that is altering your state in (potentially) unpredictable ways.

We can introduce indirection and a more versatile state container using plain JavaScript objects. Our Counter component becomes more terse, yet more expressive:

const state = { count: 0 };

const actions = {
  increment: () => state.count += 1,
  decrement: () => state.count -= 1
};

const Counter = {
  view: () =>
    m('div',
      m('h1', 'Counter'),
      m('p', state.count),
      m('button', { onclick: actions.increment }, '+'),
      m('button', { onclick: actions.decrement }, '-')
    )
};

m.mount(document.body, Counter);

Live Example

#Using factory functions to reproduce state & actions

As your application grows in size, it might be preferable that your state and actions are easily testable and replicable from the beginning. Further, instead of relying on lexical scoping for your actions to have access to your state, we can use a combination of dependency injection and closures so that an instance of your actions will always directly reference a specific state object. We can easily achieve this with factory functions that provide your initial state and actions that directly reference a single state object.

const State = () => ({ count: 0 });

const Actions = state => ({
  increment: () => state.count += 1,
  decrement: () => state.count -= 1
});

From there, it is dead simple to reproduce your state and actions objects respectively:

const state   = State();
const actions = Actions(state);

Passing these to a Mithril component is trivial using the attrs property (near-equivalent to props in React) and object destructuring. Notice that our Counter component remains virtually unchanged:

const Counter = {
  view: ({ attrs: { state, actions } }) =>
    m('div',
      m('h1', 'Counter'),
      m('p', state.count),
      m('button', { onclick: actions.increment }, '+'),
      m('button', { onclick: actions.decrement }, '-')
    )
};

m.mount(document.body, {
  view: () => m(Counter, { state, actions })
});

Live Example

(P.S. Credit goes to porsager who shared this brilliant solution in the Mithril.js Gitter, nicknamed "Mitosis", named after the equally awesome Meiosis Pattern by foxdonut). This is my preferred approach to state management in Mithril. Passing your state and actions to child components would work as you'd expect. Simply pass your state and actions objects further down as attrs, or more wisely, be selective of what you choose to expose to child components.

#Factory functions with stateless components

You could also take an approach where your application is composed of solely stateless components. That is, every component is a pure, deterministic function. Hyperapp is a JavaScript framework that does not allow for local state in components. Instead, every component returns a portion of your UI that reflects the global state. While I highly recommend checking out Hyperapp (it's only 1kb gzipped!), this post is about Mithril, and you can use a similar approach with Mithril.

const State = () => ({ count: 0 });

const Actions = state => ({
  increment: () => state.count += 1,
  decrement: () => state.count -= 1
});

const Counter = (state, actions) =>
  m('div',
    m('h1', 'Counter'),
    m('p', state.count),
    m('button', { onclick: actions.increment }, '+'),
    m('button', { onclick: actions.decrement }, '-'),
    Child(state, actions)
  );

const Child = (state, actions) =>
  m('div',
    m('h2', 'Child'),
    m('p', state.count * 2),
    m('button', { onclick: actions.increment }, '+'),
    m('button', { onclick: actions.decrement }, '-'),
  );

m.mount(document.body, () => {
  const state   = State();
  const actions = Actions(state);

  return { view: () => Counter(state, actions) };
});

Live Example

#Conclusion

In the end, always do what feels right to you and makes more sense given your team and/or project. If this has been helpful or if you have any questions, drop me an email!