Created
April 21, 2018 19:07
-
-
Save peerreynders/c49e939b378a2c236994fe014e284aa6 to your computer and use it in GitHub Desktop.
RxJS in Action Ch10 5C: Building the application
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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 C: Eliminate Redux using only RxJS | |
import React, {Component} from 'react'; | |
import PropTypes from 'prop-types'; | |
import ReactDOM from 'react-dom'; | |
import registerServiceWorker from './registerServiceWorker'; | |
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 makeReducer = (type, update, source$) => { | |
const makeReduce = ({payload: {account, amount}}) => { | |
if (typeof amount !== 'number' | isNaN(amount) || amount <= 0) { | |
return state => state; | |
} | |
return state => ({...state, [account]: update(getBalance(account,state), amount)}); | |
}; | |
const reducer$ = | |
filterOnType(source$, type) | |
.map(makeReduce); | |
return reducer$; | |
}; | |
const depositReducer = source$ => | |
makeReducer(DEPOSIT, depositAmount, source$); | |
const withdrawReducer = source$ => | |
makeReducer(WITHDRAW, withdrawAmount, source$); | |
const logReducer = source$ => { | |
const makeReduce = ({payload: {message}}) => | |
state => { | |
console.log(message); | |
return state; | |
}; | |
const reducer$ = | |
filterOnType(source$, LOG) | |
.map(makeReduce); | |
return reducer$; | |
}; | |
// --- 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$, getState) => { | |
const attachBalance = obj => { | |
const {value: {payload: {account}}} = obj; | |
const balance = getBalance(account, 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$, getState) => { | |
const account = SAVINGS; | |
const getAccountBalance = () => getBalance(account, 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 = (stateSource$, getState, epics) => { | |
const source$ = new Rx.Subject(); | |
const attachEpic = epic => epic(source$, getState); | |
const attachedEpics = epics.map(attachEpic); | |
const combinedEpics$ = | |
Rx.Observable | |
.merge(...attachedEpics) | |
.publish(); | |
combinedEpics$.subscribe(source$); // Ouroboros | |
combinedEpics$.subscribe(stateSource$); | |
const dispatch = action => source$.next(action); | |
return [combinedEpics$, dispatch]; | |
}; | |
// --- Listing 10.8 end | |
const makeState = (initialState, reducers) => { | |
const source$ = new Rx.Subject(); | |
const attachReducer = reducer => reducer(source$); | |
const attachedReducers = reducers.map(attachReducer); | |
const stateSubject$ = new Rx.BehaviorSubject(initialState); | |
const reduceState = (state, reduce) => reduce(state); | |
const state$ = | |
Rx.Observable | |
.merge(...attachedReducers) | |
.scan(reduceState, initialState) | |
.multicast(stateSubject$) | |
.refCount(); | |
const getState = () => stateSubject$.value; | |
return [state$, getState, source$]; | |
}; | |
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 Components | |
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 reducers =[ | |
logReducer, | |
depositReducer, | |
withdrawReducer | |
]; | |
const [state$, getState, stateSource$] = makeState(accounts(), reducers); | |
const epics = [ // p.298 | |
transactionLogEpic, | |
interestEpic | |
]; | |
const [epics$, dispatch] = createMiddleware(stateSource$, getState, epics); | |
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