mediamonks / muban

A backend-agnostic framework to enhance server-rendered HTML using a modern webpack development setup.
https://mediamonks.github.io/muban/
39 stars 15 forks source link

Global State Management #27

Open ThaNarie opened 6 years ago

ThaNarie commented 6 years ago

This is a two-part issue, since this needs to be solved for two situations:

  1. Normal Muban projects that only have .hbs components
  2. SPA-like Muban sections where we have an app-in-site that uses the normal knockout components/templates, and works fully client-side

1. Normal Muban

In this case it really depends on how much global state we need.

Even if state only exists on the current page, it might still cross component boundaries; updates to data/state in one component should also reflect in other components. When this is required, you probably want to have some global state management in place.

2. SPA Muban

In some cases, one page of the website is a complex tool that only lives on the client. The backend might provide an initial data set (embedded or via API), and all logic happens on the client. Such a tool can consist of multiple virtual pages, and can become quite large.

The best solution depends on how much data/communication is needed between components.

Options

Singleton Model

The simplest option would be to have a Modal class that exports itself as a single instance. It can be required in all components, where they can read and write data.

The class would consist of multiple observables, so components can subscribe to them to receive updates.

Model:

class Model {
  constructor() {
    this.foo = ko.observable('bar');
  }
}

const model = new Model();
export default model;

ViewModel:

import model from '../data/model';

class Foo {
  constructor() {
    model.foo.subscribe(value => {
      console.log('updated: ', value);
    }
  }
}

However, when dealing with large (external) datasets, updating and detecting changes in deeply nested objects can become quite cumbersome. Other libraries like mobX and Vue deal with this in a way that in userland you working with normal objects, but in the background they are able to detect changes to them. This feature make those options more friendly to users. Unfortunately, knockout observables don't have this option.

Pros:

Cons:

Redux

By choosing redux as the global state container, and creating an interface to map the redux state to observables in components, we can make it easier to work with data.

I created a proof-of-concept function called knockout-redux-connect, where the interface is based on the connect function of react-redux.

When having the following component:

<link rel="stylesheet" href="./todos.scss">
<script src="./Todos.ts"></script>

<ul data-bind="foreach: params.todos">
  <li data-bind="text: $data.text"></li>
</ul>

<form>
  <input type="text" data-bind="value: newTodo" />
  <button type="submit" data-bind="click: addTodo">Add</button>
</form>

And the following class:

class Todos {
  private params: any;
  public newTodo = ko.observable();

  constructor(params) {
    this.params = params;
  }

  addTodo = () => {
    this.params.addTodo(this.newTodo());
    this.newTodo('');
  };
}

You can connect to redux like this:

const mapStateToParams = state => ({
  todos: state.todo.todos,
});
const mapDispatchToParams = dispatch => ({
  addTodo: (...args) => dispatch(addTodo(...args)),
});

export default connect(mapStateToParams, mapDispatchToParams)(Todos);

The params object is already used by Knockout components to receive data from a parent component. The connect function sits in between, and merges the object returned from its functions to pass it to the component.

When doing this (the first time), it converts the returned state object to observables by using the ko.mapping plugin. When the redux state changes at a later time, it uses that function again to updated the stored observables.

Using this method the component can use/subscribe the received observable params the same way as if they were passed from the parent component.

Actions work the same way as in redux, they can called from the component. Optionally we could unwrap the passed payload so it doesn't contain any observables before it's dispatched.

The normal handlebars components could also be updated to support this util.

Pros:

Cons:

Custom Stores

The ko.mapping plugin could also be used to create a custom store, similar to it's done in mobx/vue.

Vuex uses actions and mutation to update data in the store. These could be converted to observables after execution. It also has a concept of getters, which are like knockout computes, or redux selectors.

MobX uses uses actions as a context where all updates to the observables in the store are batched.

Pros:

Cons:


Feedback is much appreciated! @flut1 (you have knockout and redux experience) Edgar (you have knockout, redux and mobX experience) @larsvanbraam, @mmricco, @ReneDrie (you have knockout and Vue experience)

skulptur commented 4 years ago

Are we still considering this? The singleton models are very simple to use, anything more advanced can be decided per project. And if that is a common need then it should be a lib IMO.

ThaNarie commented 4 years ago

This is 2,5 years old, so need to re-check if and how we still need it, and if the solutions still make sense, or if there are new options.

I hope that you and others who did more with muban have some insights/experience on what's needed/missing in this area.

pigeonfresh commented 4 years ago

I'm using the singleton model for most of my projects. In my cases most of the use-cases are fairly simple or simple enough to not have to use a different method.