Git Product home page Git Product logo

arbor-store's Introduction

Arbor

npm version Build Status

Seamless state management made with ❤️.

What is it?

Arbor is a state tree management solution that leverages immutability through structural sharing to perform state tree mutations allowing subscribers to listen to state changes.

Even though immutability is Arbor's foundation, very little boilerplate is added to the developer's workflow and mutations are triggered via the old and familiar Javascript Object/Array APIs (thanks to ES6 Proxies under the hoods). Because Arbor mutations apply structural sharing under the hoods, features like state history playback and undos are easily implemented.

Additionally, Arbor allows custom types to be bound the specific paths within the state tree so you may better encapsulate your business logic keeping them separate from UI logic, increasing testability as well as business logic reusability.

Arbor is framework agnostic, though, there is a React binding that you can check out.

Getting Started

A simple Counter APP...

import Arbor from "arbor-store"

const store = new Arbor({
  counter1: {
    count: 0,
  },
  counter2: {
    count: 0,
  }
})

store.subscribe((nextState, previousState) => {
  console.log("new state:", nextState)
  console.log("old state:", previousState)
})

store.state.counter1.count++

Breaking it down

The State Tree

const store = new Arbor({
  counter1: {
    count: 0,
  },
  counter2: {
    count: 0,
  }
})

The snippet above defines a store whose state tree looks like this:

          (root)
          /    \
(counter1)     (counter2)
    |              |
  count = 0      count = 0

In the state tree, (root), (counter1) and (counter2) are tree node objects responsible for all the immutability "magic". Each node has a path that determines its location within the state tree. (root) for example is represented by the / path, (counter1) is represented by /counter1 and (counter2) represented by /counter2. Leaf nodes within the state tree are non-node types (count attributes).

Mutation Subscriptions

The code below registers a subscriber function which is called whenever a mutation happens in the state tree, providing access to the next and previous states.

store.subscribe((nextState, previousState) => {
  console.log("new state:", nextState)
  console.log("old state:", previousState)
})

Mutations

store.state.counter1.count++

Every mutation triggered by any node creates a mutation path that determines which nodes in the state tree were affected by the mutation and thus must be refreshed with new instances.

Once a mutation is finished, a new state tree is generated where nodes affected by the mutation path have their instances refreshed and nodes not affected by the mutation path are kept untouched (Structural Sharing), for instance:

Triggers a mutation in the state tree for the mutation path /counter1. That mutation path affects the (root) node whose path is /, and the (counter1) node whose path is /counter1. Since (counter2) whose path is /counter2 is not affected by the mutation path, it is reused in the new state tree:

          (root*)
          /    \
(counter1*)     (counter2)
    |              |
  count = 1      count = 0

Nodes marked with a * in the state tree above represent the nodes affected by the mutation path and thus are new node instances.

Splitting Business logic From UI Logic

As React applications grow larger, splitting business and UI logic can get tricky. Arbor allows custom node types to be bound to specific paths within the state tree, where business logic code can be encapsulated increasing testability and maintainability.

Custom node types are just plain ES6 classes that are explicitly bound to certain paths of the state tree and provide a constructor which "selects" what state attributes it cares about, for example:

class Todo {
  constructor({ id, title, status }) {
    this.id = id
    this.title = title
    this.status = status
  }

  start() {
    this.status = "doing"
  }

  finish() {
    this.status = "done"
  }
 }

 const store = new Arbor({
   todos: [
     { id: 1, title: "Do the dishes", status: "todo" },
     { id: 2, title: "Clean the house", status: "todo" }
   ]
 })

 store.bind("/todos/:index", Todo)

The example above defines a custom node type Todo and binds it to the /todos/:index path. There are a few things to notice here:

  1. The custom type Todo implements a constructor which takes all properties that make up a todo item.
  2. Business logic is defined by the new custom type for starting and finishing a todo.
  3. The custom type is bound to a wildcard path where :index represents any todo item within the todos array. Any access to any todo item in the array, will yield an instance of Todo, e.g.:
const todo = store.state.todos[0]
expect(todo).to.be.an.instanceOf(Todo)

Custom node types can represent either objects or array nodes within the State Tree. Custom array nodes must extend Array:

class Todos extends Array {
  constructor(items) {
    super(...items)
  }

  createTodo({ title }) {
    this.push({ id: Math.random(), title })
  }

  startTodo(index) {
    this[index].start()
  }

  finishTodo(index) {
    this[index].finish()
  }

  sortByTitle() {
    this.sort((todo1, todo2) => {
      if (todo1.title > todo2.title) return 1
      if (todo1.title < todo2.title) return -1
      return 0
    })
  }
}

store.bind("/todos", Todos)

State Tree Time Travel

Arbor leverages Structural Sharing in order to perform state mutations. A new state tree is always created by applying the minimum amount of operations necessary to generate the new state. With this, a series of state snapshots may be recorded, allowing for interesting use cases such as State Time Travel.

2017-12-14 20 51 16

arbor-store's People

Contributors

drborges avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar

arbor-store's Issues

Subscribe to specific mutation paths

Currently, subscribers are notified of any state change, receiving the new state tree root node as argument.

The idea is to allow subscriptions to created for specific mutation paths, e.g.:

// Subscribe to new posts being pushed into a user's posts list
store.subscribe("/users/:index/posts/push", (post) => {
  // do something with new post
})

// Subscribe to new posts being pushed into a user's posts list
store.subscribe("/users/:index/name", (user) => {
  // do something with user whose name was just updated
})

We might want to hold off on this feature until there are more clear use cases.

Update README file

The current README file is outdated and needs to be updated to reflect the new Arbor API (subscriptions).

  • Add Installation section
  • Add React section (quick overview and link to arbor-react)
  • Update Timetravel section to point to arbor-timetravel repo
  • Add Examples section with working example apps (add arbor-react-app to the examples list)
  • Add License
  • Add How to Contribute section
  • Add more examples
  • Add a section to provide a good overview of how Arbor works internally (State Tree, Structural Sharing, Mutation Paths, Transaction and large dataset updates, etc...)
  • Add a section for benchmark results (add graphs, everybody likes graphs :)

Nodes must have their path refreshed

A GIF is worth 10000 words...

2018-04-10 20 28 45

Basically, a node bound to path /array/:index must have its path refreshed when items removed/added to the array affect the path.

Example:

const todos = [
  { id: 1, ... }, // path = /todos/0
  { id: 2, ... }, // path = /todos/1
]

When removing todos[0], the path of the remaining todo, shoud no longer be /todos/1 but rather /todos/0.

Tree subscriptions seem to receive a tree root with non-refreshed children references

On the example below, the first subscription logs out an invalid state for the store, where name still says Diego. The second subscription, though, logs out the state properly, which leads me to believe that the Tree#root is not updated properly upon mutations.

const store = new Store({
  user: {
    name: "Diego",
    age: 32,
  }
})

store.user.name = "Bianca"

store.subscribe("/", state => console.log(state))
store.subscribe("/", state => console.log(state.user))

Allow restoring a tree path to a previous value

This would essentially allow one to "rollback" mutations to a certain path. For optimistic UIs this means we'd be able to easily rollback UI changes upon API request failures:

store.subscribe("/users/:index", async (newUser, oldUser, restore) => {
  const response = await api.users.update(newUser)
  if (response.status >= 400) {
    restore(oldUser).errors.push(response.message)
  }
})

Subscribers would receive a restore function as the third argument, to be called with the previous value of the mutated node, returning the restored node allowing further method chaining.

Extract MTree into its owne arbor-model package

MTree provides a new state tree engine (extending the default PTree) adding support to model abstractions for registered state tree path patterns.

This could be a separate, optional package since developers may choose to not use models (for simple apps it might not be needed...) thus, reducing the number of dependencies one would have to bundle within their app.

Implement arbor-model

The idea here is simple, allow developers to provide their own abstraction to a given state tree path. Take the following Store as example:

const store = new Store({
  todos: [
    { title: "Do the dishes", status: "todo" },
    { title: "Take the dog out", status: "doing" },
    { title: "Do the laundry", status: "done" },
  ]
})

One could then create a class to represent a todo and even provide a more domain specific API:

@Model("/todos/:index")
class Todo {
  isStarted() {
    return this.status === "doing"
  }

  start() {
    if (this.status === "todo") this.status = "doing"
  }

  finish() {
    if (this.status === "doing") this.status = "done"
  }
}

This model can be made visible to the store by "registering" it as so:

import { Todo } from "./models"
store.register(Todo)

With that, whenever a todo is accessed, an instance of Todo would be provided:

const todo = store.todos.find(todo => !todo.isStarted())
todo.start()

Implement `Node#transaction` operation to allow "atomic" mutations

Currently, every mutation operation (set, Array#push, etc...) will notify all subscribers as soon as they are done running. Because of that, if one needs to perform a more complex mutation, by changing multiple properties of a given node, for instance, that would cause subscribers to "see" the intermediary states required to complete the mutation. With transactions, mutations can be performed atomically:

const store = new Store({ user: { age: 32 }})
store.subscribe(console.log)

store.state.user.age++
// subscribers are notified
store.state.user.name = "Diego"
// subscribers are notified

Node#transaction would allow multiple mutations to be applied to a given node in an atomic fashion, similar to DB transactions:

const store = new Store({ users: [{ age: 32 }]})
store.subscribe(console.log)

store.state.users[0].transaction(user => {
  user.age++
  user.name = "Diego"
  console.log(user)
  // => { name: "Diego", age: 33 }
})
// subscribers are notified only once

Mutations performed within the #transaction closure happen "atomically" and subscribers will perceive the final mutation as if it were a single one. Additionally, within the transaction closure, mutations are visible right away (as shown in the example above).

Better test coverage

  • add specs to show that tree nodes (proxies) support es6 features such as destructuring objects (NodeObject) and arrays (NodeArray).
  • add specs to NodeArray to make sure the Array API (slice, map, reduce, filter, find, etc) will behave as expected when called on the tree node proxies.
  • refactor specs so they use a common theme (todo app for instance) for better consistency.
  • refactor spec examples so they focus on a single (if possible) aspect of the feature.

Provide old and new state values to subscribers

Currently, subscribers are notified with only the new state. The old state should also be provided so that subscribers may choose to, perhaps, rollback changes in case it is needed.

store.subscribe("/users/:index", async (newUser, oldUser, restore) => {
  const response = await api.users.update(newUser)
  if (response.status >= 400) {
    restore(oldUser).errors.push(response.message)
  }
})

This would be very useful for implementing optimistic UIs, where local changes are applied right away and may be rolled back depending on the server's response.

Arbor-devtools

Every framework/lib is as good as the tools supporting its development. A devtools plugin for (initially) chrome would help to reduce the adoption friction of Arbor. Here are some ideas for features:

  1. Time travel out-of-the-box: Anyone should be able to choose to start recording state mutations on an Arbor app and be able to replay them, or travel back and forth in time, and export the recording so other user may import and replay those mutations on their machine.
  2. 3D timeline to represent app mutations over time. Each mutations would render the entier app on a given z-index, allowing users to move back and forth in time, visualizing the different state snapshots taken during that period.
  3. Live state tree rendering and path access highlighting. There should be a panel within the devtool where a tree data structure can be rendered to represent the current app state. As the dev access state within the tree via the dot notation API (store.state.users[0].name) the accessed path within the tree is highlighted.

Use WeakMap for cache implementations

Arbor uses a few different caches in order to optimize memory usage and speed things up a bit. For example, there is always a single instance of Path for a given sequence of path nodes, e.g. new Path("users", 0) === new Path("users", 0).

Each proxy within a PTree has a cache to hold their children so that they are only created once (upon first access) for a given state tree, e.g. multiple accesses to the users node (tree.root.users) create the users proxy as a child of root on the first access, subsequent accesses yield the cached proxy.

Using WeakMap for these caches would (likely, benchmark might be needed) allow the GC to free up memory when no other object in the app is referencing these cached items.

Support seamless mutation promise resolution

It would be very convenient if mutations could take promises in addition to sync values, example:

store.users.push(fetch("app.com/users/123"))
store.todos = fetch("app.com/todos?limit=20")

If the mutation value is a Promise, arbor would then only apply the corresponding mutation upon promise resolution.

Failures can still be handled by the caller:

const userPromise = fetch("app.com/users/123").catch(console.log)
store.users.push(userPromise)

This is particularly useful when APIs reflect exactly the data structure expected by the front-end (BFF pattern).

Implement store adapters to connect to different data sources

This would allow a local store to connect with different remote data sources, such as a Rest API, GraphQL, or even another arbor store via a peer-to-peer connection.

Rest APIs

store.connect(new MyRestApiAdapter)

GraphQL

http://graphql.org/graphql-js/running-an-express-graphql-server

store.connect(new MyGraphQLServerAdapter)

Web Sockets

https://www.html5rocks.com/en/tutorials/websockets/basics

store.connect(new MyWebsocketChannel)

WebRTC

https://www.html5rocks.com/en/tutorials/webrtc/basics

store.connect(new MyWebRTCConnection)

Local Storage

store.connect(new LocalStorageAdapter)

Allow for async initial store state

This would allow for creating a Store with an initial async state, likely provided by an API (GraphQL??)

const promise = fetch("my.api.com/state")
const store = new Store(promise)
store.subscribe(console.log)

Subscribers should be notified with the current state upon subscription

Currently, subscribers are only notified of mutations when they happen and never have a chance to handle the current store state upon their subscription.

This would allow for instance React dev tools to correctly show the current app state on its first rendering cycle (before any user interactions have taken place).

Implement arbor-timetravel package

This package would be a proof-of-concept project to validate whether or not arbor would provide a nice way to handle time travel functionality for any given app.

Basically, this package would allow one to travel back and forth in time and see all the state mutations that happened in that period.

Prototype:

import Store from "arbor"
import withTimetravel from "arbor-timetravel"

const StoreWithTimetravel = withTimetravel(Store)
const store = new StoreWithTimetravel({
  todos: []
})

store.timeline.back(1)
store.timeline.forward(1)
store.timeline.last()
store.timeline.first()

Provide @prop decorator to bind instance variables to props attributes

Allow binding instance variables to prop fields. This could provide an even higher data abstraction level.

class App extends React.Component {

  @prop // binds this.users to this.props.users
  users

  @prop("todo_list") // binds this.todos to this.props.todo_list
  todos

  render() {
    // JSX...
  }
}

Support #destroy operation

It would be quite convenient if we could easily remove branches of the state tree by simply calling node.$destroy().

If node is a child of an object, the operation would be delete parent[nodeProp]. In case the node is an array item, the operation can be fulfilled by Array.splice.

Remove elements from arrays is a common operation in React apps which should make a case for this API. Removing props from objects seems a little less common, but could still be handy should the user decide to build normalized key/value stores.

Optimize final package size

  1. Remove unnecessary code such as index.js files only used internally by the lib
  2. Remove core.js and babel-runtime from the final packages.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.