Skip to content
/ redurx Public

Redux'ish Functional State Management using RxJS

Notifications You must be signed in to change notification settings

shiftyp/redurx

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

90 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ReduRx: State Management with Observables

Travis CI Status: Travis CI Status Image

Basic Snippet

Always good to start out with a basic snippet:

import { createState, createAction } from 'redurx';

const increment = createAction();
const decrement = createAction();
const state = createState({
  counter: 0
});
const counter = state('counter');

counter
  .asObservable()
  .subscribe(num => console.log(num));

state.connect();
// 0

counter
  .reduce(increment, num => num + 1)
  .reduce(decrement, num => num - 1);

increment();
// 1
increment();
// 2
decrement();
// 1
decrement();
// 0

So what's going on here. We have some state, the counter, and we want to observe changes to this state from anywhere in our application. We want to change this state when events occur in our application, so we have action functions that we can call when those events happen. The way the state changes in response to those actions is defined functionally using reducers. In essence this is the Redux pattern, with two major differences.

First, the state tree is implemented with RxJS Observables, with every value, or node, in the tree having its own observable you can subscribe to. Second, actions creators are functions that have their own associated observable. We can hook the state observables and one or more additional observables together using reduce, which accepts a reducer function that works like a Redux reducer.

Using a tree of observables along with action observables has many implicit features. Async operations and promises without middleware; computing additional state functionally from other parts of state; caching or collecting previous values within observables; pausing, delays, retrys, intervals, throttling, sampling, and other observable features; are all possible with ReduRx.

So ReduRx is like Redux, only you can do more with less code. Redux is based on three basic principles, and ReduRx maintains these principles to allow you to write more predictable code. Given that, it makes sense to introduce the features of ReduRx using these principles as a guide:

#1 Single Source of Truth

With Redux there is single store object that stores state, and you describe the initial state of that store when it loads. You can do this either all at once when you create the store, or in parts as the reducer functions that manage the store are called.

ReduRx also maintains a single state tree; only each node in the tree has an observable associated with it. You can create the state tree all at once by calling createState with a value that you'd like to use as the initial state. When you're ready to use the state, you call connect on the state;

import { createState } from 'redurx';

const state = createState({
  todos: {
    list: [],
    search: {
      filter: '',
      query: '',
      dirty: true
    },
    error: null
  },
  todonts: {
    list: [],
    search: {
      filter: '',
      query: '',
      dirty: true
    },
    error: null
  },
});

state.connect();

Each node is a function that can be used to access any child node by passing a dot delimited path to that node. So to get the node for the todo list from the state you could do:

state('todos.list');
// or
state('todos')('list');

Each node has an observable associated with it that you can subscribe to. To get an observable call asObservable on the node.

state('todos.list')
  .asObservable()
  .subscribe(list => {
    console.log(`The list: ${JSON.stringify(list)}`);
  });
// The List: []

You don't have to define your initial state at the outset however. You can figure out what the state for any part of your tree is at any point in the future. You can set the initial state by calling setInitialState on the node, or by passing the initial state as a second argument when accessing the node:

import { createState } from 'redurx';

const state = createState();

state.setInitialState({
  todos: {
    list: [],
    search: {
      filter: '',
      query: '',
      dirty: true
    },
    error: null
  }
});

setTimeout(() => {
  state('todonts').setInitialState({
    search: {
      filter: '',
      query: '',
      dirty: true
    },
    error: null
  });

  state('todonts.list', []);

  state.connect();
}, 1000);

You can subscribe to changes on a node at any point though, even before the node has an initial value. If you've already connected the parent state before defining a new node, make sure to connect the child node after referencing it.

import { createState } from 'redurx';

const state = createState();

state('todonts.list')
  .asObservable()
  .subscribe(list => {
    console.log(`The list: ${JSON.stringify(list)}`);
  });

state.connect();

setTimeout(() => {
  state('todonts').setInitialState({
    list: [],
    search: {
      filter: '',
      query: '',
      dirty: true
    },
    error: null
  }).connect();
}, 1000);

// ...time passes
// The List: []

#2: State is Read only

Observables give you values, not the other way around.

#3: Changes are made with Pure Functions

So if we're subscribing to changes in state, then state must be changeable. This is where ReduRx is like Redux, in that you can write reducer functions that take the previous value for a node, and some data, and return a new value for the node. You provide this additional data as a set of observables, and you provide your reducer functions through the node's reduce api:

import Rx from 'rx';
// We defined and connected state somewhere else
import state from '../state';

const itemAction = new Rx.Subject();
const errorAction = new Rx.Subject();

const todoState = state('todos');
const listState = todoState('list');
const errorState = todoState('error');


const logStateWithType = (someState, type) => {
  someState
    .asObservable()
    .subscribe(list => {
      console.log(`The ${type}: ${JSON.stringify(list)}`);
    });
};

logStateWithType(listState, 'List')
// The List: []
logStateWithType(errorState, 'Error')
// The Error: null

todoState
  .reduce(itemAction, (state, item) => {
    return Object.assign({}, state, {
      list: [...state.list, item]
    });
  })
  .reduce(errorAction, (state, err) => {
    return Object.assign({}, state, {
      list: [],
      error: item.message
    });
  });

itemAction.onNext(42);
// The List: [42]
itemAction.onNext(50);
// The List: [42, 50]
errorAction.onNext(new Error('AHHHH!'));
// The List: []
// The Error: 'AHHHH!'

Notice that the reducers are returning state for the parent node, but these changes are being propagated to the child nodes. The reverse is also true, in that if you hook reducers into child nodes it will update the state for the parent node. In this case, all updates to child nodes that result from an identical hooked observable will only cause a single update on all parent nodes.

To make these "action creator" observables easier to manage, ReduRx also includes a createAction function, that creates an update function with an associated observable. The create action function takes a callback that allows you to configure the action's observable, allowing you to transform the arguments to the action. Here's a somewhat complete example of what the state and business logic for a todo list app might look like:

import axios from 'axios';
import { createAction } from 'redurx';
// We defined and connected state somewhere else
import state from '../state';

const todoState = state('todos').setInitialState({
  list: [],
  search: {
    filter: '',
    query: '',
    dirty: true
  },
  error: null
});

export const setTodoFilter = createAction((filters) => {
  return filters.distinctUntilChanged();
});

export const setTodoQuery = createAction((queries) => {
  return queries.distinctUntilChanged();
});

export const getTodos = createAction((submits) => {
  return submits
    .withLatestFrom(
      todoState('search.dirty').asObservable(),
      (_, dirty) => dirty
    )
    .filter(dirty => dirty)
    .withLatestFrom(
      todoState('search.filter').asObservable(),
      todoState('search.query').asObservable(),
      (_, filter, query) => ({ filter, query })
    )
    .flatMapLatest(params => axios.get('/api/todos', params)
      .then(result => result.data)
      .catch(err => {
        getTodosError(err);
        return [];
      }));
});

export const getTodosError = createAction();

todoState('search.filter')
  .reduce(setTodoFilter, (state, filter) => filter);

todoState('search.query')
  .reduce(setTodoQuery, (state, query) => query);

todoState('search.dirty')
  .reduce([setTodoFilter, setTodoQuery], () => true)
  .reduce(getTodos, () => false)

todoState('list')
  .reduce(getTodos, (state, list) => list);

todoState('error')
  .reduce(getTodos, () => null)
  .reduce(getTodosError, (err) => err);

Because you can reduce values from any observable, you can even use other parts of the state to create computed properties. It's possible to create an infinite loop this way, so make sure that you don't hook state into its own children.

import { createState } from 'redurx';

const state = createState({
  todos: {
    list: [{
      text: 'Some Todo',
      completed: false
    },{
      text: 'Some Other Todo',
      completed: true
    }],
    filteredList: [],
    filter: true
  }
});

state('todos.filteredList').reduce(
  [
    state('todos.list').asObservable(),
    state('todos.filter').asObservable()
  ],
  (filtered, [list, filter]) => (
    list.filter(todo => todo.completed === filter)
  )
);

// Call after you've hooked the state into itself
state.connect();

state('todos')
  .asObservable()
  .subscribe(todos => {
    console.log(`The Filtered List: ${JSON.stringify(todos.filteredList)}`)
  });
// The Filtered List: [{text:'Some Other Todo',completed:true}]

One Way Data Flow

ReduRx maintains one way data flow through your application just like any Flux framework. Data is aggregated from various sources using observables, and given some input you'll get predictable output. FRP FTW! Here's how it works with ReduRx where the state is an object, with two values bar and foo. A single action creator is hooked into the base object, and we're subscribing to changes on the base object as well. The flow goes from green to blue to red:

Diagram of one way data flow

How would I use this?

ReduRx, like Redux, can be used anywhere you'd like some functional state management. ReduRx is probably useful under a wider set of circumstances because you can subscribe to state changes anywhere in the tree, not just at the root. The obvious use for it is as a state container for React; and using something like recompose's observable utilities this turns out to be pretty simple:

// See setting the observable config in the recompose docs
// You'll want to set it for RxJS 4
import { mapPropsStream } from 'recompose';

import state from '../state';
import {
  setTodoFilter,
  setTodoQuery,
  getTodos
} from '../actions/todos';
import TodoSearchBar from './todo-search-bar';

const enhance = mapPropsStream(propsStream => {
  return propsStream
    .combineLatest(
      state('todos').asObservable(),
      (props, { list, search }) => ({
        ...props,
        list,
        search
      })
    );
})

const TodoList = enhance(({ list, search }) => {
  const searchActions = { setTodoQuery, setTodoFilter, getTodos };
  return (
    <div>
      <TodoSearchBar {...search} {...searchActions} />
      <ul>
        {list.map(todo => <li>{todo}</li>)}
      </ul>
    </div>
  );
})

Pretty cool right! This project is still in it's early stages (read alpha), but that's how every project we couldn't live without got started. However, the code has been tested in IE10 and Safari 5.1 (Windows), as well as all modern browsers. Bug reports and contributions are welcome.

Changelog

v0.4.0

  • Feature #18: Added ability to create composite nodes.
  • Feature #17: Chages to state shape are now allowed.

v0.3.3

  • Removed duplicative WeakMap shim

v0.3.2

  • Feature: Added shims for expanded browser support.
  • Bug: Fixed bug in ReTweet example webpack config.

v0.3.1

  • Bugfix: Action observables are now shared, eliminating duplicate side effects Bug
  • Bugfix: State can now be set on leaf nodes created provisionally as children of a tree node

v0.3.0

  • Feature: Breaking changes to the reducer api. hookReducers with its next and error reducers have been replaced by a single reduce function. This takes a single observable, or an array of observables; along with a reducer function. Silent errors are gone; and only you can prevent uncaught errors in your observables.

Smokey the Bear

  • Feature: Tested and documented previously available feature for setting a nodes initial state using the accessor function.
  • Feature: Added an error when a reducer returns undefined.
  • Feature: Improved unit tests, which cover errors and other new functionality.

License

ISC License

Copyright (c) 2016, Ryan Lynch

Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.

THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

About

Redux'ish Functional State Management using RxJS

Resources

Stars

Watchers

Forks

Packages

No packages published