Skip to content

Instantly share code, notes, and snippets.

@peerreynders
Created April 21, 2018 18:50
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save peerreynders/5a40f42384530105c887a8e29f872dc8 to your computer and use it in GitHub Desktop.
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
// 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