A component is described by:
- A
render
function.
-
An
initialState
. -
An
actions
object with actions that return a new state and -optionally- async cancellable commands that resolve into another actions.
import React from 'react'; | |
import { build } from "./declarative-component"; | |
const initialState = { value: 0 }; | |
const actions = { | |
decrement: (num) => state => ({ ...state, value: state.value - 1 }), | |
increment: (num) => state => ({ ...state, value: state.value + 1 }), | |
}; | |
const render = (state, props, dispatch) => ( | |
<div style={{fontSize: props.size}}> | |
<div>Value: <b>{state.value}</b></div> | |
<button onClick={dispatch("decrement")}>-1</button> | |
<button onClick={dispatch("increment")}>+1</button> | |
</div> | |
); | |
export default build(render, actions, initialState, {name: "SimpleCounter"}); | |
import React from 'react'; | |
import { ajax } from 'rxjs/ajax'; | |
import { map } from 'rxjs/operators'; | |
import { Future } from 'fluture'; | |
import { fetchF } from 'fetch-future/src'; | |
import { Record, List } from 'immutable'; | |
import { build, commands, compose } from "./declarative-component"; | |
const { fromObservable, fromFuture, call } = commands; | |
// Helpers | |
const qrngUrl = "https://qrng.anu.edu.au/API/jsonI.php?length=1&type=uint8"; | |
// eslint-disable-next-line | |
const getPromiseRandomNumber = (max = 100) => { | |
return fetch(qrngUrl) | |
.then(res => res.json()) | |
.then(json => json.data[0] % max); | |
}; | |
// eslint-disable-next-line | |
const getFutureRandomNumber = (max = 100) => { | |
return fetchF(Future)(qrngUrl) | |
.chain(res => res.json) | |
.map(json => json.data[0] % max); | |
}; | |
// eslint-disable-next-line | |
const getObservableRandomNumber = (max = 100) => { | |
return ajax(qrngUrl) | |
.pipe(map(ajaxResponse => ajaxResponse.response.data[0] % max)); | |
}; | |
// Component | |
const initialState = { value: 0, messages: new List(), randomCommand: null }; | |
// Let's demostrate how to use a immutable.js Record as State with custom methods. | |
// IDEA: Use Adt (type-less) classes for state and action values -> with update function pattern-matching babel plugin | |
class State extends Record(initialState) { | |
addValue(num) { | |
return this.set("value", this.value + num); | |
} | |
clearMessages() { | |
return this.set("messages", this.messages.clear()); | |
} | |
addMessage(message) { | |
return this.set("messages", this.messages.unshift(message)); | |
} | |
setRandomCommand(randomCommand) { | |
return this.set("randomCommand", randomCommand); | |
} | |
} | |
const actions = { | |
add: (value) => state => state.addValue(value).addMessage(`Added: ${value}`), | |
clearMessages: () => state => state.clearMessages(), | |
logMessage: (message) => state => state.addMessage(message), | |
onRandomError: (err) => | |
compose(actions.logMessage(`Fetch error: ${err}`), actions.clearRandomResult()), | |
addRandom: () => (state, commands, props) => { | |
const randomCommand = | |
//fromFuture(getFutureRandomNumber(10), actions.addRandomResult, actions.onRandomError); | |
fromObservable(getObservableRandomNumber(10), actions.addRandomResult, actions.onRandomError); | |
const newState = state | |
.setRandomCommand(randomCommand) | |
.addMessage("Get a random number (0-10) from QRNG (async)") | |
return [newState, commands.add(randomCommand)]; | |
}, | |
clearRandomResult: () => state => state.setRandomCommand(null), | |
notifyParent: (ev) => (state, commands, props) => | |
[state, commands.add(call(props.onFinish, state.value))], | |
addRandomResult: (n) => compose( | |
actions.logMessage(`Random number: ${n}`), | |
actions.add(n), | |
actions.clearRandomResult(), | |
), | |
cancelRandom: () => (state, commands, props) => [ | |
state.setRandomCommand(null).addMessage("Random command cancelled"), | |
commands.cancel(state.randomCommand) | |
], | |
}; | |
const render = (state, props, dispatch) => ( | |
<div> | |
<div style={{fontSize: props.size}}> | |
Value: <b>{state.value}</b> | |
</div> | |
<div> | |
<button onClick={dispatch("add", -1)}>-1</button> | |
<button onClick={dispatch("add", +1)}>+1</button> | |
<button disabled={!!state.randomCommand} onClick={dispatch("addRandom")}>+Random</button> | |
<button disabled={!state.randomCommand} onClick={dispatch("cancelRandom")}>Cancel Random</button> | |
<button onClick={dispatch.withArgs("notifyParent")}>Notify parent</button> | |
<button onClick={dispatch("clearMessages")}>Clear log</button> | |
</div> | |
<div> | |
<pre>{state.messages.join("\n")}</pre> | |
</div> | |
</div> | |
); | |
export default build(render, actions, new State(), {name: "Counter"}); |
class CommandsPool { | |
constructor(dispatch, active = new Map()) { | |
this._dispatch = dispatch; | |
this._active = active; | |
} | |
_clone() { | |
console.log("commands", this.size(), this.keys()); | |
return new this.constructor(this._dispatch, this._active); | |
}; | |
_removeFromActiveAndDispatch(key, action) { | |
this.remove(key); | |
if (action) { | |
this._dispatch(action); | |
} | |
} | |
/* Public interface */ | |
keys() { | |
return Array.from(this._active.keys()); | |
} | |
size() { | |
return this._active.size; | |
} | |
add(command, optionalId = null) { | |
const key = optionalId || command; | |
// Check if active command with the same key exists and cancel it | |
if (this._active.has(key)) { | |
const info = this._active.get(key); | |
if (info.cancelFn) { | |
console.log(`Command with key ${key} already active, cancel`) | |
info.cancelFn(); | |
} | |
} | |
const cancelFn = command(this._removeFromActiveAndDispatch.bind(this, key)); | |
const info = { command, cancelFn } | |
this._active.set(key, info); | |
return this._clone(); | |
} | |
remove(commandOrId) { | |
this._active.delete(commandOrId); | |
return this._clone(); | |
} | |
cancel(commandOrId) { | |
const commandInfo = this._active.get(commandOrId); | |
if (commandInfo) { | |
this._active.delete(commandOrId); | |
if (commandInfo.cancelFn) { | |
commandInfo.cancelFn(); | |
} | |
} | |
return this._clone(); | |
} | |
cancelAll() { | |
this._active.forEach(info => info.cancelFn && info.cancelFn()); | |
this._active.clear(); | |
return this._clone(); | |
} | |
} | |
const fromPromise = (promise, onSuccess, onError) => dispatch => { | |
promise.then( | |
val => dispatch(onSuccess(val)), | |
onError ? err => dispatch(onError(err)) : null, | |
); | |
// ES6 Promises are not cancellable, but others (Bluebird) have a cancel method | |
return promise.cancel ? () => promise.cancel() : null; | |
}; | |
const fromFuture = (future, onSuccess, onError) => dispatch => { | |
return future.fork( | |
err => dispatch(onError(err)), | |
val => dispatch(onSuccess(val)), | |
); | |
}; | |
const fromObservable = (observable, onSuccess, onError) => dispatch => { | |
const disposable = observable.subscribe( | |
val => dispatch(onSuccess(val)), | |
onError ? err => dispatch(onError(err)) : null, | |
); | |
return () => disposable.unsubscribe(); | |
}; | |
const call = (fn, ...args) => dispatch => { | |
fn(...args); | |
dispatch(null); | |
}; | |
export { CommandsPool, fromPromise, fromFuture, fromObservable, call }; |
import React from 'react'; | |
import memoize from "micro-memoize"; | |
import * as commands from './commands'; | |
const build = (render, actions, initialState, options = {}) => { | |
return class DeclarativeComponent extends React.PureComponent { | |
static displayName = options ? options.name : "DeclarativeComponent"; | |
constructor(props) { | |
super(props); | |
this.mounted = false; | |
this.state = { data: isFunction(initialState) ? initialState(props) : initialState }; | |
this.commands = new commands.CommandsPool(this.dispatch.bind(this)); | |
const memoizeOpts = { maxSize: 100 }; | |
this.memoizedPropDispatch = | |
memoize(this.propDispatch.bind(this, { onlyDispatcherArgs: true }), memoizeOpts); | |
this.memoizedPropDispatch.withArgs = | |
memoize(this.propDispatch.bind(this, { onlyDispatcherArgs: false }), memoizeOpts); | |
} | |
componentDidMount() { | |
this.mounted = true; | |
if (actions.onMount) | |
this.dispatch(actions.onMount()); | |
} | |
componentWillUnmount() { | |
if (actions.onUnmount) | |
this.dispatch(actions.onUnmount()); | |
this.commands.cancelAll(); | |
this.mounted = false; | |
} | |
componentWillReceiveProps(newProps) { | |
if (actions.onNewProps) | |
this.dispatch(actions.onNewProps(newProps)); | |
} | |
render() { | |
return render(this.state.data, this.props, this.memoizedPropDispatch); | |
} | |
propDispatch(options, actionKey, ...dispatchArgs) { | |
return (...args) => { | |
const action = actions[actionKey]; | |
if (!action) { | |
throw new Error("Unknown action:", actionKey); | |
} else { | |
const fullArgs = options.onlyDispatcherArgs ? dispatchArgs : [...dispatchArgs, ...args]; | |
console.log("Run action:", actionKey, fullArgs); | |
this.dispatch(action(...dispatchArgs, ...args)); | |
} | |
}; | |
} | |
dispatch(actionFn) { | |
if (this.mounted) { | |
const [newState, newCommands] = | |
normalizeActionResult(actionFn, this.state.data, this.commands, this.props); | |
this.setState({ data: newState }); | |
this.commands = newCommands || this.commands; | |
} | |
} | |
}; | |
}; | |
/* Action helpers */ | |
const normalizeActionResult = (actionFn, state, commands, props) => { | |
const result = actionFn(state, commands, props); | |
const [newState, newCommands] = toArray(result); | |
return [newState, newCommands || commands]; | |
}; | |
const compose = (...actionFns) => { | |
return (state, commands, props) => { | |
return actionFns.reduce(([currentState, currentCommands], actionFn) => { | |
return normalizeActionResult(actionFn, currentState, currentCommands, props); | |
}, [state, commands]); | |
}; | |
}; | |
/* Generic helper functions */ | |
const isFunction = | |
obj => !!(obj && obj.constructor && obj.call && obj.apply); | |
const toArray = obj => | |
obj === undefined || obj === null ? [] : (Array.isArray(obj) ? obj : [obj]); | |
const recomposeDeclarativeComponent = (...args) => Component => build(Component, ...args); | |
export { build, commands, compose, recomposeDeclarativeComponent }; |
import React from 'react'; | |
import SimpleCounter from './1simple-counter'; | |
import CounterWithRandomButton from './2counter-with-random-button'; | |
import Counter from './3extended-counter'; | |
const styles = {box: {border: "1px solid grey", padding: 5, margin: 5, display: "table"}}; | |
class App extends React.Component { | |
constructor(props) { | |
super(props); | |
this.state = {showCounter: true, childInfo: null}; | |
this.onFinish = this.onFinish.bind(this); | |
this.toggleCounter = this.toggleCounter.bind(this); | |
} | |
onFinish(value) { | |
this.setState({ childInfo : value }); | |
} | |
toggleCounter() { | |
this.setState({ showCounter: !this.state.showCounter, childInfo: null }); | |
} | |
render() { | |
const { showCounter, childInfo } = this.state; | |
return ( | |
<div> | |
<div style={styles.box}> | |
<div>Simple counter with no commands</div> | |
<SimpleCounter size={20} /> | |
</div> | |
<div style={styles.box}> | |
<div>Counter with random command (promise, not cancellable)</div> | |
<CounterWithRandomButton size={20} /> | |
</div> | |
<div style={styles.box}> | |
<div>Counter with random command (future, cancellable)</div> | |
<button onClick={this.toggleCounter}>{showCounter ? "Unmount" : "Mount"}</button> | |
{childInfo != null && (<span>Child says: {childInfo}</span>)} | |
{ /* TODO: Controlled component with state save: props <state> and <setState> */ } | |
{showCounter && <Counter size={20} onFinish={this.onFinish} />} | |
</div> | |
</div> | |
); | |
} | |
} | |
export default App; |
onMount
, onUnmount
, onNewProps(newProps)
.props
so it's easier to avoid inline arrow functions in render props.newState
from [newState
, commands]` return value). Some ideas: immutable.js Record, or ADT structures like adt.js or adt-simple.build
? does it matter?dispatcher
argument passed to render
is a wrapper of actions ready to be used as callback props. Is there a better name for it? it should be probably have a plural name.render
would be passing a dispatch function, with args: onClick={dispatch(actions.add, 1)}
. This requires additional work, as the return value of dispatch should be memoized to avoid re-renders.dispatch("add", 1)
.