intercellular / cell

A self-driving web app framework
https://www.celljs.org
MIT License
1.5k stars 93 forks source link

State management recommendations? #151

Closed dargue3 closed 6 years ago

dargue3 commented 7 years ago

Hey Cells,

This isn't an issue, but I was wondering if we could have a discussion about state management when it comes to Cell. Perhaps I'm misunderstanding the intended architecture because of my daily work with React + Redux.

I set out to create a TodoMVC example using Cell (trying to race @devsnek 😄), and was really enjoying everything except for my total indecision about how to structure the state. The obvious place to start with this app is structuring something like this:

const TodoItem = (item) => {
  let classes;
  if (item.complete) classes = 'complete';
  return {
    $type: 'li',
    $text: item.name,
    class: classes
  }
}

var TodoList = {
  $cell: true,
  $type: 'ul',
  _items: [{ name: 'Learn Cell', complete: false, id: 42 }],
  $components: [],
  _add: function(todo) {
    this._items.push(todo);
  },
  $init: function() {
    this.$update();
  },
  $update: function() {
    this.$components = this._items.map(TodoItem);
  }
}

But this started to break down once I went to create the footer of the TodoMVC, where there's a component that keep tracks of the remaining todos, a button to clear all completed todos, and some filtering options. How do you decide which component is the keeper of the data? Do all components need to be of the same parent and re-render down the tree when some of the data changes? One method that sort of works is to create a state object and have all the cells use that data instead.

var store = {
  items: [{ name: 'Learn Cell', complete: false, id: 42 }],
  addTodo: (todo) => {
    store.items.push(todo);
    store.update();
  }
  removeTodo: (id) => {
    const index = store.items.findIndex(i => i.id === id);
    store.items.splice(index, 1);
    store.update();
  }
  update() {
    document.querySelector('[store="true"]').$update();
  }
}

var TodoList = {
  $cell: true,
  $type: 'ul',
  store: true,
  _items: store.items,
  $components: [],
  $init: function() {
    this.$update();
  },
  $update: function() {
    this.$components = this._items.map(TodoItem);
  }
}

var TodosRemaining = {
  $cell: true,
  $type: 'span',
  store: true,
  _items: store.items,
  $init: function() {
    this.$update();
  },
  $update: function() {
    this.$text = `${this._items.length} items remaining`;
  }
}

// any cell can update the list now!
store.addTodo({ name: 'Ask for help', complete: true, id: 22 });
store.addTodo({ name: 'Figure out state', complete: false, id: 21 });

This solves the problem of needing one component to update who-knows-how-many other components that are relying on the same dataset. Now, this works, but you can agree that it's pretty awful.

Can anyone offer me insight into the approach you're using that you find elegant?

mrjjwright commented 7 years ago

I am using Mobx.

gliechtenstein commented 7 years ago

To be honest there is no "best practice" for using cell at the moment since it's a new and minimal library, and I myself am discovering different ways of structuring apps with Cell everytime. But I do realize how "raw" cell is in this regard and I can imagine some sort of a "centralized framework" on top of cell so that this type of centralized communication is easier to deal with. (I discuss one such idea here https://github.com/intercellular/cell/issues/143)

Also some of the ideas you suggested look like valid solutions to me.

That said, in case you're not aware, I would like to mention one feature though. There's this feature called "context inheritance" where you can define _variables anywhere on the tree and the node's children will be able to access them.

Here's the documentation https://github.com/intercellular/tutorial#b-context-inheritance-and-polymorphism and here's an example: https://jsfiddle.net/k0knxwer/

This means in your example you don't have to store store._items for each store node. You can keep it under a single root node and reference them from descendants. Example:

var TodoList = {
  $type: 'ul',
  store: true,
  $components: [],
  $init: function() {
    this.$update();
  },
  $update: function() {
    this.$components = this._items.map(TodoItem);
  }
}

var TodosRemaining = {
  $type: 'span',
  store: true,
  $init: function() {
    this.$update();
  },
  $update: function() {
    this.$text = `${this._items.length} items remaining`;
  }
}

var TodoApp = {
  $cell: true,
  _items: store.items,
  $components: [TodoList, TodosRemaining]
}

Note that:

  1. the $cell: true is at the top level only.
  2. store.items is only attached to TodoApp._items
  3. The _items attributes are gone from TodosRemaining and TodoList.
  4. When you refer to this._items from TodosRemaining and TodoList, they utilize context inheritance to propagate up until it finds a node with _items attribute, and utilizes that.

Also, I see a lot of $update() calls inside $init() but these are not desirable, you should directly instantiate them instead. For example instead of:

var TodoList = {
  ...
  _items: store.items,
  $init: function() {
    this.$update();
  },
  $update: function() {
    this.$components = this._items.map(TodoItem);
  }
  ...
}

It's better if you use the below code because you don't have to wait for an $init callback:

var TodoList = {
  ...
  _items: store.items,
  $components: store.items.map(TodoItem),
  $update: function() {
    this.$components = this._items.map(TodoItem);
  }
}

Lastly,

It makes store.items explicitly not immutable, because if you were to change store.items with a function like:

removeTodo: (id) => store.items = store.items.filter(i => i.id !== id)

It would mean that this._items on TodoList no longer references the updated store.items and even forcing a re-render through $update() wouldn't work.

I think you can solve this if you attach an _items array at the root node as a single source of truth and refer to it everywhere, as I mentioned above. Just make sure to prefix it with "_" so the auto-trigger kicks in.

Here's an example that may lead you to a solution https://play.celljs.org/items/O9Rf6y/edit

Basically, since cell auto-triggers $update() every time a _variable's value changes (doesn't matter if it's the same reference or not, cell checks the value diff on UI update cycle and makes decisions based on that) you can take advantage of that and directly "mutate" the value, which will trigger a UI update.

I hope this helps! I think there can be many different ways of building a TODOMVC with Cell and think ANY approach is cool really. There is no right or wrong answer at this point and I think what matters is there are multiple ways of building the same thing . So please share your result once you get it working, would appreciate it.

Also feel free to ask further questions if something wasn't clear!

dargue3 commented 7 years ago

Thank you so much for the clear answers to each of my problems! This was exactly what I was hoping for when I took the time to write the post.

I'm envisioning that I'll have the entire app inheriting a state management component; makes so much sense now.

As for another question, could you point to an example of your favorite way to structure your file system in cell apps? Moving from using import package from '...' to having seemingly global components is a big step for me 😄

gliechtenstein commented 7 years ago

First of all, all apps--no matter how complex they are--can be built with a single cell variable theoretically. It would basically look something like this:

var app = {
  $cell: true,
  $type: "body",
  $components: [{
    ...
    $components: [{
      ...
      $components: [{
        ...

But this is not practical because you will want to make it modular, which is why you may end up creating multiple global variables.

But you probably want to only use these global variables by plugging them into cells as an attribute instead of using them everywhere. Here's an example of a bad usage:

var doSomething = function() {
  console.log("Do something!");
}
var app = {
  $cell: true,
  $init: function() {
    doSomething()
  }
}

This is a great example of why people say "globals are bad" because if you keep doing this you will end up with a spaghetti code where you can't keep track of what's going on anymore.

But take the same app and do something like this:

var doSomething() {
  console.log(this._caption);
}
var app = {
  $cell: true,
  _caption: "Do something!",
  $init: doSomething
}

And you end up with a perfectly modular app. In fact the doSomething() function becomes really powerful because it is:

  1. Completely stateless on its own (it doesn't depend on any particular object)
  2. But plugs into its parent node seamlessly to make use of the node's attributes (this._caption can mean different things depending on where this function gets plugged into)

Here's another example: https://play.celljs.org/items/12Dfay/edit

If you look at the Github variable:

Github = function(input){
  fetch("https://api.github.com/search/repositories?q=" + input.value).then(function(res){
    return res.json()
  }).then(function(res){
    input._done(res);
  })
}

It assumes that it will communicate with its whichever parent it's plugged into via a _done() function, so if you scroll down to where Github is actually being used, you'll see:

Input = {
  ...
  onkeyup: function(e){
    if(e.keyCode === 13){
      Github(this);
      this.value = "";
    }
  },
  _done: function(res){
    document.querySelector(".container")._set(res.items.map(function(item){
      return {
        image: item.owner.avatar_url,
        title: item.full_name,
        stars: item.watchers_count,
        content: item.description ? item.description : ""
      }
    }))
  }
  ...
};

Lastly, in practice your app may become more complex and you may still want to organize them based on functionality. In this case, you can take advantage of the stateless functional components I mentioned above and simply create a "module" by wrapping these functions and variables with a parent object. Example:

var Widgets = {
  Input: {
    $type: "input",
    type: "text",
    ...
  },
  List: {
    $type: "li",
    $components: [...]
    ...
  },
  Selector: {
    ...
  }
}
var app = {
  $cell: true,
  $components: [Widgets.Input, Widgets.List, Widgets.Selector]
}

This is similar to how cell.js itself is structured

If you have any other ideas or come across an interesting usage pattern please feel free to share :)

dargue3 commented 7 years ago

You're amazing, @gliechtenstein. I'm really interested to dig into cell.js more soon. Is there a way we could chat about things that isn't thru Issues? Unless you think this is good conversations to have on the books of course.

gliechtenstein commented 7 years ago

@dargue3 no problem, we can also use slack https://celljs.now.sh/

jshuadvd commented 6 years ago

@dargue3 could you share your repo for inquiring minds?