-
-
Save gaearon/c02f3eb38724b64ab812 to your computer and use it in GitHub Desktop.
/** | |
* Stores are just seed + reduce function. | |
* Notice they are plain objects and don't own the state. | |
*/ | |
const countUpStore = { | |
seed: { | |
counter: 0 | |
}, | |
reduce(state, action) { | |
switch (action.type) { | |
case 'increment': | |
return { ...state, counter: state.counter + 1 }; | |
case 'decrement': | |
return { ...state, counter: state.counter - 1 }; | |
default: | |
return state; | |
} | |
} | |
}; | |
const countDownStore = { | |
seed: { | |
counter: 10 | |
}, | |
reduce(state, action) { | |
// Never mind that I'm doing the opposite of what action says: I'm just | |
// showing that stores may handle actions differently. | |
switch (action.type) { | |
case 'increment': | |
return { ...state, counter: state.counter - 1 }; | |
case 'decrement': | |
return { ...state, counter: state.counter + 1 }; | |
default: | |
return state; | |
} | |
} | |
}; | |
/** | |
* Dispatcher receives an array of stores and manages a global state atom, | |
* giving each store a slice of that atom using store index as an ID. | |
* | |
* It seeds the atom with the initial values and returns a dispatch function | |
* that, when called with an action, will gather the new reduced state and | |
* update the cursor with it. | |
*/ | |
function createDispatcher(cursor, stores) { | |
// Create the seed atom | |
const seedAtom = stores.map(s => s.seed); | |
cursor.set(seedAtom); | |
return function dispatch(action) { | |
// Create an atom with the next state of stores | |
const prevAtom = cursor.get(); | |
const nextAtom = stores.map((store, id) => | |
store.reduce(prevAtom[id], action) | |
); | |
cursor.set(nextAtom); | |
} | |
} | |
/** | |
* Creates a cursor that holds the value for the state atom. | |
*/ | |
function createCursor() { | |
let atom = null; | |
return { | |
get: () => atom, | |
set: (nextAtom) => atom = nextAtom | |
}; | |
} | |
/** | |
* A cursor middleware that lets consumer observe() mutations to individual stores. | |
*/ | |
function makeObservable(cursor) { | |
const observers = []; | |
/** | |
* Observes a store by its ID. | |
* Returns a real observable! | |
*/ | |
function observe(id) { | |
if (!observers[id]) { | |
observers[id] = []; | |
} | |
function subscribe(observer) { | |
// Immediately fire the current value (Zalgo!) | |
const atom = cursor.get(); | |
observer.onNext(atom[id]); | |
// Subscribe | |
const storeObservers = observers[id]; | |
storeObservers.push(observer); | |
function dispose() { | |
// Unsubscribe | |
const index = storeObservers.indexOf(observer); | |
if (index > -1) { | |
storeObservers.splice(index, 1); | |
} | |
} | |
return { dispose }; | |
} | |
return { subscribe }; | |
} | |
const wrapper = { | |
get() { | |
return cursor.get(); | |
}, | |
set(nextAtom) { | |
const prevAtom = cursor.get(); | |
cursor.set(nextAtom); | |
// Walk through each store's slice | |
for (let id = 0; id < nextAtom.length; id++) { | |
if (!observers[id] || !observers[id].length) { | |
continue; | |
} | |
// Notify the observers if state is referentially unequal | |
if (!prevAtom || prevAtom[id] !== nextAtom[id]) { | |
observers[id].forEach(o => | |
o.onNext(nextAtom[id]) | |
); | |
} | |
} | |
} | |
}; | |
return { observe, cursor: wrapper }; | |
} | |
it('whatever', () => { | |
let cursor = createCursor(); | |
let observe; | |
// Wrap cursor into the observation middleware: | |
({ cursor, observe } = makeObservable(cursor)); | |
// Pass stores to dispatcher | |
const dispatch = createDispatcher(cursor, [countDownStore, countUpStore]); | |
// We can now subscribe to store's individual updates without | |
// any involvement from the stores themselves: | |
const subscription = observe(1/* index in store array */).subscribe({ | |
onNext(countUpState) { | |
console.log('countup store state', countUpState); | |
} | |
}); | |
// Dispatch actions: | |
dispatch({ type: 'increment' }); | |
dispatch({ type: 'increment' }); | |
dispatch({ type: 'increment' }); | |
dispatch({ type: 'decrement' }); | |
// Unsubscription: | |
subscription.dispose(); | |
dispatch({ type: 'decrement' }); // Silent | |
// The *really* interesting part is left as an exercise to the reader: | |
// | |
// | |
// let cursor = createCursor(); | |
// let observe, peekAtPast, lock, unlock; | |
// ({ cursor, peekAtPast } = makePeekable(cursor)); // NEW! records values | |
// ({ cursor, lock, unlock } = makeLockable(cursor)); // NEW! ignores current atom and forces a constant | |
// ({ cursor, observe } = makeObservable(cursor)); // observe at the end of the chain | |
// | |
// | |
// Some boring stuff: | |
// | |
// | |
// const dispatch = createDispatcher(cursor, [countDownStore, countUpStore]); | |
// const subscription = observe(1/* index in store array */).subscribe({ | |
// onNext(countUpState) { | |
// console.log('countup store state', countUpState); | |
// } | |
// }); | |
// dispatch({ type: 'increment' }); | |
// dispatch({ type: 'increment' }); | |
// dispatch({ type: 'increment' }); | |
// dispatch({ type: 'increment' }); | |
// | |
// | |
// ... now comes the interesting part. | |
// | |
// | |
// const pastAtom = peekAtPast(2); // NEW! reaches back in time | |
// lock(pastAtom); // NEW! forces change handlers to always receive pastAtom instead of current atom | |
// ... | |
// unlock(); // NEW! switches to emit the current atom again | |
// | |
// | |
// Do you see? Because makeObservable() is last in chain, it will receive | |
// the values from makeLockable(). We can make a time travel interface on top of it, | |
// and components will receive past values as you drag a slider, but stores have | |
// *zero* knowledge of it and need no special time travelling logic. | |
}) |
uncomfortable about indexes for dispatches
Yeah, in real code that would be store string keys, but I figured indexes work well for a proof of concept.
having to register all stores at one go;
Agreed, I'm just doing something quick and dirty here.
In real code createDispatcher should probably return { dispatch, register }.
I'm still seeing value in doing a full replay
Yes. Both are valuable. For debugging stores, though, I'd rather have hot reload that replays actions, not replay during time travel. I haven't thought about how to fit not reload with true replay of actions into this model, but it shouldn't be hard.
I really like the idea of stores being seed + reduce functions that don't own state
. In my previous attempt at doing something like this, I ran into the quirkiest behavior with native array methods (map, reduce, push, splice), weirdness happening behind the scenes. Immutable.js didn't have the same issues because map/reduce is implemented differently and everything is guaranteed unique, but if I could do this without immutable - I would. I'm genuinely curious about the tradeoffs.
what quirky behavior? haven't heard of this before.
Thanks a lot for putting this out! I work on a Flux lib, that is hugely inspired by your prototype https://github.com/pozadi/fluce
So do I, now. :-)
http://github.com/gaearon/redux
+1 to the points above. I rewrote the core bits of Alt to contain store reducers:
https://gist.github.com/goatslacker/da0377e1413a526aa5ce
This gives us the ability to record dispatches and do full replays, and we have references to all the stores.
I really like your cursors approach though and how easy time traveling is with this approach.