Reactive Architecture

with Redux and Angular





Evan Schultz

evan@rangle.io / @e_p82 / github.com/e-schultz

Introduction

... this is me

Created by Evan Schultz / @e_p82

Typical Angular problems

  • Where is the state being held?
  • What is the state of my application?
  • How to maintain shared state across multiple components?

What if you could....

  • View the entire state of your application
  • Derive your UI from this state
  • See how actions modified the state of your application

Redux + Angular

How we can use Redux + Angular to create a reactive architecture that can help us solve some of these common Angular problems.

What's on the agenda

  • Reactive Architecture
  • Flux
  • Overview of Redux
  • Reducers
  • ngRedux
  • Actions
  • Components

Reactive Architecture

What is Reactive Programming?

Focused on data flows and propagation of change

Bringing it back to Reactive Architecture

  • A global application state
  • Controlling the flow of data to and from this state
  • Deriving your application from this state

What the Flux?

Some of the challenges

  • Where does async happen?
  • Where do side effects happen?
  • Smart Stores vs Smart Actions
  • Conceptual boilerplate

How I got to Redux

Redux

Redux is a predictable state container for JavaScript apps.

Application as a global state

Reducers

(state, action) => state

TODO MVC

Reducer Edition

The reducer

let todoState = (state = [], action = {}) => {
  switch (action.type) {
  case 'TODO_ADDED':

    return [action.payload, ...state];
  case 'TODO_COMPLETED':

    return state.map(todo => 
        todo.id === action.payload.id ?
        Object.assign({}, todo, { completed: !todo.completed }) : todo
        );
  default:
    return [...state];
  }

};

The actions

let actions = [{type: 'TODO_ADDED',
  payload: { 
    id: 0,
    title: 'Start another TODO Example',
    completed: false
  }
}, {
  type: 'TODO_ADDED',
  payload: {
    id: 1,
    title: 'Reconsider TODO example',
    completed: false
  }
},{
  type: 'TODO_COMPLETED',
  payload: {
  id: 1
  }
}]

Putting it all together...

let finalState = actions
  .reduce((state, action) =>
    todoState(state, action), /* reducer */ 
    []                        /* initial state */ )

console.log(finalState)
let finalState = actions.reduce(todoState, [])
console.log(finalState)

What's going on?

  • The state is not mutating itself
  • The actions are plain JSON objects
  • I reconsidered yet another TODO-MVC example

Time for a quick preview...

Lets start with our first reducer

import {PARTY_LEFT, PARTY_JOINED} from '../actions/lineup-actions.js';
import * as R from 'ramda';

const INITIAL_STATE = []

export default function lineup(state = INITIAL_STATE, 
    action = { }) {

  switch (action.type) {
    case PARTY_JOINED: 
      // Ramda will return a copy of the state 
      // instead of modifying it.
      return R.append(action.payload)(state);
    default: 
        return state;
  }
}

Testing a reducer

it('should allow parties to join the lineup', () => {
    const initialState = lineup();
    const expectedState = [{
      partyId: 1,
      numberOfPeople: 2
      }];

    const partyJoined = {
      type: PARTY_JOINED,
      payload: {
        partyId: 1,
        numberOfPeople: 2
      }
    };

    const nextState = lineup(initialState, partyJoined);
    expect(nextState).to.deep.equal(expectedState);

  });

Action Creators

  • Should return plain JSON objects
  • .....unless using middleware
  • Are where your side effects happen
  • Are where you deal with async

A simple action

export function joinLine(numberOfPeople) {

  return {
    type: PARTY_JOINED,
    payload: {
      partyId: ++partyIndex,
      numberOfPeople: parseInt(numberOfPeople, 10)
    }
  };

}

And a quick test

it('should create an action for joining the line', () => {
  const action = lineupActions.joinLine(4);
  const expected = {
    type: lineupActions.PARTY_JOINED,
    payload: {
      partyId: 1,
      numberOfPeople: 4
    }
  };
  expect(action).to.deep.equal(expected);
});

Angular + Redux = ng-redux

What's next

  • Setting up our reducers with Redux
  • Configuring ngRedux
  • Using ngRedux in our controllers, and the $ngRedux.connect API
  • Components
  • ngReduxUIRouter

Setting up the reducers

import * as redux from 'redux';
import lineup from './lineup-reducer';
import tables from './table-reducer';
import {router} from 'redux-ui-router';
import menu from './menu-reducer';

const reducers = redux.combineReducers({
  lineup,
  tables,
  router,
  menu
});

export default reducers;

What's going on?

  • Construct a global state object
  • Each reducer becomes a key in the state
  • Each reducer now has a smaller area of concern

Preview of what this state could look like...

{
 
   "lineup":  [{
       "partyId": 1,
       "numberOfPeople": 5
     }]
   ,
   "tables": [{
     "id": 1,
     "numberOfSeats": 2,
     "status": "ORDERED",
     "order": {
       "pancake": 2
     }
   }, {
     "id": 2,
     "numberOfSeats": 4,
     "status": "ORDERING",
     "order": {
       "pancake": 2
     }
   }, {
     "id": 3,
     "numberOfSeats": 4,
     "status": "OCCUPIED",
     "order": {}
   }, {
     "id": 4,
     "numberOfSeats": 2,
     "status": "CLEAN",
     "order": {}
   }],
   "router": {
     "currentState": { /** snip **/ },
     "currentParams": {},
     "prevState": { /** snip **/ }
   },
   "prevParams": {}
 },
 "menu": [{
   "menuId": "pancake",
   "description": "Stack of Pancakes",
   "stock": 50,
   "price": 1.99
 }, {
   "menuId": "fruitbowl",
   "description": "Bowl of fresh fruit",
   "stock": 10,
   "price": 3.5
 }, {
   "menuId": "eggsbenny",
   "description": "Eggs Benedict",
   "stock": 30,
   "price": 4.95
 }, {
   "menuId": "hashbrowns",
   "description": "Crispy Golden Hashbrowns",
   "stock": 10,
   "price": 2.5
 }]
 }

Configuring $ngRedux

$ngReduxProvider

import reducers from './reducers';
import createLogger from 'redux-logger';

const logger = createLogger({ level: 'info', collapsed: true });

export default angular
.module('app', [ngRedux, ngUiRouter])
.config(($ngReduxProvider) => {
  $ngReduxProvider
    .createStoreWith(reducers       // our application state
    , ['ngUiRouterMiddleware',      // middleware - that supports DI
    logger                          // middleware - that doesn't need DI
    ]);
});

$ngRedux.connecting things to Angular

  • $ngRedux.connect api
  • Mapping the state to your controller
  • Mapping actions to your controller

The theory....

$ngRedux.connect(mapStateToTarget, [mapDispatchToTarget])(target)

The reality...

import lineupActions from '../../actions/lineup-actions';

export default class LineupController {
  constructor($ngRedux, $scope) {

    let disconnect = $ngRedux.connect(
        state => this.onUpdate(state),  // What we want to map to our target
        lineupActions                   // Actions we want to map to our target
        )(this);                        // Our target
    
    $scope.$on('$destroy', disconnect); // Cleaning house
  }

  onUpdate(state) {
    return {
      parties: state.lineup
    };
  }
};

The actions

export function joinLine(numberOfPeople) {
  return {
    type: PARTY_JOINED,
    payload: {
      partyId: getNextPartyId(),
      numberOfPeople: numberOfPeople
    }
  };
}

export function leaveLine(id) { /* snip */ }

export default {
  joinLine, leaveLine
};

Demo Time

Making something new, from something old

  • New feature derived from state
  • Responds to changes in state
  • Low complexity added

The template

People in line: {{lineupSummary.total}}

The controller

import * as R from 'ramda';

export default class LineupSummaryController {
  constructor($ngRedux, $scope) {

    let disconnect = $ngRedux.connect(state => this.onUpdate(state))(this);

    $scope.$on('$destroy', disconnect);
  }

  onUpdate(state) {
    return {
      total: R.reduce((acc, val) => acc + val.numberOfPeople, 0)(state.lineup)
    };
  }
};

Lets see that in action

Boston ... lets fix that problem

  • Lets bring a bit of React into our application

Components

"Smart" "Dumb"
Location Top level, route handlers Middle and leaf components
Aware of Redux Yes No
To read data Subscribe to Redux State Read data from properties
To change data Dispatch Redux Actions Invoke callbacks
Adopted from Redux Docs

A more complex component tree

  • DiningRoom
    • Smart, and passes data and callbacks down to menu
  • Table
    • Dumb - takes data and callbacks, and passes some down
  • Menu
    • Dumb, recieves data and callbacks

The smart dining room component

The table component

The menu component

In Summary...

  • DiningRoom knows about redux
  • It passes down data and callbacks
  • Table and Menu are only template + directive definition

Does it route?

ng-redux-ui-router

https://github.com/neilff/redux-ui-router

Or, npm install it

  • Still in early development
  • Use at own risk

What it does so far

  • Reducer to keep track of state
  • Responds to
    • $stateChangeStart
    • $stateChangeSuccess
    • $stateChangeError

What it does so far

  • Actions for
    • state.go -> stateGo
    • state.reload -> stateReload
    • state.transitionTo -> stateTransitionTo

Why?

  • Dispatched actions should be the only things that change state
  • Translates $stateChange events to actions
  • Dispatch actions to trigger state change

ui-router and components

  • Abstract states = smart
  • Leaf states = dumb

A first pass approach

everything is smart

The state configuration

$stateProvider.state('app.orders', {
  url: '/orders',
  abstract: true,
  views: {
    'orders@app': {
      template: '
', controller: 'OrdersController', controllerAs: 'orders' } } }).state('app.orders.completed', { url: '/completed', views: { 'orders@app.orders': { // only need a tag here - seems tempting template: '' } } })

Completed Orders Controller

export default class CompletedOrdersController {
  constructor($ngRedux, $scope) {
// But, it now needs to know about redux
    let disconnect = $ngRedux.connect(
        state => this.onUpdate(state), 
        tableActions)(this);

    $scope.$on('$destroy', () => disconnect());
  }
  mapOrders(order, menu) {
/*
Quite a bit going on here, it needs to know about the 
structure of our application state.

Not that reusable, is there a way we can do it better?
 */
    return {
      tableId: order.id,
      items: R.mapObjIndexed((value, key) => {
        let menuItem = R.find(menuItem => menuItem.menuId === key)(menu);
        return {
          menuId: key,
          qty: value,
          description: menuItem.description,
          price: menuItem.price,
          total: value * menuItem.price
        };
      })(order.order)
    };
  }
  onUpdate(state) {
    let completedOrders = R.filter(n => n.status === ORDERED)(state.tables);
    return {
      orders: R.map(order => this.mapOrders(order, state.menu))(completedOrders)
    };
  }
};

Pending Orders

looks awfully similar


export default class PendingOrdersController {
  constructor($ngRedux, $scope) {

    let disconnect = $ngRedux.connect(
      state => this.onUpdate(state))(this);
    $scope.$on('$destroy', () => disconnect());
  }

  mapOrders(order, menu) {

    return {
      tableId: order.id,
      items: R.mapObjIndexed((value, key) => {
        let menuItem = R.find(menuItem => menuItem.menuId === key)(menu);
        return {
          menuId: key,
          qty: value,
          description: menuItem.description,
          price: menuItem.price,
          total: value * menuItem.price
        };
      })(order.order)
    };

  }
  onUpdate(state) {
    let pendingOrders = R.filter(n => n.status === ORDERING)(state.tables);
    return {
      orders: R.map(order => this.mapOrders(order, state.menu))(pendingOrders)
    };
  }
};                
                

Can we do better?

Lets take a look

  • Orders get a little smarter
  • Introduce a container
  • Components get dumber

State Configuration

Smart orders component

$stateProvider.state('app.orders', {
        url: '/orders', // parent smart component
        abstract: true,
        views: {
          'orders@app': {
            template: '
', controller: 'OrdersController', controllerAs: 'orders' } } }).state('app.orders.completed', { url: '/completed', // container views: { 'orders@app.orders': { template: completedOrdersContainer } } })

The orders controller

export default class OrdersController {
  constructor($ngRedux, $scope) {

    let disconnect = $ngRedux.connect(
        state => this.onUpdate(state), 
        tableActions)(this);

    $scope.$on('$destroy', disconnect);
  }

  onUpdate(state) {
    let orderMap = (menu, tables) => {

      return R.map(table => {
        return {
          tableId: table.id,
          items: Object.keys(table.order || {}).map(function (key) {
            let menuItem = R.find(menuItem => menuItem.menuId === key)(menu);
            return {
              menuId: key,
              qty: table.order[key],
              description: menuItem.description,
              price: menuItem.price,
              total: table.order[key] * menuItem.price
            };
          })
        };
      })(tables);
    };

    return {
      pending: orderMap(state.menu, 
        R.filter(n => n.status === ORDERING)(state.tables)),
      completed: orderMap(state.menu, 
        R.filter(n => n.status === ORDERED)(state.tables))
    };
  }
}

Container Template

<completed-orders 
  <!-- Data passed down --> 
  orders="orders.completed"
  <!-- Callbacks passed down --> 
  on-add-item-to-order="orders.addItemToOrder(tableId,menuItemId)"
  on-remove-item-from-order="
    orders.removeItemFromOrder(tableId,menuItemId)"
  on-deliver-order="orders.deliverOrder(tableId)">
</completed-orders>

Role of the container

  • Knows about orders from the parent 'app.orders' state
  • Passes data down to the component
  • Passes callbacks down to the component

The component definition

import completedOrdersTemplate from './completed-orders-tpl.html';

export default angular
  .module('app.components.completedOrders', [])
  .directive('completedOrders', () => {
    return {
      restrict: 'E',
      template: completedOrdersTemplate,
      scope: {
        orders: '=',
        deliverOrder: '&onDeliverOrder',
        addItemToOrder: '&onAddItemToOrder',
        removeItemFromOrder: '&onRemoveItemFromOrder'
      }
    };
  }).name;

Role of the completed orders component

Render Data

<div ng-repeat="order in orders">
        Table: {{order.tableId}}

Invoke Callbacks

<button type="button" 
    ng-click="addItemToOrder(
                        {tableId:order.tableId, 
                          menuItemId:item.menuId})">+
</button>

The controller?

GONE!

The result...

  • Orders got smarter
  • Completed Orders got dumber
    • Only cares about the data, not where it came from

Unit testing our mapStateToTarget

  • Potential for quite a bit of logic to land here
  • Can we isoalte it, and make it testable?

Introducing Selectors

  • Compute derived data
  • Efficent
  • Composable

Step 1. decompose our mapToState functionality

let orderMap = (menu, tables) => {

  return R.map(table => {
    return {
      tableId: table.id,
      items: Object.keys(table.order || {}).map(function (key) {
        let menuItem = R.find(menuItem => menuItem.menuId === key)(menu);
        return {
          menuId: key,
          qty: table.order[key],
          description: menuItem.description,
          price: menuItem.price,
          total: table.order[key] * menuItem.price
        };
      })
    };
  })(tables);
};

let menuSelector = state => state.menu;
let pendingOrders = state => R.filter(n => n.status === ORDERING)(state.tables);
let completedOrders = state => R.filter(n => n.status === ORDERED)(state.tables);

Step 2. Create a selector with re-select

let ordersSelector = 
  createSelector(
    [menuSelector,    // can be an array of functions
    pendingOrders,    // and other selectors
    completedOrders], // will memoize and only execute if
                      // any of the paramaters change
    (menu, pending, completed) => {
    return {
      pending: orderMap(menu, pending),
      completed: orderMap(menu, completed)
    };
});

Step 3. Update our onUpdate

import ordersSelector from 'orders-selector';
export default class OrdersController {
  constructor($ngRedux, $scope) {

    let disconnect = $ngRedux.connect(state => this.onUpdate(state), tableActions)(this);

    $scope.$on('$destroy', disconnect);
  }

  onUpdate(state) {
    return ordersSelector(state);

  }

}

3.1 Or just replace it

import ordersSelector from 'orders-selector';
export default class OrdersController {
  constructor($ngRedux, $scope) {

    let disconnect = $ngRedux.connect(
      state => ordersSelector(state), 
      tableActions)(this);

    $scope.$on('$destroy', disconnect);
  }
}

Now to test it

... yes this should have been Step 1.

Setup our mock state

const mockState = {
  menu: [{
    menuId: 'pancake', description: 'Stack of Pancakes',
    stock: 50, price: 1.99
    }],
  tables: [{
    id: 'tableWithPending', status: ORDERING,
    order: {pancake: 1} },
    {
    id: 'tableWithCompleted',  status: ORDERED, 
    order: {pancake: 2}}]
  };

Write tests

it('should convert order to a collection of items', () => {
  const result = ordersSelector(mockState);
  const pendingOrders = result.pending[0];
  const completedOrders = result.completed[0];

  expect(pendingOrders).to.have.property('items');
  expect(completedOrders).to.have.property('items');
  });

it('should calculate the total of the items in the order', () => {
  const result = ordersSelector(mockState);
  const pendingItems = result.pending[0].items;
  const completedItems = result.completed[0].items;

  expect(pendingItems[0].total).to.be.equal(1.99);
  expect(completedItems[0].total).to.be.equal(1.99 * 2);
  });

In Summary

  • Store your application state in one place
  • Derive data from this state to build new features
  • Treat your UI as a represntation of state
  • Use Redux as your state container
  • Use ng-redux for Angular bindings

Some extra Resources

Small demo apps with Redux, ngRedux and Angular

Some extra Resources

Redux Documentation

Some extra Resources

ng-redux - Angular bindings for Redux

Some extra Resources

Awsome Redux - collection of Redux utilities

Thanks!

Questions?

Made with Slides.com