Skip to content

Instantly share code, notes, and snippets.

@tokland
Last active July 8, 2018 17:07
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 tokland/7127cfb2074b4844f4f7dd212aa4d265 to your computer and use it in GitHub Desktop.
Save tokland/7127cfb2074b4844f4f7dd212aa4d265 to your computer and use it in GitHub Desktop.
Declarative React Component
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 { build, compose, commands } from "./declarative-component";
const { fromPromise } = commands;
const initialState = { value: 0, message: "" };
const getRandomNumber = (max) => {
return fetch("https://qrng.anu.edu.au/API/jsonI.php?length=1&type=uint8")
.then(res => res.json()).then(json => json.data[0] % max);
};
const actions = {
add: (num) => state => ({ ...state, value: state.value + num }),
setMessage: (message) => state => ({ ...state, message }),
setError: (err) => actions.setMessage(err.message || err),
addRandom: () => compose(
actions.requestRandomNumber(),
actions.setMessage("Random number requested"),
),
requestRandomNumber: () => (state, commands) => [
state,
commands.add(fromPromise(getRandomNumber(10), actions.addRandomSuccess, actions.setError)),
],
addRandomSuccess: (num) => compose(
actions.add(num),
actions.setMessage(`Random number received: ${num}`),
),
};
const render = (state, props, dispatch) => (
<div style={{fontSize: props.size}}>
<div>
<div>Value: <b>{state.value}</b></div>
<div>{state.message}</div>
</div>
<button onClick={dispatch("add", -1)}>-1</button>
<button onClick={dispatch("add", +1)}>+1</button>
<button onClick={dispatch("addRandom")}>+Random</button>
</div>
);
export default build(render, actions, initialState, {name: "CounterWithRandom"});
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 };

Functional stateful React components

A component is described by:

  1. A render function.
  1. An initialState.

  2. 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 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;

Notes

  • It support typical lifecycle events (as actions): onMount, onUnmount, onNewProps(newProps).
  • Actions have access to props so it's easier to avoid inline arrow functions in render props.
  • Any kind of object can be used as state except an array (so we can tell a newState from [newState, commands]` return value). Some ideas: immutable.js Record, or ADT structures like adt.js or adt-simple.

Open questions

  • Is there any operation that cannot be done with the abstractions defined?
  • When we want to cancel a command, we first need to save it in the state, but this may re-render the component with no need. Is this a problem?
  • Does it make sense to start commands with ID (easier to cancel when unique than saving command in state)
  • Implementation uses a HOC, would it be better to use Render Props instead?
  • HOC: Integration with recompose?
  • Should lifecycle events be normal actions or be passed as separate options to build? does it matter?
  • Is it better to group render arguments or spread them in typical React fashion?
  • The 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.
  • Another way to dispatch actions in 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.
  • time-travel is probably not possible when we have no actions but functions, maybe some indirection? dispatch("add", 1).

Related

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment