cassiozen / useStateMachine

The <1 kb state machine hook for React
MIT License
2.38k stars 47 forks source link

Parameters when transitioning states #29

Closed jonathanj closed 3 years ago

jonathanj commented 3 years ago

It looks like there is currently no way to provide external information when transitioning states, I read the source code for send, and the examples (such as fetch) seem to internalise the external effect so that the data is available within effect. Apologies if I did a poor job of looking.

My use case is moving data from a subscription callback into the state machine. I need to manage an external lifecycle (add/remove subscriber), and data is supplied to me via callback to the subscriber. Firebase's realtime database is a concrete example:

const [state, send] = useStateMachine({firebaseValue: …})({
  initial: 'loading',
  states: {
    loading: {
      on: {
        SUCCESS: 'loaded',
        FAILURE: 'error',
      },
      effect(_, update, /* ??? */) {
        update(context => /* External value would be placed into the context here. */)
      },
    },
    // …
  },
})

React.useEffect(() => {
  // A hypothetical `send` API with parameters.
  const success = value => send('SUCCESS', value)
  const failure = error => send('FAILURE', error)
  const ref = firebase.database().ref('some/path')
  // Manage the subscriber lifecycle.
  ref.on('value', success, failure)
  return () => {
    ref.off('value', success)
  }
)

I realise it would be possible to achieve this with a ref, but I would prefer not having one foot in the door in terms of state and context.

cassiozen commented 3 years ago

Hi, good question. This is by design - Ideally, changing the state machine context is always a consequence of the state machine itself transitioning. It should never come from the outside.

My suggestion is, as you noted, internalise the effect:


const [state, send] = useStateMachine({firebaseValue: …})({
  initial: 'loading',
  states: {
    loading: {
      on: {
        SUCCESS: 'loaded',
        FAILURE: 'error',
      },
      effect(send, update) {
        const success = firebaseValue => {
          update(context => ({firebaseValue, ...context}))
          send('SUCCESS')
        }
        const failure = error => {
          update(context => ({error, ...context}))
          send('FAILURE')
        }
        const ref = firebase.database().ref('some/path')
        // Manage the subscriber lifecycle.
        ref.on('value', success, failure)
        return () => {
          ref.off('value', success)
        }
      },
    },
    // …
  },
})

But this will unsubscribe as soon as the first piece of data is fetched. Another strategy would be using nested states, which this library doesn't support yet (working on it). A nested state would allow you to do something like this:

const [state, send] = useStateMachine({firebaseValue: …})({
  initial: 'subscribe',
  states: {
    subscribe: {
      initial: 'loading',
      on: {
        SUCCESS: 'active',
        FAILURE: 'error',
      },
      states: {
        loading: {},
        active: {},
        error: {}
      },
      effect(send, update) {
        const success = value => {
          update(context => ({value, ...context}))
          send('SUCCESS')
        }
        const failure = error => {
          update(context => ({error, ...context}))
          send('FAILURE')
        }
        const ref = firebase.database().ref('some/path')
        // Manage the subscriber lifecycle.
        ref.on('value', success, failure)
        return () => {
          ref.off('value', success)
        }
      },
    },
    // …
  },
})

This would start as subscribe > loading, then would either transition to subscribe > error or subscribe > active. The effect on the parent "subscribe" state would still be active, sending updates.

Right now, I would suggest using XState for this, and keep an eye on the progress on #21

jonathanj commented 3 years ago

Thanks for the clear and detailed reply, @cassiozen!

cassiozen commented 3 years ago

Closing for now, please feel free to reopen.