Skip to content

Instantly share code, notes, and snippets.

@gaearon
Last active January 30, 2024 05:08
Show Gist options
  • Star 36 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save gaearon/c02f3eb38724b64ab812 to your computer and use it in GitHub Desktop.
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
/**
* 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.
})
@goatslacker
Copy link

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

@gaearon
Copy link
Author

gaearon commented May 6, 2015

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.

@RnbWd
Copy link

RnbWd commented May 6, 2015

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.

@threepointone
Copy link

what quirky behavior? haven't heard of this before.

@rpominov
Copy link

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

@gaearon
Copy link
Author

gaearon commented Jun 3, 2015

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment