-
-
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.