Predictable and observable state containers.


Keywords
politic, react, redux, mobx, observable, reactive, model, state, observer, event, subscribe, frp, mvc, reducer, predictable, store, functional, action, immutable, readonly
License
MIT
Install
npm install politic@0.1.7

Documentation

Politic

Build Status

Predictable and observable state containers.

See also, react-politic for React integration.

adjective: pol·i·tic

  • (of an action) seeming sensible and judicious under the circumstances.

Getting Started

Install and save this module as a dependency.

npm install politic --save

Really Simple

import Store from "politic";

const store = new Store();

// The state starts as null if no initial state is passed to the constructor.
console.log(store.state); // null

// If no actions map is passed to the constructor, then "set" and "merge" are provided as defaults.
store.action('set', { foo: 1 })
store.action('merge', { bar: 2 });

// Actions are applied synchronously.
console.log(store.state); // { foo: 1, bar: 2 }

// Update notifications are asynchronous.
store.subscribe(state => {
    console.log(state); // { foo: 1, bar: 2 }
});

TODO List

This is a more "realistic" TODO list example.

File: TodoStore.js

import Store from "politic";

export default new Store({
    // Initial state.
    state: { 
        items: []
    },
    
    // Actions map.
    actions: {
        add: (state, item) => {
            const items = state.items;
            return Object.assign({}, state, {
                items: items.concat({
                    id: item.id,
                    value: ""+item.value,
                    completed: !!item.completed
                })
            });
        },
        
        removeId: (state, id) => {
            const items = state.items;
            return Object.assign({}, state, {
                items: items.filter(item => item.id !== id)
            });
        },
        
        completeId: (state, id) => {
            const items = state.items;
            return Object.assign({}, state, {
                items: items.map(item => {
                    return item.id !== id ? item : Object.assign({}, item, {
                        complete: true
                    });
                })
            });
        }
    }
});

Create some RxJS Subjects (or equivalent ES Observables) which represent abstract events in your TODO app. Subjects can serve to invert control so that event and data sources do not need direct knowledge of store actions. They can also be used to enable asynchronous state changes.

File: Subjects.js

import Rx from "rxjs/Rx";

export default {
    newItem: new Rx.Subject(),
    completeItem: new Rx.Subject(),
    removeItem: new Rx.Subject()
};

Connect the subjects to the store.

File: Routing.js

import todos from "./TodoStore";
import { newItem, completeItem, removeItem } from "./Subjects";

function getItemId(item) {
    return item.id;
}

todos.connect('add', newItem);
todos.connect('completeId', completeItem, getItemId);
todos.connect('removeId', removeItem, getItemId);

Use the subjects to cause state changes.

import { newItem, completeItem, removeItem } from "./Subjects";

let item = {
    id: 0,
    value: "Hello, World!"
};

// Add an incomplete todo.
newItem.next(item);

// Complete the todo.
completeItem.next(item);

// Remove the todo.
removeItem.next(item);

Handle todo items updates.

import todos from "./TodoStore";

todos.subscribe(state => {
    state.items.forEach(item => {
        console.log(item.value);
    });
});

Class: Store

State container which implements the ES Observable Proposal.

import Store from "politic";

Pass values to predefined actions which use reducers to create the next state of the store. Store subscribers will be notified of changes to the Store's state.

Constructor: new Store([options])

  • options Object - Store options map. Default: {}
    • initialState any - The initial state of the store. Default: null
    • actions Object<String|Symbol, Action> - A map of action "reducer" methods. Default: DefaultActions
    • middleware Array<Middleware> - An array of middlewares. Default: []

NOTE: The initialState value, actions map, and new states returned by reducers, will be recursively frozen.

Property: store.state any

The current (readonly) state.

Method: store.action(action, value)

Apply a value to the state using the action.

  • action String - Name of the action to be applied.
  • value any - Value to be applied to the Store's state by the action.
  • Returns: any - New (readonly) state after the action is applied.

NOTE: If an action is invoked on the store as the result of a state change notification on the same store, an error will be thrown.

Actions are "lifted" to the store so that if an action name does not conflict with an existing store instance method (e.g. subscribe, connect, toString, etc.), then it can also be invoked as a store instance method (e.g. store.action('foo', value) is equivalent to store.foo(value)).

Method: store.subscribe(observer)

Register an observer which will be notified when the store's state changes. Implements the ES Observable subscribe(...) method.

  • observer Observer|Function - An observer or onNext function to be subscribed to store state changes.
  • Returns: Subscription - A subscription which can be cancelled.

Method: store.connect(action, observable[, mapFunction])

Connect an observable so that published values are applied to the Store's state using the action.

  • action String - Action name.
  • observable Observable - Observable value source.
  • map Function - Function used to transform published values before invoking the action. Default: value => value
  • Returns: Subscription - A subscription which can be cancelled.

NOTE: Even though Stores themselves are observable, you cannot connect one Store to another store. If a Store is passed to the connect(...) method, an error will be thrown. This is to prevent state cascades, shared state responsibility, and complicated application flow.

Using a Store's connect(...) method is an inversion of control, equivalent to using an observable's subscribe(...) method.

Example: Using store.connect(...)

const subscription = store.connect(action, observable, mapFunction);

Example: Using observable.subscribe(...)

const subscription = observable.subscribe(value => store.action(action, mapFunction(value)));

Method: store.copy()

Get a copy of the current Store with the same state and actions.

  • Returns: Store - A copy of the current store.

Callback: Action(state, value)

Prototype for Politic action "reducer" functions. All properties of the actions map passed to the Store constructor should be functions with this prototype.

  • state any - Current (readonly) state of the store.
  • value any - Action value.
  • Returns: any - The next state. If no state change is required, then return the state reference.

Object: DefaultActions

Default actions map used when no explicit actions map is defined for a new store.

import { DefaultActions } from "politic";

Property: set Action

Replace the current state with the action value.

Property: merge Action

Shallow merge (e.g. Object.assign({}, state, value)) the action value with the current state.

If either the state or the new value is not an object, then the new value will replace the current state.

Middleware

Middleware are functions which wrap every action. They can be used to log state changes, filter actions or action values, and to validate or modify the state returned by actions.

See MIDDLEWARE.md for more information.

FAQ

What does this buy me over Redux and the various connector libraries?

Coffee? But seriously, maybe nothing. I wanted something an order of magnitude simpler than Redux, it didn’t seem to exist, so I wrote it. Redux is arguably more powerful, but I didn’t need a lightsaber, I needed a screwdriver.

Above all, Politic is designed to be simple and easy to integrate with any project.

Here's the "simplest" Redux use case:

import { createStore } from "redux";

const store = createStore((state, action) => {
    if (action.type === 'merge') return Object.assign({}, state, action.payload);
});

store.dispatch({ type: 'merge', payload: { a: 1 } });
store.dispatch({ type: 'merge', payload: { b: 2 } });
store.dispatch({ type: 'merge', payload: { a: 3 } });

console.log(store.getState()); // { a: 3, b: 2 }

Here's the same for Politic:

import Store from "politic";

// Using the default "set" and "merge" actions.
const store = new Store();

// Actions are "lifted" to instance methods if they won't conflict with existing instance methods.
store.merge({ a: 1 });
store.merge({ b: 2 });
store.merge({ a: 3 });

// ES6 getter for state.
console.log(store.state); // { a: 3, b: 2 }

How does this compare with MobX?

MobX has "fine-grained" observables. Politic does not. Stores are observable, but they are not designed to be nested, and a subscriber gets the whole state every time.

Instead of reinventing the observable wheel, Politic leverages the ES Observable Proposal which makes it compatible with RxJS among others.

Politic probably has more in common with Redux. State modifications are the result of pre-defined Store actions, rather then direct manipulation of the state.

Are hierarchies of models supported?

Short answer, yes. You can store anything you want as state. But, if you mean, is Politic aware of special things like observables in it's state, then no. This would put restrictions on what a state is and how it can be manipulated. Politic is designed to make as few assumptions as possible around what it will contain and how it will be used.

As compensation, you can have as many stores as you want. You might also look at Subjects as a way to keep multiple stores in sync.

How do I create asynchronous actions?

Asynchronous operations probably shouldn't be part of an action, because actions are supposed to be pure. However, you could use middleware to accept Promise objects returned from actions, and then asynchronously invoke another action when the promise is resolved.

A better option would be to write or wrap your asynchronous operations as ES Observables, then use the store.connect(...) method to update your Store when the asynchronous operation concludes. RxJS is a good place to start for creating observables.