├── README.md ├── api.js ├── docs └── ru.md ├── fsm ├── machine.js ├── states.js └── transitions.js ├── index.html ├── index.js ├── renderer.js └── utils.js /README.md: -------------------------------------------------------------------------------- 1 | # Finite State Machine on Frontend 2 | 3 | > Read this [in Russian](./docs/ru.md). 4 | 5 | How to use Finite State Machines to manage state and transform UI in frontend applications. 6 | 7 | - [Sample App](https://bespoyasov.me/showcase/fsm/) 8 | - [Post About It](https://bespoyasov.me/blog/fsm-to-the-rescue/) 9 | -------------------------------------------------------------------------------- /api.js: -------------------------------------------------------------------------------- 1 | // Fetches posts list from json placeholder 2 | const fetchPosts = async () => { 3 | const response = await fetch('https://jsonplaceholder.typicode.com/posts') 4 | const posts = await response.json() 5 | return posts.slice(0, 5) 6 | } -------------------------------------------------------------------------------- /docs/ru.md: -------------------------------------------------------------------------------- 1 | # Управление состоянием приложения с помощью конечных автоматов 2 | 3 | > Read this [in English](../README.md). 4 | 5 | Пост о том, как использовать конечные автоматы для управления состоянием фронтенд-приложений, и небольшое приложение-пример: 6 | 7 | - [Приложение](https://bespoyasov.ru/fsm/) 8 | - [Пост](https://bespoyasov.ru/blog/fsm-to-the-rescue/) 9 | -------------------------------------------------------------------------------- /fsm/machine.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Implements FSM abstraction. 3 | * @param {String} initial initial state for machine 4 | * @param {Object} states all possible states 5 | * @param {Object} transitions all transition functions 6 | * @param {Any} data data to store 7 | */ 8 | class StateMachine { 9 | constructor({ 10 | initial, 11 | states, 12 | transitions, 13 | data=null, 14 | }) { 15 | this.transitions = transitions 16 | this.states = states 17 | this.state = initial 18 | this.data = data 19 | 20 | this._onUpdate = null 21 | } 22 | 23 | stateOf() { 24 | return this.state 25 | } 26 | 27 | _updateState(newState, data=null) { 28 | this.state = newState 29 | this.data = data 30 | 31 | this._onUpdate 32 | && this._onUpdate(newState, data) 33 | } 34 | 35 | async performTransition(transitionName) { 36 | const possibleTransitions = this.transitions[this.state] 37 | const transition = possibleTransitions[transitionName] 38 | if (!transition) return 39 | 40 | const current = { 41 | state: this.state, 42 | data: this.data, 43 | } 44 | 45 | for await (const {newState, data=null} of transition(current)) { 46 | this._updateState(newState, data) 47 | } 48 | } 49 | 50 | subscribe(event, callback) { 51 | if (event === 'update') this._onUpdate = callback || null 52 | } 53 | } -------------------------------------------------------------------------------- /fsm/states.js: -------------------------------------------------------------------------------- 1 | // All possible state for system 2 | const states = { 3 | INITIAL: 'idle', 4 | LOADING: 'loading', 5 | SUCCESS: 'success', 6 | FAILURE: 'failure', 7 | } -------------------------------------------------------------------------------- /fsm/transitions.js: -------------------------------------------------------------------------------- 1 | // To set loading state, perform a network request 2 | // and set success or failure state after request is done. 3 | // Clears all older posts 4 | async function* loadNewPosts() { 5 | yield message(states.LOADING) 6 | const performRequest = tryCatchWrapper(states.SUCCESS, states.FAILURE) 7 | yield* performRequest(fetchPosts) 8 | } 9 | 10 | // To append new posts after existing. 11 | // Example of usage FSM with data storage inside 12 | async function* appendPosts(current) { 13 | yield message(states.LOADING, current.data) 14 | const performRequest = tryCatchWrapper(states.SUCCESS, states.FAILURE) 15 | 16 | for await (const update of performRequest(fetchPosts)) { 17 | const {newState, data=null} = update 18 | const newData = data !== null 19 | ? [...current.data, ...data] 20 | : null 21 | 22 | yield message(newState, newData) 23 | } 24 | } 25 | 26 | // To clear data and return to initial state 27 | function* clear() { 28 | yield message(states.INITIAL) 29 | } 30 | 31 | 32 | /** 33 | * Dict of all possible transitions between states. 34 | * Each transition func returns a new state which 35 | * FSM should be in after transition 36 | */ 37 | const transitions = { 38 | [states.INITIAL]: { 39 | fetch: loadNewPosts, 40 | }, 41 | 42 | [states.LOADING]: {}, 43 | 44 | [states.SUCCESS]: { 45 | loadMore: appendPosts, 46 | reload: loadNewPosts, 47 | clear: clear, 48 | }, 49 | 50 | [states.FAILURE]: { 51 | retry: loadNewPosts, 52 | clear: clear, 53 | }, 54 | } 55 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Finite State Machine Example 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // render the app helper 2 | const renderApp = (state, data) => { 3 | const html = render(state, data) 4 | document.getElementById('root').innerHTML = html 5 | } 6 | 7 | // create an FSM object 8 | const fsm = new StateMachine({ 9 | states, 10 | transitions, 11 | initial: states.INITIAL, 12 | }) 13 | 14 | // subscribe to state updates 15 | fsm.subscribe('update', (state, data) => 16 | renderApp(state, data)) 17 | 18 | 19 | // helpers to bind button clicks with FSM transitions 20 | const transitionFetch = () => 21 | fsm.performTransition('fetch') 22 | 23 | const transitionClear = () => 24 | fsm.performTransition('clear') 25 | 26 | const transitionRetry = () => 27 | fsm.performTransition('retry') 28 | 29 | const transitionMore = () => 30 | fsm.performTransition('loadMore') 31 | 32 | const transitionReload = () => 33 | fsm.performTransition('reload') 34 | 35 | 36 | // initial render 37 | renderApp(states.INITIAL) -------------------------------------------------------------------------------- /renderer.js: -------------------------------------------------------------------------------- 1 | const renderBackBtn = () => 2 | `` 3 | 4 | const renderStatus = (status) => 5 | `
${status}
` 6 | 7 | const renderPostsList = (posts) => 8 | posts && posts.length 9 | ? `` 13 | : '' 14 | 15 | 16 | /** 17 | * Renders markup for an app 18 | * @param {String} state 19 | * @param {Any} payload 20 | * @return {String} html to inject 21 | */ 22 | const render = (state, payload) => { 23 | switch (state) { 24 | case states.INITIAL: 25 | return `
26 | ${renderStatus('How are you today?')} 27 | 28 |
` 29 | 30 | case states.LOADING: 31 | return `
32 | ${renderStatus('Loading...')} 33 | ${renderPostsList(payload)} 34 |
` 35 | 36 | case states.FAILURE: 37 | return `
38 | ${renderStatus('Loading failed!')} 39 | ${renderBackBtn()} 40 | 41 |
` 42 | 43 | case states.SUCCESS: 44 | return `
45 | ${renderStatus('Yay!')} 46 | ${renderPostsList(payload)} 47 | ${renderBackBtn()} 48 | 49 | 50 |
` 51 | 52 | default: return '' 53 | } 54 | } -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Composes message for transition in utilized format 3 | * @param {String} newState 4 | * @param {Any} data 5 | * @return {Object} {newState, data} 6 | */ 7 | const message = (newState, data=null) => ({ 8 | newState, data 9 | }) 10 | 11 | 12 | /** 13 | * Wraps network request in try-catch. 14 | * Returns a function which takes a request as argument, 15 | * to use `await` request should based on promise 16 | * @param {String} successState state to yield on success 17 | * @param {String} failureState state to yield on error 18 | * @return {Async Func} 19 | */ 20 | const tryCatchWrapper = (successState, failureState) => 21 | async function* (request) { 22 | try { 23 | const data = await request() 24 | yield message(successState, data) 25 | } 26 | catch(e) { 27 | yield message(failureState) 28 | } 29 | } --------------------------------------------------------------------------------