Skip to content

Instantly share code, notes, and snippets.

@nicolashery
Last active July 5, 2016 16:22
Show Gist options
  • Select an option

  • Save nicolashery/158dbcf8314a1ddd5e27 to your computer and use it in GitHub Desktop.

Select an option

Save nicolashery/158dbcf8314a1ddd5e27 to your computer and use it in GitHub Desktop.
// We can mentally separate "core state" from "derived state".
// Core state is where you can put normalized data (for relational data):
// it is the "single source of truth".
// Derived state is where you shape the core state into a representation that's
// closer to what you need in your views and components.
var coreState = {
venuesById: {
'v1': {id: 'v1', name: 'Hipster Coffee House'},
'v2': {id: 'v2', name: 'Veggies For Everyone'}
},
listsById: {
'l1': {id: 'l1', name: 'My Favorite Venues', venues: ['v1']},
'l2': {id: 'l2', name: 'My Other List', venues: ['v2']}
},
sharedListIds: ['l1']
};
var derivedState = {
listsWithVenues: [
{
id: 'l1',
name: 'My Favorite Venues',
venues: [
{id: 'v1', name: 'Hipster Coffee House'},
{id: 'v2', name: 'Veggies For Everyone'}
]
},
{
id: 'l2',
name: 'My Other List',
venues: [
{id: 'v2', name: 'Veggies For Everyone'}
]
}
],
sharedListsWithVenues: [
{
id: 'l1',
name: 'My Favorite Venues',
venues: [
{id: 'v1', name: 'Hipster Coffee House'},
{id: 'v2', name: 'Veggies For Everyone'}
]
}
]
};
var state = _.assign({}, coreState, derivedState);
var action = {
type: 'ADD_VENUE_TO_LIST',
venueId: 'v2',
listId: 'l1'
};
// When an action occurs, core state will be updated using the action, and then
// derived state can be computed by applying projections to the new state.
// You can think of:
// coreState = f(state, action)
// derivedState = f(state)
// Core state reducers
function venuesById(state, action) {
// ...
return state;
}
function listsById(state, action) {
switch (action.type) {
case 'ADD_VENUE_TO_LIST':
let list = _.cloneDeep(state[action.listId]);
list.venues.push(action.venueId);
let newState = _.clone(state);
newState[list.id] = list;
return newState;
// ...
default:
return state;
}
}
function sharedListIds(state, action) {
// ...
return state;
}
// Projections for derived state
var listsWithVenues = ['listsById', 'venuesById', function(state) {
return _.map(state.listsById, list => {
var venues = _.map(list.venues, venueId => state.venuesById[venueId]);
list = _.assign({}, list, {venues: venues});
return list;
});
}];
var sharedListsWithVenues = ['sharedListIds', 'listsWithVenues', function(state) {
return _.filter(state.listsWithVenues, list =>
state.sharedListIds.indexOf(list.id) > -1
);
}];
// Notice that projections depend on a certain subset or "slice" of state.
// We declare those dependencies as the first elements of an array, the last
// element being the actual projection function.
// Also notice that a projection can depend on other projections
// ("sharedListsWithVenues" depends on "listsWithVenues" in our example).
// We can use the core state "reducers" and the derived state "projections"
// in a "main reducer" (assume we are using the Redux library)
function mainReducer1(state, action) {
// First we compute core state
var newState = {
venuesById: venuesById(state.venuesById, action),
listsById: listsById(state.listsById, action),
sharedListIds: sharedListIds(state.sharedListIds, action)
};
// Then we compute derived state, manually fullfilling the dependencies
// of each projection
newState = _.assign(newState, {
listsWithVenues: _.last(listsWithVenues)({
listsById: newState.listsById,
venuesById: newState.venuesById
})
});
newState = _.assign(newState, {
sharedListsWithVenues: _.last(sharedListsWithVenues)({
sharedListIds: newState.sharedListIds,
listsWithVenues: newState.listsWithVenues
})
});
return newState;
}
// Since our projections declare their dependencies, we can use a function
// (much like the "auto" function from the "async" library) that will figure
// out what needs to be computed first etc.
function mainReducer2(state, action) {
var newState = auto({
// Core state
// Give "auto" plain functions with no dependencies
venuesById: venuesById.bind(null, state.venuesById, action),
listsById: listsById.bind(null, state.listsById, action),
sharedListIds: sharedListIds.bind(null, state.sharedListIds, action),
// Derived state
// Give "auto" arrays with dependencies and the projection function,
// it will figure out in which order to run them
listsWithVenues: listsWithVenues,
sharedListsWithVenues: sharedListsWithVenues
});
return newState;
}
// We can even go a step further, and "memoize" our projections
// (since we know which slice of state they depend on).
// This way they won't get re-computed on every single action, giving us
// a performance optimization for those that are more resource-intensive.
var listsWithVenuesMemoized = memoizeProjection(listsWithVenues);
var sharedListsWithVenuesMemoized = memoizeProjection(sharedListsWithVenues);
function mainReducer3(state, action) {
var newState = auto({
// Core state
venuesById: venuesById.bind(null, state.venuesById, action),
listsById: listsById.bind(null, state.listsById, action),
sharedListIds: sharedListIds.bind(null, state.sharedListIds, action),
// Derived state
// Projections will only be re-run if their slice of state changes
listsWithVenues: listsWithVenuesMemoized,
sharedListsWithVenues: sharedListsWithVenuesMemoized
});
return newState;
}
// Utilities
// -----------------------------------------------
function memoizeProjection(projection) {
projection = ensureArray(projection);
var dependencies = projection.slice(0, projection.length - 1);
var projectionFn = projection[projection.length - 1];
var cache = {
state: null,
result: null
};
var sliceState = function(state) { return _.pick(state, dependencies); };
if (!dependencies.length) {
sliceState = _.identity;
}
function memoized(state) {
var stateSlice = sliceState(state);
var prevStateSlice = sliceState(cache.state);
if (shallowEqualImmutable(stateSlice, prevStateSlice)) {
return cache.result;
}
var result = projectionFn(state);
cache.result = result;
cache.state = state;
return result;
}
memoized.cache = cache;
if (!dependencies.length) {
return memoized;
}
return [...dependencies, memoized];
}
// Inspired by https://github.com/caolan/async#auto
function auto(tasks) {
var keys = _.keys(tasks);
if (!keys.length) {
return;
}
var results = {};
var listeners = [];
function addListener(fn) {
listeners.unshift(fn);
}
function removeListener(fn) {
var idx = _.indexOf(listeners, fn);
if (idx >= 0) listeners.splice(idx, 1);
}
function taskComplete(key, taskResult) {
results[key] = taskResult;
_.forEach(listeners.slice(0), function(fn) {
fn();
});
}
_.forEach(keys, function(k) {
var task = _.isArray(tasks[k]) ? tasks[k] : [tasks[k]];
var requires = task.slice(0, task.length - 1);
// Prevent dead-locks
var len = requires.length;
var dep;
while (len--) {
if (!(dep = tasks[requires[len]])) {
throw new Error('Has inexistant dependency');
}
if (_.isArray(dep) && _.indexOf(dep, k) >= 0) {
throw new Error('Has cyclic dependencies');
}
}
function taskArg() {
return _.pick(results, requires);
}
function ready() {
return _.reduce(requires, function(a, x) {
return (a && results.hasOwnProperty(x));
}, true) && !results.hasOwnProperty(k);
}
var taskResult;
if (ready()) {
taskResult = task[task.length - 1](taskArg());
taskComplete(k, taskResult);
} else {
addListener(listener);
}
function listener() {
if (ready()) {
removeListener(listener);
taskResult = task[task.length - 1](taskArg());
taskComplete(k, taskResult);
}
}
});
return results;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment