Reactive Architecture
with Redux and Angular
Evan Schultz
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
Welcome to TrendyBrunch
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
- Redux - framework agonstic state manager
- ngRedux - Angular bindings for Redux
- Supports DI for Middleware and Store Enhancers
- Simple API to work with
- Plays nice with redux-dev tools
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 |
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
Thanks!
- email: evan@rangle.io
- Github: http://www.github.com/e-schultz
- Twitter: @e_p82
- Reactiflux Slack: e-schultz
Questions?
Reactive Architecture with Angular
By Evan Schultz
Reactive Architecture with Angular
aReactive Architecture with Angular
- 6,154