Last active
January 30, 2024 05:08
-
-
Save gaearon/c02f3eb38724b64ab812 to your computer and use it in GitHub Desktop.
Time travelling concept with reducey stores and state atoms inspired by https://gist.github.com/threepointone/43f16389fd96561a8b0b#comment-1447275
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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. | |
}) |
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Yeah, in real code that would be store string keys, but I figured indexes work well for a proof of concept.
Agreed, I'm just doing something quick and dirty here.
In real code createDispatcher should probably return { dispatch, register }.
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.