Created
April 21, 2018 18:50
-
-
Save peerreynders/5a40f42384530105c887a8e29f872dc8 to your computer and use it in GitHub Desktop.
RxJS in Action Ch10 3C: Simple banking form with checking text field and withdraw button
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 | |
// Listing: | |
// 10.3 Simple banking form with checking text field and withdraw button (p.289) | |
// | |
// Variation C: Use RxJS for state and use React.Context | |
import React, {Component} from 'react'; | |
import PropTypes from 'prop-types'; | |
import ReactDOM from 'react-dom'; | |
import registerServiceWorker from './registerServiceWorker'; | |
import Rx from 'rxjs'; | |
const CHECKING = 'checking'; | |
const SAVINGS = 'savings'; | |
const getBalance = (account, state) => state[account]; | |
// action type | |
const WITHDRAW = 'WITHDRAW'; | |
const DEPOSIT = 'DEPOSIT'; | |
// action creators | |
const withdraw = (payload) => ({ | |
type: WITHDRAW, | |
payload | |
}); | |
const deposit = (payload) => ({ // eslint-disable-line no-unused-vars | |
type: DEPOSIT, | |
payload | |
}); | |
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$ = | |
source$.filter(({type: value}) => (value === type)) | |
.map(makeReduce); | |
return reducer$; | |
}; | |
const depositReducer = source$ => | |
makeReducer(DEPOSIT, depositAmount, source$); | |
const withdrawReducer = source$ => | |
makeReducer(WITHDRAW, withdrawAmount, source$); | |
// --- Context | |
const StateContext = React.createContext(); | |
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> {/* Listing 10.3 */} | |
</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(); // local state for local values | |
} | |
onChange = ({target: {value}}) => { | |
const newAmount = parseFloat(value); | |
const amount = | |
isNaN(newAmount) || newAmount < 0 ? | |
initialAmount().amount : | |
newAmount; | |
this.setState(prevState => ({...prevState, amount})); | |
} | |
// --- Listing 10.3 Simple banking form with checking text field and withdraw button | |
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!'); | |
} | |
}; | |
// --- Listing 10.3 end | |
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, | |
}; | |
class Balances extends Component { | |
constructor(props) { | |
super(props); | |
this.unsubscribe = null; | |
} | |
handleChange = (_state) => { | |
this.forceUpdate(); // Trigger render | |
} | |
componentDidMount() { | |
const {handleChange, props: {appState: {state$}}} = this; | |
const subscription = state$.subscribe(handleChange); | |
this.unsubscribe = () => subscription.unsubscribe(); | |
} | |
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 = { | |
appState : appStatePropType.isRequired | |
}; | |
// initial state | |
const accounts = () => ({ | |
[CHECKING]: 100, | |
[SAVINGS]: 100 | |
}); | |
const reducers =[ | |
depositReducer, | |
withdrawReducer | |
]; | |
const [state$, getState, stateSource$] = makeState(accounts(), reducers); | |
const dispatch = action => stateSource$.next(action); | |
const appState = {state$, dispatch, getState}; | |
ReactDOM.render( | |
<Balances {...{appState}} />, | |
document.getElementById('root') | |
); | |
registerServiceWorker(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment