0.1.0-alpha1 • Published 9 years ago

afflux v0.1.0-alpha1

Weekly downloads
4
License
CC0-1.0
Repository
github
Last release
9 years ago

{name}

Izaak Schroeder izaak.schroeder@gmail.com :name: afflux :description: Unopinionated, functionally-reactive flux patterns for React. :icons: font :source-highlighter: highlight.js :idprefix: :idseparator: - :toc: :toc-placement: preamble

{description}

image:http://img.shields.io/travis/izaakschroeder/{name}/master.svg?style=flat[foo] image:http://img.shields.io/coveralls/izaakschroeder/{name}/master.svg?style=flat[foo] image:http://img.shields.io/npm/l/{name}.svg?style=flat[foo] image:http://img.shields.io/npm/v/{name}.svg?style=flat[bar] image:http://img.shields.io/npm/dm/{name}.svg?style=flat[baz]

abstract {name} uses most to implement a very minimal, fast, functional-reactive style of flux patterns for web applications.

TODO:

  • More tests
  • Allow generators for actions (this allows for optimistic evaluation)
  • Pattern for optimistic evaluation
  • Comparisons
  • Cleanup tests

Overview

{name} follows the <> mantra fairly closely by providing patterns for actions and stores. It works best with purely functional-style data representation like immutable.js.

Actions:

  • Are dispatchers - Call whenever you need to do something.
  • Are asynchronous - Actions always flow on the next tick.
  • Are streams - You can observe the flow of all actions.
  • Return values - You can observe the result of a single action.

Example: "Create a todo with content 'x'" actions.todos.create("x").

Stores:

  • Are oracles - Observe whenever you need to know something.
  • Are synchronous - React only to results of actions.
  • Create properties - Reduce actions to values consumed by views.
  • Are streams - You can observe the flow of all property changes.

Example: "What are all the known todos?". stores.todos

State:

  • Is mutable
  • Is time-varying

TIP: What about the dispatcher? There is none. Everything is a stream. You can emulate all the functionality (including waitFor) of a dispatcher with stream combinators.

Framework Comparison

Alt

Fluxible

Fluxxor

Flexy

https://github.com/nmn/flexy

Usage

Add {name} to your project.

npm install --save afflux react

TIP: You can use the generator-{name} package with yeoman to help make action and store files automatically; there's also {name}-router for handling routing.

Actions

Actions are just functions that create promises. Typically you group your actions together logically in a class that encapsulates the relevant functionality. Actions contain no data about the state of the system.

import Promise from 'bluebird';
import { action } from 'afflux';

export default class MyActions {

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

    @action // <1>
    foo() {
        return this.api.get('/foo');
    }

    @action
    bar() {
        return Promise.reject();
    }
}

<1> Use action as an ES7 decorator.

You can create free-standing functions if you wish as well.

import { action } from 'afflux';

var example = action((id) => {
	return ajax().then(resp => JSON.parse(resp));
});

Stores

Stores react precisely to the results of actions. Stores do not contain the state of the system - they represent the state of the system.

Typically a store will:

  • produce a result that is the combination of multiple actions,
  • use the result of the promise from the action.

You can use most combinators to achieve this.

import { await } from 'afflux';

export default class MyStore {
    constructor(actions) {
        this.myobject = await(actions.bar);
    }
}

Views

Higher-order components make using {name} in React views straightforward.

import { send, receive } from 'react-beam';
import { observer } from 'react-observer';

@send('stores', 'actions') <1>
class App extends Component {
    render() {
        return <View/>;
    }
}

@receive('stores', 'actions') <1>
@observer <3>
class View extends Component {

    observe(props) {
        return {
            myobject: props.stores.mystore.myobject
        };
    }

    render() {
        return <div>{this.props.myobject.value}</div>
    }
}

<1> Use react-beam to send and recieve the needed properties like stores and actions. <2> Use react-observer to watch for changes in stores and automatically re-render your component when they occur.

TIP: You can still pass stores and actions as part of props when you need to -- local values override those from parents.

Isomorphism

Server-side rendering is possible by waiting until all actions have settled and then outputting the result. Clients can then use this result by having the stores dehydrate their state on the server and rehydrate them on the client.

Every request creates new instances of actions and stores so messages and state from one request doesn't' interfere with that of another.

import { render } from 'afflux';
import express from 'express';

let app = express();

app.use((req, res, next) => {
    const component = <App stores={stores} actions={actions}/>;

    render(component).then(result => {
        res.send(result);
    }, next);
});
import { render } from 'react';

const component = <App stores={stores} actions={actions}/>;
const root = document.querySelector('#content');

render(component, root);

Patterns

Dispatcher

Observing all events:

To observe all actions, simply merge them all together.

import { merge, observe } from 'most';

const all = merge(actions.a, actions.b, ...);

observe(all, (evt) => {
    console.log('Got event', evt);
});

Waiting for other stores:

Generally when you wait for another store it's because you want to use its result as part of the new value in your store (combined with whatever actions your store observes). This can be achieved with a flatMap combinator.

import { map, flatMap, take } from 'most';
import { partial } from 'lodash';

function compute(action, todo) {
    // Do something with both action and todo
    return { ... };
}

const stream = flatMap(
    (result) => map(partial(compute, result), take(1, todos)),
    action
);

Roughly this works as follows:

  • action emits an event
  • Remember that event and combine it with the next event in todos
  • Call compute with both of those values and emit the result

You can also explicitly wait for a stream by turning it into a promise with drain.

import { drain, take } from 'most';

const result = drain(take(1, store.todos));
result.then(() => {
    console.log('Finished waiting for todos');
});

Models

{name} has no model class, however it's easy to pattern models analogous to those of backbone using immutable. Note that models have no methods since they cannot sensibly modify themselves - they are never attached to a store, so save, load, etc. are meaningless.

import { Record } from 'immutable';

class MyModel extends Record({ a: 1, b: 2 }) {
    get isAdmin() {
        return this.a > 3;
    }
}

const test = new MyModel();
const derp = new MyModel({ a: 5, b: 7 });

console.log(derp.isAdmin);

Collections

{name} has no collection class, however it's easy to pattern collections analogous to those of backbone using immutable and some stream combinators. Collections are stores that accumulate changes to a set of objects over time.

import { Map, fromJS } from 'immutable';
import { merge, map, flatMapError } from 'most';
import accumulate from 'afflux/lib/combinators/accumulate';
import update from 'afflux/lib/combinators/update';

export default function createCollection(actions, initialValue) {

	const updates = merge(
		update((todos, todo) => todos.set(todo.id, todo), actions.create),
        update((todos, todo) => todos.delete(todo.id), actions.delete),
		update((_, todos) => todos, actions.rehydrate)
	);

	const s = flatMapError(() => updates, updates);

	const initialValue = base ? fromJS(base) : Map();

	const stream = map(entry => entry.toJS(), accumulate(initialValue, s));


	return { ...actions, source: stream.source, id: 'todo' };
}

Sources

Sometimes information about a single entity is the result of more than one action - maybe you have chat messages that can come from an HTTP API call and from a socket.io event stream. You can use stream combinators to combine these sources for your store.

import { merge, fromEvent } from 'most';

class ChatMessageStore {
    constructor(actions, io) {
        const stream = merge(actions.a, fromEvent('message', io));
    }
}

NOTE: Information from non-action stream sources cannot be accurately detected when using server-side rendering. This pattern should be used on the client only.

Optimistic Updates

Since actions are just streams of promises, you can simply perform updates before the promise finishes - if the promise is rejected then you revert back to the old value, and if it resolves you simply ensure the current value is the actual value.

var beep = action(function(message) {
    if (Math.random() > 0.5) {
        Promise.resolve('beep');
    } else {
        Promise.reject('bop');
    }
});

const optimistic = map((message), beep);
const actual =

flatMapError(e => startsWith(old, stream), stream);

const stream = merge(optimistic, actual);

You can extend this to the collection pattern to perform optimistic updates for entire collections as well.

Testing

Easy to test using any test framework that supports promises. Such a possible combination is mocha, chai and chai-as-promised.

Actions

import TodoActions from 'actions/todos.action';
describe('#create', () => {

    let actions;

    beforeEach(() => {
        actions = new TodoActions();
    });

    it('should create a new todo', () => {
        return expect(actions.create).to.eventually.equal({ <1>
            foo: 'bar'
        });
    });
});

<1> Since actions return promises, we can just test the value of the promise directly.

Stores

import { never, of as just } from 'most';
import TodoStore from 'stores/todos.store';

describe('todos', () => {
    it('should add created todo', () => {
        const actions = { create: just({ id: 5 }), update: never };
        const store = new TodoStore(actions);
        // Since stores are also promises, we can just test the value of
        // the promise directly.
        return expect(store.todos).to.eventually.contain({ id: 5 });
    });
})

Views

Testing views is slightly more involved since React and the DOM are now involved. Stubbing out actions and stores are both straightforward, however, and follow from the previous two types of testing.

import View from './view';
import { jsdom } from 'jsdom';
import { renderComponent } from 'react';

describe('View', () => {

    const html = '<!doctype html><html><body><div id="test"/></body></html>';
    let view, actions, stores, document, target;

    function render(view) {
        return renderComponent(view, target);
    }

    beforeEach(() => {
        document = jsdom(html);
        target = document.getElementById('test');
        actions = {
            test: stub().returns(Promise.resolve('yes'))
        }
        stores = {
            todos: emitter()
        }
    });

    describe('#render', () => {
        it('should add todo when add button clicked', () => {
            const view = <View actions={..} stores={..}/>;
            let node = render(view);
            node.button.click();
            expect(actions.test).to.be.calledOnce;
        });
        it('should display list of todos from store', () => {
            const view = <View actions={..} stores={..}/>;
            stores.todos.emit({ id: 5, text: "hello" });
            let node = render(view);
            expect(node.props.children).to.have.length(1);
        });
    });
});

bibliography