-
-
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. | |
}) |
+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.
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
man, this is looking great!
ok now to be boring and nitpicky -
cheers man, will try an implementation soon.