Skip to content

Instantly share code, notes, and snippets.

@TheDahv
Last active March 30, 2018 06:29
Show Gist options
  • Save TheDahv/bbb4ee9493db06222aa3b858ed8dfc78 to your computer and use it in GitHub Desktop.
Save TheDahv/bbb4ee9493db06222aa3b858ed8dfc78 to your computer and use it in GitHub Desktop.
State flow control (Redux, Flux, etc.) in 100 lines or less
/**
* This example tries to show how a one-way state flow control library--similar
* to libraries like Redux, Flow, Elm, etc.--could work. For a quick refresher
* on the problem we're solving, we want a way to:
*
* - make the state of our app easier to find and express
* - confine changes to our state to clear, simple, pure functions
* - update the app in response to events--either from the user or from the
* browser
*
* To borrow from Elm language, we want to establish a "model -> update -> view"
* flow.
*
* I started thinking about web applications as a stream of events describing
* user behavior over time. Then I thought about the UI of the application being
* the result of events and application state applied to update functions.
*
* When I work on streams problems, I always turn to Highland.js [0]. Highland
* gives me an elegant, high-level API to work with a stream of events and
* produce a new version of the UI in response to each event. In effect, the
* application is a reduction of the events and the application state over time.
*
* This isn't an example of something that should be put in production. I can't
* even recommend it as a new way to solve your web application management
* problems. But I do want to demystify some of the magic happening in your web
* frameworks and show how thinking of old problems in new ways can be really
* fun. The uncommented, ugly-code version of this fits in at less than 100
* lines, so I hope you're able to get something from this as I attempt to make
* it simple enough to comprehend.
*
* Read on to get a sense for what's going on and hopefully you have as much fun
* as I did!
*
* -- David
*
* [0] - https://highlandjs.org/
*/
/**
* First we pull in preact, which is a lightweight React-compatible library.
* There's no specific reason for me to use it over React for this problem other
* than that it's very lightweight
*/
const { h, render } = preact;
/**
* This list of actions describe the kind of events that can happen in our
* application. The only reason for this fancy-pants object building is just to
* give us a way to enumerate our actions and make it harder to make typos
* later.
*/
const actions = [
'CLOCK_UPDATE', 'COUNTER_ADD', 'COUNTER_SUBTRACT',
'FUTURE_MESSAGE', 'NAME_INPUT'
].reduce((memo, action) => Object.assign(memo, { [action]: action }), {});
/**
* This is where we start with Highland. Highland can wrap other stream-like
* objects (callbacks, events, generators, promises, arrays, etc.), but an empty
* Highland object can serve as both a readable and writable stream. This will
* be our global event bus.
*/
const bus = highland();
/**
* For sake of familiarity with an API you may already be familiar with, we set
* up a dispatch helper that we can pass around to components to send events
* into our bus. An event is just data: the kind of event, and any relevant metadata.
*/
const dispatch = (event = {}) => bus.write(event);
// run takes a DOM node, sets up the event stream processor, and kicks off the
// first render with the application's "empty" state.
function run(container) {
// Here we set up the beginning default empty state of the app
let initial = { count: 0, currentTime: new Date, futureMessage: '', name: '' };
/**
* After we run the initial render, we save a reference to Preact's render
* This lets Preact know to use its DOM diffing algorithm to replace the
* contents of the container rather than appending each time:
* https://preactjs.com/guide/api-reference
*/
let replaceNode = render(h(App, { ...initial }), container);
/**
* Here we have some helper functions to deal with events in the stream. We
* want to be able to both handle events that describe themselves
* synchronously, or events that trigger at some future point in time. For
* that, we use promises, so we need to split the event stream to handle them
* differently.
*/
const promise = event => typeof event.then === 'function';
// syncEvents are data that we can process right away.
const syncEvents = bus.fork().reject(promise).tap(console.log);
/**
* We set up a stream of asynchronous events so we can listen for them to
* resolve and then return them to the dispatch stream. This is important
* because we don't want to block the stream of synchronous events that we can
* process right now.
*/
const asyncEvents = bus.fork().filter(promise).each((promise) => promise.then(dispatch));
// We process the two normalized stream events together as they come in
highland.merge([ syncEvents, asyncEvents ]).
/**
* At this point, we want to take an event and the state of the application,
* and produce a new version of the state based on the changes indicated by
* the event.
*
* We could think of the event processing like a list reduction: given an
* initial value, a list of items, and a function combine the state and item
* to produce new values through the list, we can arrive at a final version
* of that value.
*
* For our app, the state is the initial value, and we reduce to the final
* state of the UI after applying all the changes.
*
* However, this is an endless stream of application events and we want the
* UI to update the whole time, not just at the end.
*
* For that we turn to scan [0]. It is just like reduce, but it also emits the
* each value as it is reducing over the event stream. That's great because
* we can then produce new HTML for each version of the app state.
*
* [0] http://highlandjs.org/#scan
*/
scan(initial, reduce).
/**
* Here we take each version of the app state and render it. We *could* work
* it in to the stream values, but HTML updates are a side-effect and all
* the code up to this point has been pure. We use Preact to render the
* top-level App component at each state update, using the 'replaceNode' we
* saved earlier so Preact knows to perform an update and not an append.
*/
each(state => render(h(App, { ...state }), container, replaceNode)).
// We want to 'consume' our Stream. Otherwise we just set up a large
// description of what *will* happen without ever actually kicking off the
// stream flow.
done(() => console.log('Ran out of events!'));
// By now, we have a way to pass state to our View code, and we can respond to
// changes. Let's show some of the asynchronous ways to trigger changes first.
// Here we just loop on every second and dispatch the current time. Since the
// 'dispatch' function is in scope here, we can just call it.
setInterval(() => dispatch({ type: 'CLOCK_UPDATE', time: new Date }), 1000);
// Here we show an example of dispatching an event whose data we don't know
// right at the moment. This could be something like waiting for a server to
// respond to an API call. Because our event stream knows how to handle
// Promises/Futures, this lets us handle async problems with an API pretty
// familiar to JS programmers.
dispatch(new Promise(resolve => {
const msg = {type: actions.FUTURE_MESSAGE, message: 'hi from the future'};
setTimeout(resolve.bind(null, msg), 2000);
}));
}
/**
* The reduce function is responsible for producing a new version of the
* application state in response to an event/action. It's actually kind of
* boring! That's good though since state management is usually where bugs come
* from so we want this to be simple.
*
* If this appplication were bigger, we would be either composing other
* collections of data-producing functions that deal with subsets of the
* application domain, or forwarding events on to sub-reducers to organize our
* code better.
*/
function reduce(state, event) {
let updated = Object.assign({}, state, (() => {
switch (event.type) {
default: return updated;
case actions.NAME_INPUT: return { name: event.name };
case actions.COUNTER_ADD: return { count: state.count + 1 };
case actions.COUNTER_SUBTRACT: return { count: state.count - 1 };
case actions.CLOCK_UPDATE: return { currentTime: event.time };
case actions.FUTURE_MESSAGE: return { futureMessage: event.message };
}
})());
// Quick and dirty logging like https://www.npmjs.com/package/redux-logger
console.log({ event, state, updated });
return updated;
};
/**
* App is the top-level component responsible for passing the pieces of state
* that are relevant to the smaller, more focused components in the app. This is
* similar to Redux' notion of a 'container'. It mostly exists as the bridge
* between the state management part of the framework and the pure UI rendering
* functions.
*
* We're not using JSX in any of this code, but the 'h' function is pretty
* simple to follow and I've nested the components to mimic the hierarchy
* you're used to seeing in HTML/JSX.
*/
function App(state) {
const { count, currentTime, futureMessage, name } = state;
return h('div', null,
// This example shows simple binding of state to input controls. Our HTML
// bits are scattered around, but it's easy to see how the input and output
// work together.
h('div', { className: 'form' },
// We hid some of the update behavior inside of NameForm component, so go
// look at that!
h(NameForm, { name })),
h('div', null,
h('p', null, `Hello ${name}`)),
// Here we wrap even more functionality into a higher-level component that
// will compose more components. This implements the usual demo this
// family of frameworks employs: http://elm-lang.org/examples/buttons
h(Counter, { count, className: 'counter' }),
// Clock is where we represent the setInterval clock tick we set up earlier
h(Clock, { time: currentTime }),
// And here we have a place to show the results of our Promise stream
// processing
h('div', null,
h('p', null,
h('strong', null, futureMessage)))
);
}
/**
* NameForm is our first example of hiding component details inside a component
* function. Notice how we define what fields we want from the state and then
* arrange our first event dispatching.
*/
function NameForm({ name } = {}) {
return h('input', {
type: 'text', placeholder: 'Name', value: name,
// Here we hook into Preact's component events to dispatch to the event
// stream
onInput: (evt) => {
dispatch({ type: 'NAME_INPUT', name: evt.target.value });
}});
}
/**
* Counter demonstrates composing smaller, simple custom components to wire up
* related behavior to our event dispatcher
*/
function Counter({ count, className } = {}) {
return h('div', { className },
h(CounterButton, { action: 'COUNTER_ADD', text: '+' }),
h('span', null, count),
h(CounterButton, { action: 'COUNTER_SUBTRACT', text: '-' }));
}
function CounterButton({ action, text } = {}) {
return h('button', { onClick: dispatch.bind(null, { type: action }), }, text);
}
function Clock({ time } = {}) {
return h('div', { className: 'clock' },
h('span', null, 'The time is: '),
h('strong', null, time.toJSON()));
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>State flow control (Redux, Flux, etc.) in 100 lines or less</title>
</head>
<body>
<div id="app"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highland/2.13.0/highland.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/preact/dist/preact.min.js"></script>
<script src="./annotated-app.js"></script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment