Skip to content

Instantly share code, notes, and snippets.

@gregberns
Last active March 13, 2018 06:48
Show Gist options
  • Save gregberns/056ed55c861d4a83d75895a289841a98 to your computer and use it in GitHub Desktop.
Save gregberns/056ed55c861d4a83d75895a289841a98 to your computer and use it in GitHub Desktop.
UniDirectional Data Flow With React
//# Introduction
//The following is a generalized architecture based on the Elm architecture
// and the many subsequent derivations of it.
//The essential idea of the Elm architecture is that a User Interface has a
// 'uni-directional' data flow, which means data flows in a single directions.
//Here's a simple example:
// Button Click Event -> Handle Event -> Make Http Call -> Update State -> Update View
//Events occur which causes the system('services') to react to them. The system
// then sends commands to the state of the system to transition from 'State A' to 'State B'.
// The Views then re-render based on the change to the new State.
//# Adoption
//This architecture has been adopted by numerous frameworks:
// ClosureScript's Reframe - https://github.com/Day8/re-frame
// React's Redux - https://github.com/reactjs/redux
// PureScript's Halogen - https://github.com/slamdata/purescript-halogen
// This solution is broken into several components
// * Events - An event handling system is not seen in React, but 'Signals' are in Halogen
//The first half of this solution has to do
//# Event Dispatch and Handling
// Events are generally user initiated actions such as
// button clicks or the submision of a form
// The objective is to declaritively notify the system that something occured
//## Initiailization
// The event system needs to be initialized with one or more event handlers
// An event handler, when called, will accept a payload and process the event
// Things like Http calls, business rule calculations and such
// should occur in the handler
let eventHandlers = {};
function registerEvent(name, handler) {
eventHandlers[name] = handler;
}
//## Firing Events
// Once a view is initialized, events can be 'dispatched' or fired from it
// When an event is dispatched it's handler will be called and the effects
// of the handler may effect the rest of the system. It may initiate
function dispatchEvent(event) {
console.log(`Event '${event.name}' dispatched`);
//find event handler and apply the payload
eventHandlers[event.name](event.payload);
}
/* START -- NEW CODE */
// 'State' retains the applications state. The unique
//thing about application state is that everytime it
//is changed a new one is generated. The benefit is that
//each state can be saved and when debugging a problem it
//makes it easier to find issues. This could be turned
//off in prod.
class State {
constructor(initialState) {
this._state = initialState
}
//Required by Comonad
//Returns the value inside the comonad
extract() {
return this._state;
}
//Required by Comonad
//must follow the rules of Functor
//extend :: Extend w => w a ~> (w a -> b) -> w b
extend(f) {
return new State(f(this.extract()));
}
}
//'Store' will take an initial state and the action handlers.
//Actions will be dispatched to the Store and
class Store {
constructor(initialState, actionHandlers) {
this._history = [{
state: new State(initialState),
action: null
}];
this._handlers = actionHandlers;
this._subscribers = [];
}
current() {
return this._history[0].state;
}
_updateState(action, state) {
this._history.unshift({
state: state,
action: action
});
//update subscribers
this._subscribers.forEach(s => {
try {
console.log('Subscriber callback executed.');
s(this.current().extract());
} catch (e) {
console.error('Error on subscribe', e)
}
})
}
dispatchAction(action) {
console.log(`Action '${action.name}' dispatched`);
if (this._handlers[action.name] === undefined)
throw new Error(`Action handler not found: '${action.name}'`);
let handler = innerState => this._handlers[action.name](action.payload, innerState);
let newState = this.current().extend(handler);
this._updateState(action, newState);
}
//Some type of 'publish' mechanism is needed to
//alert subscribers of state changes.
//Need to add try/catch and logging around any subscriber calls
subscribe(callback) {
this._subscribers.push(callback)
}
//Thought: deprioritize view updates by delaying the subscribe to the next tick
}
// Add a 'diff' of the state function, to see differentces
//between states: 'what does the state transition look like??'
// instead of having to look at the whole object
/* END -- NEW CODE */
//# Application Code
// The application code below uses the framework code defined above.
const Complete = 'Complete';
const InProgress = 'InProgress';
//The initial application state
let initialState = {
todos: [{
text: 'An Initial ToDo',
status: InProgress
}],
filters: []
}
let actionHandlers = {
ADD_TODO: (payload, state) => {
let {todos, filters} = state;
todos = todos.concat({ text: payload, status: InProgress })
return {todos, filters};
},
INPROGRESS_TODO: (payload, state) => {
let {index, item} = payload
let {todos, filters} = state;
todos[index].status = InProgress;
return {todos, filters};
},
COMPLETE_TODO: (payload, state) => {
let {index, item} = payload
let {todos, filters} = state;
todos[index].status = Complete;
return {todos, filters};
},
REMOVE_TODO: (payload, state) => {
let { index, item } = payload;
let {todos, filters} = state;
todos = R.remove(index, 1, todos)
return {todos, filters};
}
}
let store = new Store(initialState, actionHandlers);
//Set up the UI
function render(state) {
//Click Handlers
let onAdd = (e) => {
if (e.key === 'Enter') {
store.dispatchAction({name: 'ADD_TODO', payload: e.target.value});
}
}
let onToggle = (index, item) =>
item.status === InProgress ?
store.dispatchAction({name: 'COMPLETE_TODO', payload: {index, item}}) :
store.dispatchAction({name: 'INPROGRESS_TODO', payload: {index, item}});
let onDelete = (index, item) =>
store.dispatchAction({name: 'REMOVE_TODO', payload: {index, item}});
//Components
let ListItem = React.createClass({
render() {
let textStyle = {
textDecoration: this.props.item.status === Complete ? 'line-through' : ''
}
return (
<li>
<span onClick={this.onToggle}
style={textStyle}>
{this.props.item.text}</span>
<span onClick={this.onClickDelete}> X</span>
</li>
);
},
onToggle() {
this.props.onToggle(this.props.index, this.props.item);
},
onClickDelete() {
this.props.onDelete(this.props.index, this.props.item);
}
});
const listItems = state.todos.map((item, index) =>
<ListItem index={index} key={index} item={item}
onToggle={onToggle}
onDelete={onDelete} />
);
let el = (
<div>
<h1>ToDos</h1>
<span>Enter some text, press enter:</span>
<input type="text" onKeyPress={onAdd} />
<ul>
{listItems || <li>None Found</li>}
</ul>
<div>(Click on the item to complete it, click the X to delete it)</div>
</div>
);
ReactDOM.render(
el,
document.getElementById('root')
);
}
//register to listen to changes
store.subscribe(render);
//load the ui
render(initialState);
/* NOTES */
//Query:
//App State is an Observable. This provides a way to:
//* Push changes on App State change
//* Create 'views' based off the base state
//View:
//One or more components that accept state or a 'view' off the main state
//Refrences:
// https://github.com/Day8/re-frame
//Maybe its not the Store thats Foldable, instead its a stream of Actions
//https://eventstore.org/blog/20130212/projections-1-theory/
// When we talk about a projection off of an event stream basically what we
// are describing is running a series of functions over the stream. We could
// as our simplest possible projection have a projection that runs through all
// of the events in the system (in order) passing current state from one
// function to the next. The simplest of these could be to count how many events there are.
//https://eventstore.org/blog/20130213/projections-2-a-simple-sep-projection/
//when() fn to register handlers/
//each function registered in 'when()' should supply state and the event payload
//emit() to emit events/changes?
// declaritively update state??
//Getter Getter
//() => () => Try<Option<T>>
//Enumerable / Enumerator - The Consumer is in charge!
// getEnumerator():Enumerator<T> / moveNext():bool, current():T
//Setter Setter
//Try<Option<T>> => () => ()
//Observable / Observer - The producer is in charge
// Subscribe(Observer<T>) / onCompleted(), onError(Throwable), onNext(T)
function dispatchEvent(event) {
console.log(`Event '${event.name}' dispatched`);
//find event handler and apply the payload
eventHandlers[event.name](event.payload);
}
// class State {
// constructor(initialState) {
// this._state = initialState
// }
// //Required by Comonad
// //Returns the value inside the comonad
// extract() {
// return this._state;
// }
// //Required by Comonad
// //must follow the rules of Functor
// //extend :: Extend w => w a ~> (w a -> b) -> w b
// extend(f) {
// return new State(f(this.extract()));
// }
// }
const State = state => ({
//based on the fantasy land comonad
//https://github.com/fantasyland/fantasy-land#comonad
//Note: `~>` means its a method not a function
//Comonads are also functors so must implement map
//map :: Functor f => f a ~> (a -> b) -> f b
map: f => State(f(state)),
//Required by Comonad
//Returns the value inside the comonad
extract: () => state,
//Required by Comonad
//extend :: Extend w => w a ~> (w a -> b) -> w b
extend: f => State(f(State(state)))
//this doesnt provide us a lot...
})
const LazyState = (f, state) => ({
//https://medium.com/@drboolean/laziness-with-representable-functors-9bd506eae83f
//map :: Functor f => f a ~> (a -> b) -> f b
map: g => LazyState(x => g(f(x)), state),
extract: () => f(state),
//extend: g => LazyState(x => g(f(x)), state)
})
const Identity = x => x;
//State.of Identity
//'Store' will take an initial state and the action handlers.
//Actions will be dispatched to the Store and
class Store {
constructor(initialState, actionHandlers) {
this._history = [{
//state: new State(initialState),
//state: State(initialState),
state: LazyState(Identity, initialState),
action: null
}];
this._handlers = actionHandlers;
this._subscribers = [];
}
current() {
return this._history[0].state;
}
dispatchAction(action) {
console.log(`Action '${action.name}' dispatched`);
if (this._handlers[action.name] === undefined)
throw new Error(`Action handler not found: '${action.name}'`);
//Either need to `map` over innerState...
let handler = innerState => this._handlers[action.name](action.payload, innerState);
let newState = this.current().map(handler);
//or extend over state
//does this mean that the function needs to know what type w is?
//let handler = state => this._handlers[action.name](action.payload, state.extract());
//let newState = this.current().extend(handler);
this._updateState(action, newState);
}
_updateState(action, state) {
this._history.unshift({
state: state,
action: action
});
//update subscribers
this._subscribers.forEach(s => {
try {
console.log('Subscriber callback executed.');
s(this.current().extract());
} catch (e) {
console.error('Error on subscribe', e)
}
})
}
//Some type of 'publish' mechanism is needed to
//alert subscribers of state changes.
//Need to add try/catch and logging around any subscriber calls
subscribe(callback) {
this._subscribers.push(callback)
}
//Thought: deprioritize view updates by delaying the subscribe to the next tick
}
//# Application Code
// The application code below uses the framework code defined above.
const Complete = 'Complete';
const InProgress = 'InProgress';
//The initial application state
let initialState = {
todos: [{
text: 'An Initial ToDo',
status: InProgress
}],
filters: []
}
let actionHandlers = {
ADD_TODO: (payload, state) => {
let {todos, filters} = state;
todos = todos.concat({ text: payload, status: InProgress })
return {todos, filters};
},
INPROGRESS_TODO: (payload, state) => {
let {index, item} = payload
let {todos, filters} = state;
todos[index].status = InProgress;
return {todos, filters};
},
COMPLETE_TODO: (payload, state) => {
let {index, item} = payload
let {todos, filters} = state;
todos[index].status = Complete;
return {todos, filters};
},
REMOVE_TODO: (payload, state) => {
let { index, item } = payload;
let {todos, filters} = state;
todos = R.remove(index, 1, todos)
return {todos, filters};
}
}
let store = new Store(initialState, actionHandlers);
//Set up the UI
function render(state) {
//Click Handlers
let onAdd = (e) => {
if (e.key === 'Enter') {
store.dispatchAction({name: 'ADD_TODO', payload: e.target.value});
}
}
let onToggle = (index, item) =>
item.status === InProgress ?
store.dispatchAction({name: 'COMPLETE_TODO', payload: {index, item}}) :
store.dispatchAction({name: 'INPROGRESS_TODO', payload: {index, item}});
let onDelete = (index, item) =>
store.dispatchAction({name: 'REMOVE_TODO', payload: {index, item}});
//Components
let ListItem = React.createClass({
render() {
let textStyle = {
textDecoration: this.props.item.status === Complete ? 'line-through' : ''
}
return (
<li>
<span onClick={this.onToggle}
style={textStyle}>
{this.props.item.text}</span>
<span onClick={this.onClickDelete}> X</span>
</li>
);
},
onToggle() {
this.props.onToggle(this.props.index, this.props.item);
},
onClickDelete() {
this.props.onDelete(this.props.index, this.props.item);
}
});
const listItems = state.todos.map((item, index) =>
<ListItem index={index} key={index} item={item}
onToggle={onToggle}
onDelete={onDelete} />
);
let el = (
<div>
<h1>ToDos</h1>
<span>Enter some text, press enter:</span>
<input type="text" onKeyPress={onAdd} />
<ul>
{listItems || <li>None Found</li>}
</ul>
<div>(Click on the item to complete it, click the X to delete it)</div>
</div>
);
ReactDOM.render(
el,
document.getElementById('root')
);
}
//register to listen to changes
store.subscribe(render);
//load the ui
render(initialState);
/*
HTML for react:
<div id="root"></div>
<script src="https://fb.me/react-15.1.0.js"></script>
<script src="https://fb.me/react-dom-15.1.0.js"></script>
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment