Xstate: Help needed modeling this app with xstate

Created on 7 Apr 2019  ·  11Comments  ·  Source: davidkpiano/xstate

I'm practicing modeling ui behavior with xstate, and unfortunately got stuck at the beginning with this seemingly simple app (see screencap here).

How the app works

  • Once the app get started, it loads bus data immediately, then start a 15s timer for next reload (see the countdown on the top right corner)
  • During the 15s wait time, user can pull to refresh anytime to trigger a reload immediately which will cancel the timer
  • Once reload was completed, starts another 15s timer for next reload
  • If the network is down while reloading, show a loading indicator indefinitely

Statecharts

Here's my first attempt to model it but I can't figure out how to model the 15s timer thing on the chart, nor did I know where the timer logic should go in the code (I presume it belongs to actions?)

The code

Here's the machine code I got so far, would appreciate any help to get this through :)

const BusMachine = Machine(
  {

    id: "Bus App",

    context: {
      data: 1,
      reloadCounter: 15,
    },

    initial: "display",

    states: {
      display: {
        onEntry: 'loadData',
        // onExit: ['bar'],
        on: {
          "RELOAD": "loading"
        }
      },
      loading: {
        on: {
          "LOAD_FAIL": "error",
          "LOAD_OK": "display"
        }
      },
      error: {
        on: {
          "RELOAD": "loading"
        }
      }
    },

    on: {
      //
    }
  },
  {
    actions: {
      loadData: assign((ctx, evt) => {
        console.log( '\n[action loadData]', ctx, evt )
        const newData = getData()
        return {...ctx, data: newData}
        // countDownId = setInterval(this.countDown, 1000)
        // this.countDown();
      }),
      bar: assign((ctx, evt) => {
        console.log( '[action bar]', ctx, evt  )
        return ctx
      }),
    }
  },
)
question

Most helpful comment

I'm wondering why this approach hasn't been wildly adopted by the public?

That's a great question, and I should really write a blog/documentation entry on this.

First, state machines and statecharts _are_ widely adopted in other areas of technology such as embedded systems, hardware, aeronautics and automotive tech, game development, and more.

There is definitely a learning curve to using state machines and statecharts; you can't simply start coding away, you have to model your application first. This takes time and experience, but at least with tools like XState, you can modify your model as you go. Front-end developers in large are not used to modeling code up-front, so it's a change in discipline/behavior, and a departure from the status-quo "move fast break things" mentality.

However, I (and others, like @mogsie) are passionate about this tried-and-true methodology for modeling applications and realize that there are many more benefits than just "a different way of managing state", such as:

  • Making impossible states impossible
  • Being able to visualize application logic
  • Declaratively representing logic in a language-agnostic way
  • Generating full integration/E2E tests from state machines
  • Statistical analysis of paths traversed in real-life use
  • Predictive analytics (based on above) -> reinforcement learning for adaptive UIs
  • Communication with designers/stakeholders
  • Accommodation of late-breaking changes and requirements
  • Use with formal methods to guarantee no unexpected behavior

And this project only really started taking off less than 2 years ago, so it's pretty young! There's still time for the community to grow 🚀

All 11 comments

@carloslfu wrote an excellent article that walks through how he solved a very similar problem:

https://medium.com/@carloslfu/modeling-a-screensaver-with-a-statechart-a-real-use-case-f57301682570

The visible countdown timer ticking on every second makes me wonder if a callback service might be a good fit.

You could have that service call back every second with a countdown event to perform an internal transition and assign the new counter value, and then call back with an event to transition to the loading state when the countdown expires.

You would want to make sure your countdown service returns a cleanup function, so that your countdown is cancelled when transitioning away to the loading state (ex. via the pull to refresh while in the display state).

I'd also be inclined to start in the loading state initially, loading the data with a Promise service there, rather than trying to combine that initial load into the display state.

BTW - be sure to check out the gitter chat and spectrum chat. It's a great place to pose these kinds of questions and get input from more people. David has attracted a great community there.

Thanks @johnyanarella for all the tips, now flipping through the doc and reworking the code hoping to make it work, will report back final result.

Alright after reading through all the docs here's what I've got so far, __still got stuck by what to do once the 15s timer is up__ (i.e. don't know how to transit to the loading state to trigger the promise call there), see code below.

My guts feeling is I'm doing it wrong (or at least not following the best possible xstate way), would appreciate any help, thanks!

const BusMachine = Machine(
  {
    id: "Bus App",
    context: {
      data: null,
      timer: len,
      errMsg: null,
    },
    initial: "idle",
    states: {
      idle: {
        invoke: {
          id: 'getDataByInterval',
          src: (ctx, event) => (callback, onEvent) => {
            countdownId = setInterval(() => {
              callback('COUNTDOWN')
            }, 1000);
            return () => clearInterval(countdownId);
          }
        },
        on: {
          COUNTDOWN: {
            // target: "loading",
            // cond: (ctx, evt) => (ctx.timer !== 0),
            actions: assign((ctx, e) => {
              const t = ctx.timer - 1
              if( t < 0 ) {
                // [QUESITON] 
               // how to trigger a state transition within an action when the timer is up?
               // tried both `send` and `sendParent` to no avail
                sendParent('DEBUG')
              }
              return {...ctx, timer: t < 0 ? len : t }
            })
          },
        },
      },

      loading: {
        invoke: {
          id: 'getSomeData',
          src: (ctx, e) => {
            return getData()
          },
          onDone: {
            target: 'idle',
            actions: assign((ctx, evt) => {
              return {...ctx, data: evt.data}
            })
          },
          onError: {
            target: 'error',
            actions: assign((ctx, evt) =>{
              return {...ctx, errorMsg: evt}
            })
          }

        }
      },
      error: {
        on: {
          "RELOAD": "loading"
        }
      }
    },

    on: {
      "DEBUG": {
        target: 'loading',
        actions: () => console.log( 'DEBUG run',  )
      }
    }
  },
)

const getData = () => {
  return new Promise((resolve, reject) => {
    const d = (Math.random(100) * 100).toFixed(1)
    setTimeout(() => resolve(d), 2000)
    // setTimeout(() => reject('network error'), 2000)
  })
}

Very close!

As you get used to the statechart terminology, try to think about actions as "fire and forget" side-effects, rather than a thing that acts on the machine or transitions you to another state (with the exception of the special send() action, which is used relatively rarely - usually stimuli arrive externally or from a service).

assign(), in particular, is just concerned with computing and writing values into the context based on the previous context and event. BTW - You also don't have to worry about spreading the old context in, when updating it. Pretty convenient!

In this case, it's the countdown service that should send the event that transitions you to the "loading" state.

Try this out in the XState visualizer - be sure to check out the content of the "State" tab as it runs:

const duration = 15

const BusMachine = Machine(
  {
    id: 'Bus App',
    context: {
      data: null,
      counter: null,
      errMsg: null,
    },
    initial: 'loading',
    states: {
      idle: {
        invoke: {
          id: 'countdown',
          src: 'countdown'
        },
        on: {
          // internal transition, countdown service continues on unaffected
          COUNTDOWN: {
            actions: 'updateCounter'
          },
          // external transition, countdown service will be stopped
          // could be triggered by pull to refresh, or countdown service
          RELOAD: {
            target: 'loading'
          }
        },
      },
      loading: {
        onEntry: 'clearCounter',
        invoke: {
          id: 'getData',
          src: 'getData',
          onDone: {
            target: 'idle',
            actions: 'updateData'
          },
          onError: {
            target: 'error',
            actions: 'updateErrMsg'
          }
        }
      },
      error: {
        on: {
          RELOAD: 'loading'
        }
      }
    }
  },
  {
    actions: {
      clearCounter: assign({
        counter: null
      }),
      updateCounter: assign({
        counter: (ctx, evt) => evt.counter
      }),
      updateData: assign({
        data: (ctx, evt) => evt.data           
      }),
      updateErrMsg: assign({
        errMsg: (ctx, evt) => evt.data.message       
      })
    },
    services: {
      countdown: (ctx, evt) => callback => {
        let counter = duration
        countdownId = setInterval(() => {
          if (counter === 0) {
            callback('RELOAD')
          }
          else {
            callback({ type: 'COUNTDOWN', counter })
          }
          counter--
        }, 1000);
        return () => clearInterval(countdownId)
      },
      getData: () => {
        return new Promise((resolve, reject) => {
          const d = (Math.random(100) * 100).toFixed(1)
          setTimeout(() => resolve(d), 2000)
          // setTimeout(() => reject(new Error('network error')), 2000)
        })
      }
    }
  }
)

For clarity, I moved the action and service implementations out of the configuration, and into the options.

Early in the design of your state charts, it can be helpful to omit these implementations, as you puzzle through the behavior of your machine in something closer to plain english. It can also be helpful as the size of your statechart grows, because it enables you to see the workflow separate from the finer details, and focus on implementing those actions later.

That's very detailed and timely answers, thanks you very much @johnyanarella, very much appreciate it!

I've got the prototype running here, with some minor features added (aims to verify if subtle ui details can be supported by xstate), please feel free to point out any mistakes and suggestions!

  • There's 50% of chance the API call will fail so that unhappy path could be verified
  • When API call failed, it will wait for 1s then retry automatically
  • When timer's up it will display loading... message letting users know what's going on in the background
  • User can click reload button to refresh immediately

Out of curiosity, building frontend apps using this fsm-driven approach seemed to get a lot of things right, and xstate plus react goes really well together, I'm wondering why this approach hasn't been wildly adopted by the public? Is it simply due to lack of publicity, or there's indeed technical barriers to prevent people from doing so?

I'm wondering why this approach hasn't been wildly adopted by the public?

That's a great question, and I should really write a blog/documentation entry on this.

First, state machines and statecharts _are_ widely adopted in other areas of technology such as embedded systems, hardware, aeronautics and automotive tech, game development, and more.

There is definitely a learning curve to using state machines and statecharts; you can't simply start coding away, you have to model your application first. This takes time and experience, but at least with tools like XState, you can modify your model as you go. Front-end developers in large are not used to modeling code up-front, so it's a change in discipline/behavior, and a departure from the status-quo "move fast break things" mentality.

However, I (and others, like @mogsie) are passionate about this tried-and-true methodology for modeling applications and realize that there are many more benefits than just "a different way of managing state", such as:

  • Making impossible states impossible
  • Being able to visualize application logic
  • Declaratively representing logic in a language-agnostic way
  • Generating full integration/E2E tests from state machines
  • Statistical analysis of paths traversed in real-life use
  • Predictive analytics (based on above) -> reinforcement learning for adaptive UIs
  • Communication with designers/stakeholders
  • Accommodation of late-breaking changes and requirements
  • Use with formal methods to guarantee no unexpected behavior

And this project only really started taking off less than 2 years ago, so it's pretty young! There's still time for the community to grow 🚀

So sounds like this approach requires a mental model change just like from “jquery everywhere” to “thinking in component/react” that happened a couple of years ago, which is totally understandable.

I’m indeed very excited about all the points you mentioned above, even if just a few of them really happened it would still be a huge thing for the frontend community, this approached looks to me just like another paradigm shift coming, if not the biggest revolution after react brought component-based/declarative style approach to the world and changed everything a few years ago.

Very looking forward to the bright future of xstate and thanks for putting in the efforts to bring great things to developers, you guys rock!

Agreed. I've only been using XState for a few weeks, but I haven't been this excited since I first encountered React.

It definitely requires a "give it five minutes" kind of mental model shift (like React and JSX did back then). But afterward nothing looks the same, and you have a new vocabulary to reason about and describe problems more precisely.

I personally think David's library is poised to take off this year (hockey stick growth), given the current pain points in the React world:

  • the spiraling complexity and hidden logic errors that result from the bag-of-state + action => bag-of-state model vs the discrete-state + event => discrete-state, actions model;
  • the lack of first class support for managed, declarative side-effects;
  • the general awkwardness of workflows that chain asynchronous operations (useReducer() is too tight a straight-jacket, and be wary of post-async-operation getState() hacks as they run afoul of the next item);
  • the impact of stricter API constraints (where "don't do that" is learned rather than implicit in the API) to pave the way for concurrent mode, and the inevitable fallout on existing third-party libraries when it ships; and
  • the missing answers for data fetching while we wait for React suspense for data fetching.

The React team is doing amazing work, but their bold vision involves some waiting yet and a lot of near term uncertainty.

XState provides an answer for those problems today. Being UI library agnostic, XState also makes for an excellent insurance policy for your most important logic, should you need to move elsewhere in the future.

And that's before you even consider all the amazing things that an executable declarative statechart makes possible...

Predictive analytics (based on above) -> reinforcement learning for adaptive UIs

Do you mean since we have statistical data on usage for each state, we might be able to predict user's next move (i.e what's the most possible state that a user will be moving into), and preload data for that state or even preload the view so that visual transition will be much smoother hence providing a better user experience?

Was this page helpful?
0 / 5 - 0 ratings

Related issues

dakom picture dakom  ·  3Comments

laurentpierson picture laurentpierson  ·  3Comments

drmikecrowe picture drmikecrowe  ·  3Comments

ifokeev picture ifokeev  ·  3Comments

jfun picture jfun  ·  3Comments