Skip to content

Instantly share code, notes, and snippets.

@peerreynders
Created April 21, 2018 19:03
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save peerreynders/8f7c35c0fa3613198110457888d5bc09 to your computer and use it in GitHub Desktop.
Save peerreynders/8f7c35c0fa3613198110457888d5bc09 to your computer and use it in GitHub Desktop.
RxJS in Action Ch10 5B: Building the application
// file: src/index.js - Derived from:
// RxJS in Action (2017, Manning), 978-1-617-29341-2
// by Paul P. Daniels and Luis Atencio
// Listings:
// 10.6 Plugging into the middleware (p.297)
// 10.7 Implementing custom ofType operator (p.299)
// 10.8 Building your middleware (p.299)
// 10.9 Building the application (p.302)
//
// Variation B:
// - getState from exposed value of BahaviorSubject
// - Use React's Context and introduce WithdrawConnect component
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import ReactDOM from 'react-dom';
import registerServiceWorker from './registerServiceWorker';
import { createStore } from 'redux';
import Rx from 'rxjs';
import PouchDB from 'pouchdb-browser';
// --- Listing 10.7 Implementing custom ofType operator
// ** Use filterOnType in lieu of monkey patching
// ** Rx.Observable.prototype.ofType
const ofType = (types) => {
switch (types.length) {
case 0:
throw new Error('Must specify at least one type!');
case 1:
return ({type}) => type === types[0];
default:
return ({type}) => types.indexOf(type) > -1;
}
};
const filterOnType = (action$, ...types) =>
action$.filter(ofType(types));
// --- Listing 10.7 end
const CHECKING = 'checking';
const SAVINGS = 'savings';
const getBalance = (account, state) => state[account];
// action type
const TX_LOG = 'TX_LOG';
const LOG = 'LOG';
const WITHDRAW = 'WITHDRAW';
const DEPOSIT = 'DEPOSIT';
// action creators
const withdraw = (payload) => ({
type: TX_LOG,
payload: {...payload, type: WITHDRAW}
});
const deposit = (payload) => ({
type: TX_LOG,
payload: {...payload, type: DEPOSIT}
});
//
const depositAmount = (balance, amount) => balance + amount;
const withdrawAmount = (balance, amount) => balance - amount;
const updateBalance = (update, {account, amount}, state) => {
return typeof amount !== 'number' | isNaN(amount) || amount <= 0 ?
state :
{...state, [account]: update(getBalance(account, state), amount)};
};
// state updates
const depositTo = (payload, state) =>
updateBalance(depositAmount, payload, state);
const withdrawFrom = (payload, state) =>
updateBalance(withdrawAmount, payload, state);
// reducer
const updateAccounts = (state, action) => {
const {type, payload} = action;
switch(type) {
case WITHDRAW:
return withdrawFrom(payload, state);
case DEPOSIT:
return depositTo(payload, state);
case LOG:
console.log(payload.message);
return state;
default:
return state;
// eslint-disable-next-line no-unreachable
};
};
// --- Context
const StateContext = React.createContext();
// --- Listing 10.6 (p.297) Plugging into the middleware
const txDb = new PouchDB('transactions');
class Transaction {
constructor(type, account, amount, balance, timestamp) {
this.type = type;
this.account = account;
this.amount = amount;
this.balance = balance;
this.timestamp = timestamp;
}
}
const txWriteFailure = tx =>
_err => Rx.Observable.of({type: LOG, payload: {message: 'TX WRITE FAILURE', tx}});
const transactionLogEpic = (action$, store) => {
const attachBalance = obj => {
const {value: {payload: {account}}} = obj;
const balance = getBalance(account, store.getState());
return {...obj, balance};
};
const splitActionTx = ({value: {payload: data}, balance, timestamp}) => {
const {type, ...payload} = data;
const {amount, account} = payload;
const tx = new Transaction(type, account, amount, balance, timestamp);
return [{type, payload}, tx];
};
const postTx = ([action, tx]) => {
let source$;
try {
const promise = txDb.post(tx);
source$ = Rx.Observable.fromPromise(promise);
} catch(error) {
source$ = Rx.Observable.throw(error);
}
return (
source$
.mapTo(action)
.catch(txWriteFailure(tx))
);
};
return (
filterOnType(action$, TX_LOG)
.timestamp()
.map(attachBalance)
.map(splitActionTx)
.mergeMap(postTx)
);
};
// --- Listing 10.6 end
// p.303
const computeInterest = p => 0.1 / 365 * p;
const interestEpic = (action$, store) => {
const account = SAVINGS;
const getAccountBalance = () => getBalance(account, store.getState());
const depositInterest = balance => deposit({account, amount: computeInterest(balance)});
return (
Rx.Observable
.interval(2000)
.map(getAccountBalance)
.map(depositInterest)
);
};
// --- Listing 10.8 Building your middleware
const createMiddleware = (store, epics) => {
const source$ = new Rx.Subject();
const dispatchToStore = action => store.dispatch(action);
const attachEpic = epic => epic(source$, store);
const attachedEpics = epics.map(attachEpic);
const combinedEpics$ =
Rx.Observable
.merge(...attachedEpics)
.publish();
combinedEpics$.subscribe(source$); // Ouroboros
combinedEpics$.subscribe(dispatchToStore);
const dispatch = action => source$.next(action);
return [combinedEpics$, dispatch];
};
// --- Listing 10.8 end
// p.290
const createStreamFromStore = (store) => {
const getState = () => store.getState();
const initial = getState();
const stateSubject$ = new Rx.BehaviorSubject(initial);
const state$ =
Rx.Observable
.from(store)
.map(getState)
.multicast(stateSubject$)
.refCount();
return [state$, () => stateSubject$.value];
};
const appStatePropType = PropTypes.shape({
state$: PropTypes.shape({
subscribe : PropTypes.func.isRequired,
}).isRequired,
dispatch : PropTypes.func.isRequired,
getState : PropTypes.func.isRequired
});
// Presentation Components
const locale = 'en-US';
const currency = 'USD';
const balanceFormat =
new Intl.NumberFormat(locale, {style: 'currency', currency});
const AccountBalance = ({name, amount}) =>
<div>{name}: {balanceFormat.format(amount)}</div>;
AccountBalance.propTypes = {
name : PropTypes.string,
amount : PropTypes.number
};
const BalancesView =({checking, savings}) =>
<div>
<AccountBalance {...{name: 'Checking', amount: checking}}/>
<AccountBalance {...{name: 'Savings', amount: savings}}/>
</div>;
BalancesView.propTypes = {
checking : PropTypes.number,
savings : PropTypes.number
};
const toName = account => {
if(account && (typeof account === 'string') && (account.length > 0)){
const name = account.trim();
return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase();
}
return '';
};
const WithdrawView = ({amount, account, onChange, onClick}) => {
const type = 'number';
const value = typeof amount === 'number' ? amount : '';
return (
<div>
<fieldset>
<legend>Withdraw amount from {toName(account)}:</legend>
<input {...{type, value, onChange}} />
</fieldset>
<button {...{onClick}}>Withdraw</button>
</div>
);
};
WithdrawView.propTypes = {
amount : PropTypes.number,
account : PropTypes.string.isRequired,
onChange : PropTypes.func.isRequired,
onClick : PropTypes.func.isRequired
};
// Container Component
const initialAmount = () => ({amount: undefined});
class WithdrawContainer extends Component {
constructor(props) {
super(props);
this.state = initialAmount();
}
onChange = ({target: {value}}) => {
const newAmount = parseFloat(value);
const amount =
isNaN(newAmount) || newAmount < 0 ?
initialAmount().amount :
newAmount;
this.setState(prevState => ({...prevState, amount}));
}
handleClick = _event => {
const {
props: {withdraw, balance},
state: {amount}
} = this;
if (typeof amount !== 'number') {
this.setState(initialAmount);
} else if (balance > amount) {
withdraw(amount);
} else {
throw new Error('Overdraft error!');
}
}
render() {
const {handleClick: onClick, onChange, props: {account}, state: {amount}} = this;
return <WithdrawView {...{amount, account, onChange, onClick}} />;
}
}
WithdrawContainer.propTypes = {
account : PropTypes.string.isRequired,
balance : PropTypes.number.isRequired,
withdraw : PropTypes.func.isRequired
};
// Connect Component
const mapStateToProps = (state, props) => ({balance: getBalance(props.account, state)});
const mapDispatchToProps = (dispatch, props) => ({
withdraw: (amount => dispatch(withdraw({amount, account: props.account})))
});
const mergeProps = (stateProps, dispatchProps, props) =>
({...stateProps, ...dispatchProps, ...props});
class WithdrawConnect extends Component {
constructor(props) {
super(props);
this.state = {balance: 0};
this.subscription = null;
this.dispatchProps = null;
}
handleChange = (newState) => {
const {state: balance, props} = this;
const newProps = mapStateToProps(newState, props);
if (balance !== newProps.balance) {
this.setState((_prevState, _props) => newProps);
}
};
subscribe = (state$, dispatch) => {
const {props} = this
if(!this.subscription){
this.dispatchProps = mapDispatchToProps(dispatch, props);
this.subscription = state$.subscribe(this.handleChange);
}
};
componentWillUnmount() {
const {subscription} = this;
if(subscription) {
subscription.unsubscribe();
this.subscription = null;
this.dispatchProps = null;
}
}
withContext = ({state$, dispatch, getState}) => {
const {subscription, state, props} = this;
let stateProps;
if(subscription) {
stateProps = state;
} else {
this.subscribe(state$, dispatch);
stateProps = mapStateToProps(getState(), props);
}
const withdrawProps = mergeProps(stateProps, this.dispatchProps, props);
return <WithdrawContainer {...{...withdrawProps}} />
};
render() {
return <StateContext.Consumer>{this.withContext}</StateContext.Consumer>;
}
}
WithdrawConnect.propTypes = {
account : PropTypes.string.isRequired,
};
// --- Listing 10.9 Building the application
class Balances extends Component {
constructor(props) {
super(props);
this.unsubscribe = null;
}
handleChange = (_state) => {
this.forceUpdate(); // Trigger render
}
componentDidMount() {
const {handleChange, props: {subscribe}} = this;
this.unsubscribe = subscribe(handleChange);
}
componentWillUnmount() {
const {unsubscribe} = this;
if(unsubscribe) {
unsubscribe();
this.unsubscribe = null;
}
}
render() {
const {props: {appState}} = this;
const accounts = appState.getState();
const account = CHECKING;
return (
<StateContext.Provider {...{value: appState}}>
<BalancesView {...accounts} />
<WithdrawConnect {...{account}}/>
</StateContext.Provider>
);
}
};
Balances.propTypes = {
subscribe : PropTypes.func.isRequired,
appState : appStatePropType.isRequired
};
// initial state
const accounts = () => ({
[CHECKING]: 100,
[SAVINGS]: 100
});
const epics = [ // p.298
transactionLogEpic,
interestEpic
];
const store = createStore(updateAccounts, accounts());
const [epics$, dispatch] = createMiddleware(store, epics);
const [state$, getState] = createStreamFromStore(store);
const appState = {state$, dispatch, getState};
const subscribe = ((state$, epics$) =>
(...args) => {
const stateSub = state$.subscribe(...args);
const epicsSub = epics$.connect();
const unsubscribe = () => {
stateSub.unsubscribe();
epicsSub.unsubscribe();
};
return unsubscribe;
}
)(state$, epics$);
ReactDOM.render(
<Balances {...{subscribe, appState}} />,
document.getElementById('root')
);
// --- Listing 10.9 end
registerServiceWorker();
// cleanup
(() => {
const secs30 = 30000;
const timer$ = Rx.Observable.timer(secs30);
const beforeUnload$ = Rx.Observable.fromEvent(window, 'beforeunload');
const getAllDocs = () => {
const options = {include_docs: true};
return Rx.Observable.fromPromise(txDb.allDocs(options));
};
const logTx = row => {
const {doc: {type, account, amount, balance, timestamp}} = row;
console.log(`{${type} ${account} ${amount} ${balance} ${timestamp}}`);
};
const logResult = (result) => {
if (result.total_rows < 1) {
console.log('No transactions');
return;
}
console.log(`Rows: ${result.total_rows}`);
result.rows.forEach(logTx);
};
const destroyDb = () => txDb.destroy();
const logCleanUp = ({ok: isOk}) =>
console.log(`Cleanup - account: ${isOk}`);
Rx.Observable.race(timer$, beforeUnload$)
.switchMap(getAllDocs)
.do(logResult)
.switchMap(destroyDb)
.take(1)
.subscribe(logCleanUp);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment