rpominov / fluce

[DEPRECATED] Flux with immutable data and pure action handlers
MIT License
75 stars 1 forks source link

Middleware or something to allow "magic" (e.g. undo, time travel) #4

Open rpominov opened 9 years ago

rpominov commented 9 years ago

Here goes one idea of how it could be done (not sure if it's good enough):


When creating a Fluce instance you can provide a middleware that will be used to change default fluce behavior regarding update stores state. A middleware is a function that returns another function:

const myMiddleware = (replaceState) => {
  // The returned function will be called when fluce want to update its state.
  // It is called with the state object that contains state of all stores,
  // (the same object that is available as `fluce.stores`).
  return (newState) => {
    // Now you're in charge, and can decide whether the state will be changed
    // and to what value. To set the new state you should call the
    // `replaceState` function. You can not call `replaceState` on a request
    // from fluce, and you can also call it at any time you want. For instance,
    // you can delay all changes like this:
    setTimeout(() => replaceState(newState), 1000);
  }
}

A no op middleware is looks like this (replaceState) => replaceState, it won't change the default behavior.

To set a middleware you need to pass it to createFluce() function:

const fluce = createFluce(myMiddleware);

This feature allows you to implement advanced stuff like "time travelling" or "undo" from this prototype by @gaearon.


Perhaps we should also allow hooks on before action dispatch...

gaearon commented 9 years ago

If you go that road (it might be fun!) the API might need to be a bit smarter. I haven't figured it out myself yet so for now I'm just dumping everything I need right into my dispatcher, but at the very least middleware must be powerful enough to express the concept of transactions.

Here's a working implementation of transactions (built into dispatcher—but I want to somehow turn this into middleware later on). Call dispatcher.transact() at any moment, and the actions occurring after it can be committed by calling commit(), or cancelled by calling rollback() on the returned object. This is not the most interesting part. If a hot reload occurs while a transaction is active, the staged actions will be replayed on top of the last committed state. Sorry if this doesn't make sense—as redux grows more mature, I'll make some demos. But for now you can

  1. npm start
  2. Click the button a few times, see the counter increment (e.g. to 3)
  3. Change store logic to do +10 on increment action
  4. Click the button a few times, see the counter increment with +10 from 3 (e.g. to 33)
  5. Now comes the interesting part! Save a reference to dispatcher somewhere in a global object and call tx = dispatcher.transact() from DevTools console
  6. Click the button a few times, see the counter increment (e.g. two times—from 33 to 53)
  7. Change the store logic back to +1 on increment
  8. This time—because the transaction is active!—it will reapply your actions with the new code, and it will "go down" automatically to 35

Transactions let you replay a few last actions on each hot reload so that you can fine-tune your Store code until the actions are handled correctly. I'm positive that any middleware system needs to have all the necessary hooks to make this possible.

rpominov commented 9 years ago

Hm, I see how transactions work, but don't quite understand the use case. Where one will call dispatcher.transact() and transaction.commit() (in stores, action creators, app code?), and what kind of actions should be done in a transaction?

gaearon commented 9 years ago

It is meant to be done from a devtool. Imagine a devtool (like Chrome DevTools extension) that lets you go back to arbitrary state, or to mark a certain state as "committed" and have hot reload only re-execute a few actions you're currently debugging.

rpominov commented 9 years ago

Oh, I see, if we do something like this http://www.youtube.com/watch?v=Fo86aiBoomE Yes, transactions could be useful. Need to think about it.

rpominov commented 9 years ago

Have an idea, the API may look like this:

const fluce = createFluce(({dispatch, replaceState, reducer}) => {
  // In middleware you're given an object with functions, 
  // and must return an object with same shape. If you are not modifying 
  // a function, put the original one to the output object.
  //
  // This middleware doesn't do anything, it only shows interface of the object.

  return {

    // This is called when someone call `fluce.dispathc(type, payload)`
    // You can also call given `dispatch` function whenever you want.
    dispatch({type, payload}, curState) {
      dispatch({type, payload}, curState)
    },

    // This is called after `dispatch`, it replaces `fluce.stores` 
    // object with a new one, and notifies subscribers of changes.
    // Again you can call given `replaceState` whenever you want.
    replaceState(newState) {
      replaceState(newState)
    },

    // This is used to apply an action to all stores to get new state.
    // It is a pure function, you can use it to modify state object when needed.
    // You can also change its behavior, but make sure it stays a pure function.
    reducer(curState, {type, payload}) {
      return reducer(curState, {type, payload})
    }

  }
})

This is how transactions could be implemented using it (totally untested):


function createTransactMiddleware() {
  let _replaceState
  let _reducer

  let inTransaction = false
  let capturedState
  let capturedActions

  return {
    transact() {
      if (inTransaction) {
        throw new Error('can\'t nest transactions')
      }

      inTransacrion = true
      capturedActions = []

      return {
        commit() {
          inTransaction = false
          capturedState = undefined
          capturedActions = undefined
        },
        cancel() {
          if (capturedState) {
            _replaceState(capturedState)
          }
          inTransaction = false
          capturedState = undefined
          capturedActions = undefined
        },
        replay() {
          if (capturedState) {
            _replaceState(capturedActions.reduce(_reducer, capturedState))
          }
        }
      }

    },
    middleware({dispatch, replaceState, reducer}) {
      _replaceState = replaceState
      _reducer = reducer

      return {
        dispatch(action, curState) {
          if (inTransaction) {
            if (!capturedState) {
              capturedState = curState
            }
            capturedActions.push(action)
          }
          dispatch(action, curState)
        },
        replaceState,
        reducer
      }
    }
  }

}

const transactionMw = createTransactMiddleware()
const fluce = createFluce(transactionMw.middleware)

// ...

let tr = transactionMw.transact()

// ...

tr.repaly()
tr.cancel()

I'm going to play with it more after I finished with React bindings (<Fluce />, and connectStores). Also going to implement fluce.optimisticallyDispatch as a middleware.

gaearon commented 9 years ago

This sounds sensible!

gaearon commented 9 years ago

https://github.com/gaearon/redux/issues/6#issuecomment-108634552