Evan Schultz
How we can use Redux + Angular to create a reactive architecture that can help us solve some of these common Angular problems.
Focused on data flows and propagation of change
Redux is a predictable state container for JavaScript apps.
(state, action) => state
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];
}
};
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
}
}]
let finalState = actions
.reduce((state, action) =>
todoState(state, action), /* reducer */
[] /* initial state */ )
console.log(finalState)
let finalState = actions.reduce(todoState, [])
console.log(finalState)
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;
}
}
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);
});
export function joinLine(numberOfPeople) {
return {
type: PARTY_JOINED,
payload: {
partyId: ++partyIndex,
numberOfPeople: parseInt(numberOfPeople, 10)
}
};
}
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);
});
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;
{
"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
}]
}
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.connect(mapStateToTarget, [mapDispatchToTarget])(target)
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
};
}
};
export function joinLine(numberOfPeople) {
return {
type: PARTY_JOINED,
payload: {
partyId: getNextPartyId(),
numberOfPeople: numberOfPeople
}
};
}
export function leaveLine(id) { /* snip */ }
export default {
joinLine, leaveLine
};
People in line: {{lineupSummary.total}}
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)
};
}
};
"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 |
https://github.com/neilff/redux-ui-router
Or, npm install it
$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: ' '
}
}
})
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)
};
}
};
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)
};
}
};
$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
}
}
})
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))
};
}
}
<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>
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;
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>
GONE!
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);
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)
};
});
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);
}
}
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);
}
}
... yes this should have been Step 1.
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}}]
};
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);
});
Small demo apps with Redux, ngRedux and Angular