@microstates/observable

Observable wrapper for Microstates

Usage no npm install needed!

<script type="module">
  import microstatesObservable from 'https://cdn.skypack.dev/@microstates/observable';
</script>

README

Observable Microstates

A Microstate presents data and operations that can be performed on that data. A microstate can only ever have one value. Every operations returns a new microstate that's independent from the object that generated it. When you need a stream of values, you can create an observable from a microstate. The created obserable will stream microstate's state, actions and value to the observer.

Here is what that looks like,

import microstate, * as MS from 'microstates'
import { from } from '@microstates/observable'

let ms = microstate(MS.Number, 42)
let observable = from(ms)

observable.subscribe(v => console.log(v))
/**
 * => {
 *  state: 42,
 *  actions: { increment, decrement, set, add, substract }
 *  valueOf: function
 * }
 **/

When you subscribe to the observable, the observable will immidately push an object to the observer. This object gives you the initial state and actions that can be invoked to next value to the observer.

The value object has the following structure:

{
  /**
   * State generated from the microstate. Same as `ms.state`
   */
  state: ,
  /**
   * These are transitions of the microstate that are wrapped in closure.
   * Invoking an action will push the result of the operation through the stream.
   */
  actions: {
    ...
  },
  /**
   * Return the value that was used to create the microstate
   */
  valueOf: Function
}

Value and valueOf

Value is a plain JavaScript object used to construct a microstate. From this value, microstates library builds state and actions. The value is treated safely, meaning that the original object that's passed into a microstate constructor will never be modified. Any operations performed on the value will create a copy of the original value.

Value can be any privitive type that can be serialized with JSON.stringify and parsed with JSON.parse.

You can use valueOf function that's included in the stream value to retrieve the value bound to state and actions.

import microstate, * as MS from 'microstates'
import { from } from '@microstates/observable'

class Address {
  street = MS.String
  city = MS.String
  state = MS.String
  country = MS.String
}

class Person {
  name = MS.String
  address = Address
}

let ms = microstate(Person, { name: 'Homer Simpson', { city: 'Springfield' } })

let observable = from(ms)

observable.subscribe(v => console.log(v.valueOf()))
// => { name: 'Homer Simpson', { city: 'Springfield' } }

State

State is deserialized value of a microstate. State is a tree structure of instantiated objects, primitive values and computed properties. It's designed to make it easy to work with values that are derived from microstate's value.

import microstate, * as MS from 'microstates'
import { from } from '@microstates/observable'

class BookReview {
  reviewer = Person
  get hasReviewer() {
    return this.reviewer.name !== ''
  }
}

let ms = microstate(BookReview, {})
let observable = from(ms)

observable.subscribe(v => console.log(v.state))
/**
 * BookReview {
 *   reviewer: Person {
 *      name: '',
 *      address: Address {
 *        street: '',
 *        city: '',
 *        state: '',
 *        country: ''
 *     }
 *   },
 *   hasReviewer: false
 * }

Actions

Actions are microstate transitions that are wrapped in closures that push the result of the transition to the observer. Actions are bound to value of a microstate which means that for every value there is a new set of actions.

We include actions in the value object to ensure that calling an action will always give you the next value. If you invoke actions from a previous value, then your stream will receive value generated from an operation performed on an old value.

Like state, actions are a tree structure. They mirror the structure of the type that was used to build the microstate. This tree structure is evaluated lazily, therefore it does not encure evaluation cost until you read the action.

import microstate, * as MS from 'microstates'
import { from } from '@microstates/observable'

class Book {
  name = MS.String
  pagesCount = MS.Number
  reading = MS.Number
  turnPage(current) {
    return this().pagesCount.increment()
  }
  readAgain() {
    return this().reading.set(1)
  }
}

class BookReview {
  book = Book
  reviewer = Person
  get hasReviewer() {
    return this.reviewer.name !== ''
  }
  get isFinished() {
    return this.book.pagesCount === this.book.reading
  }
}

let ms = microstate(BookReview, {
  book: { name: 'War and Peace', pagesCount: 1000, reading: 999 },
  reviewer: { name: 'Taras Mankovski' }
})

let observable = from(ms)
let last

observable.subscribe(v => {
  console.log(v)
  last = v
})
/**
 * {
 *    state: BookReview {
 *      book: {
 *        name: 'War and Peace'
 *        pagesCount: 1000
 *        reading: 999
 *      },
 *      reviewer: {
 *        name: 'Taras Mankovski',
 *        address: Address {
 *          street: '',
 *          city: '',
 *          state: '',
 *          country: ''
 *        }
 *      },
 *      hasReviewer: true,
 *      isFinished: false
 *    },
 *    actions: {
 *      set,
 *      merge,
 *      book: {
 *        merge,
 *        name: {
 *          set,
 *          concat
 *        },
 *        pagesCount: {
 *          set,
 *          increment,
 *          decrement,
 *          add,
 *          subtract
 *        },
 *        reading: {
 *          set,
 *          increment,
 *          decrement,
 *          add,
 *          subtract
 *        }
 *      },
 *      reviewer: {
 *        set,
 *        merge,
 *        name: {
 *          set,
 *          concat
 *        },
 *        address: {
 *          set,
 *          merge,
 *          street: {
 *            set,
 *            concat
 *          },
 *          city: {
 *            set,
 *            concat
 *          },
 *          state: {
 *            set,
 *            concat
 *          },
 *          country: {
 *            set,
 *            concat
 *          }
 *        }
 *      }
 *    }
 * }
 **/

Batched & Chained Actions

You can perform several operations in one by creating batch actions or by chaining actions.

Batched Actions

Batched transitions allow you to give your action a name. When you call a batched transition, it will push one/final value to the stream.

import microstate, * as MS from 'microstates'
import { from } from '@microstates/observable'

class CancellableBookReview extends BookReview {
  cancelReview() {
    return this()
      .reviwer.set(null)
      .book.set(null)
  }
}

let ms = microstate(CancellableBookReview, {
  book: { name: 'War and Peace', pagesCount: 1000, reading: 999 },
  reviewer: { name: 'Taras Mankovski' }
})

let last

observable.subscribe(v => {
  last = v
  console.log(v.valueOf())
})
/**
 *  {
 *    book: { name: 'War and Peace', pagesCount: 1000, reading: 999 },
 *    reviewer: { name: 'Taras Mankovski' }
 *  }
 **/

last.actions.cancelReview()
/**
 *  {
 *    book: null,
 *    reviewer: null
 *  }
 **/

Chained actions

Chained actions allow adhoc operations to be performed on same value. Each action will send a new value to the stream.

import microstate, * as MS from 'microstates'
import { from } from '@microstates/observable'

let ms = microstate(BookReview, {
  book: { name: 'War and Peace', pagesCount: 1000, reading: 999 },
  reviewer: { name: 'Taras Mankovski' }
})

let last

observable.subscribe(v => {
  last = v
  console.log(v.valueOf())
})
/**
 *  {
 *    book: { name: 'War and Peace', pagesCount: 1000, reading: 999 },
 *    reviewer: { name: 'Taras Mankovski' }
 *  }
 **/

last.actions.book.turnPage().book.readAgain()
/**
 *  {
 *    book: { name: 'War and Peace', pagesCount: 1000, reading: 1000 },
 *    reviewer: { name: 'Taras Mankovski' }
 *  }
 *  {
 *    book: { name: 'War and Peace', pagesCount: 1000, reading: 1 },
 *    reviewer: { name: 'Taras Mankovski' }
 *  }
 **/

History

In most cases, you will want the last value that came through the stream because it'll give you the latest version of state and actions. However, since microstates are immutable and the stream gives you a value for every transition, it is possible to restore to a previous state by invoking the set action with value of a previous value.

For example,

import microstate, * as MS from 'microstates'
import { from } from '@microstates/observable'

let ms = microstate(MS.Object, { a: 'a' })
let observable = from(ms)
let values = []

observable.subscribe(v => values.push(v))

values[0].valueOf()
// => { a: 'a' }

values[0].actions.assign({ b: 'b' })

values[1].valueOf()
// => { a: 'a', b: 'b' }

values[1].actions.assign({ c: 'c' })

values[2].valueOf()
// => { a: 'a', b: 'b', c: 'c' }

values[3].set(values[1].valueOf())

values[4].valueOf()
// => { a: 'a', b: 'b' }

This makes it fairly trivial to implement interfaces where you need to restore to a previous value.