├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── 00 │ ├── README.md │ ├── Timer.final.js │ ├── Timer.js │ ├── timerMachine.final.js │ └── timerMachine.js ├── 01 │ ├── README.md │ ├── Timer.final.js │ ├── Timer.js │ ├── timerMachine.final.js │ └── timerMachine.js ├── 02 │ ├── README.md │ ├── Timer.final.js │ ├── Timer.js │ ├── timerMachine.final.js │ └── timerMachine.js ├── 03 │ ├── README.md │ ├── Timer.final.js │ ├── Timer.js │ ├── timerMachine.final.js │ └── timerMachine.js ├── 04 │ ├── README.md │ ├── Timer.final.js │ ├── Timer.js │ ├── timerMachine.final.js │ └── timerMachine.js ├── 05 │ ├── README.md │ ├── Timer.final.js │ ├── Timer.js │ ├── timerMachine.final.js │ └── timerMachine.js ├── 06 │ ├── README.md │ ├── Timer.final.js │ ├── Timer.js │ ├── timerMachine.final.js │ └── timerMachine.js ├── 07 │ ├── README.md │ ├── Timer.final.js │ ├── Timer.js │ ├── timerMachine.final.js │ └── timerMachine.js ├── 08 │ ├── App.js │ ├── Clock.js │ ├── ForeignClock.js │ ├── NewTimer.js │ ├── README.md │ ├── Timer.js │ ├── clockMachine.js │ ├── foreignClockMachine.js │ ├── newTimerMachine.js │ ├── timerAppMachine.final.js │ ├── timerAppMachine.js │ ├── timerMachine.js │ └── timezones.json ├── App.test.js ├── Exercise.jsx ├── ProgressCircle.js ├── Workshop.js ├── complete │ ├── App.js │ ├── Clock.js │ ├── ForeignClock.js │ ├── NewTimer.js │ ├── README.md │ ├── Timer.js │ ├── clockMachine.js │ ├── foreignClockMachine.js │ ├── newTimerMachine.js │ ├── timerAppMachine.js │ ├── timerMachine.js │ └── timezones.json ├── index.js ├── index.scss ├── scratch │ ├── README.md │ └── index.js ├── serviceWorker.js ├── setupTests.js └── styles │ ├── alarm.scss │ ├── dots.scss │ ├── exercise.scss │ ├── newTimer.scss │ └── scratch.scss └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 David Khourshid 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Frontend Masters React State Modeling Workshop 2 | 3 | Welcome to the [Frontend Masters React + XState workshop!](https://frontendmasters.com/workshops/xstate-react/) 4 | 5 | 🆕 Using Svelte? Check out the [Svelte port of this workshop!](https://github.com/annaghi/xstate-svelte-workshop) 6 | 7 | ## XState 8 | 9 | - GitHub repo: https://github.com/davidkpiano/xstate 10 | - Documentation: http://xstate.js.org/docs 11 | - Visualizer: http://xstate.js.org/viz 12 | 13 | ## Getting Started 14 | 15 | To run this workshop: 16 | 17 | - Clone this repo 18 | - Run `yarn install` 19 | - Run `yarn start` 20 | - Navigate to [http://localhost:3000/00](http://localhost:3000/00) to get to the first exercise. 21 | 22 | ## Exercises 23 | 24 | Exercises are separated by directory in `src/##`. Each directory will contain a `README.md` describing the goal of the exercise, as well as tips and comments in the `Timer.js` and `timerMachine.js` files. 25 | 26 | Your objective is to accomplish the goals in `README.md`. If you get stuck, refer to the `Timer.final.js` and/or `timerMachine.final.js` files in each directory. 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-finland-xstate-workshop", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@fortawesome/fontawesome-svg-core": "^1.2.28", 7 | "@fortawesome/free-solid-svg-icons": "^5.13.0", 8 | "@fortawesome/react-fontawesome": "^0.1.9", 9 | "@reach/tabs": "^0.11.2", 10 | "@testing-library/jest-dom": "^4.2.4", 11 | "@testing-library/react": "^11.1.1", 12 | "@testing-library/user-event": "^7.1.2", 13 | "@xstate/inspect": "^0.2.0", 14 | "@xstate/react": "^1.0.2", 15 | "@xstate/test": "^0.4.1", 16 | "react": "^16.13.1", 17 | "react-dom": "^16.13.1", 18 | "react-markdown": "^5.0.2", 19 | "react-query": "^2.25.2", 20 | "react-router": "^5.2.0", 21 | "react-router-dom": "^5.2.0", 22 | "react-scripts": "4.0.0", 23 | "xstate": "^4.14.0" 24 | }, 25 | "scripts": { 26 | "start": "react-scripts start", 27 | "build": "react-scripts build", 28 | "test": "react-scripts test", 29 | "eject": "react-scripts eject" 30 | }, 31 | "eslintConfig": { 32 | "extends": "react-app" 33 | }, 34 | "browserslist": { 35 | "production": [ 36 | ">0.2%", 37 | "not dead", 38 | "not op_mini all" 39 | ], 40 | "development": [ 41 | "last 1 chrome version", 42 | "last 1 firefox version", 43 | "last 1 safari version" 44 | ] 45 | }, 46 | "devDependencies": { 47 | "sass": "^1.42.1" 48 | }, 49 | "engines": { 50 | "node": "16" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidkpiano/frontend-masters-react-workshop/a5a81254fef93d2613b887bdb510d355e19af55d/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidkpiano/frontend-masters-react-workshop/a5a81254fef93d2613b887bdb510d355e19af55d/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidkpiano/frontend-masters-react-workshop/a5a81254fef93d2613b887bdb510d355e19af55d/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/00/README.md: -------------------------------------------------------------------------------- 1 | # Exercise 00 - Creating a state machine 2 | 3 | In this exercise, you're going to be creating a state machine from scratch, and putting it in [`useReducer`](https://reactjs.org/docs/hooks-reference.html#usereducer). 4 | 5 | ## Goals 6 | 7 | - Create a state machine reducer with 3 states: `idle`, `running`, and `paused`. 8 | - The state machine should start in the `idle` state. 9 | - When a `TOGGLE` event occurs in `idle`, the machine should transition to `running`. 10 | - When a `TOGGLE` event occurs in `running`, the machine should transition to `paused`. 11 | - When a `TOGGLE` event occurs in `paused`, the machine should transition back to `running`. 12 | - When a `RESET` event occurs in `paused`, the machine should transition back to `idle`. 13 | - Dispatch those `TOGGLE` events when the **Start timer** and **Pause timer** buttons are pressed. Additionally, clicking on the elapsed time should also start the timer. 14 | - Only show the **Pause timer** button when the `state` is `'running'`. 15 | - Only show the **Start timer** button when the state is either `paused` or `idle`. 16 | -------------------------------------------------------------------------------- /src/00/Timer.final.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useReducer } from 'react'; 3 | import { faPlay, faPause, faStop } from '@fortawesome/free-solid-svg-icons'; 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 5 | 6 | import { timerMachine, timerMachineConfig } from './timerMachine.final'; 7 | import { ProgressCircle } from '../ProgressCircle'; 8 | 9 | export const Timer = () => { 10 | const [state, dispatch] = useReducer( 11 | timerMachine, 12 | timerMachineConfig.initial 13 | ); 14 | 15 | const { duration, elapsed, interval } = { 16 | duration: 60, 17 | elapsed: 0, 18 | interval: 0.1, 19 | }; 20 | 21 | return ( 22 |
32 |
33 |

Exercise 00 Solution

34 |
35 | 36 |
37 |
{state}
38 |
dispatch({ type: 'TOGGLE' })}> 39 | {Math.ceil(duration - elapsed)} 40 |
41 |
42 | {state === 'paused' && ( 43 | 44 | )} 45 |
46 |
47 |
48 | {state === 'running' && ( 49 | 55 | )} 56 | {(state === 'paused' || state === 'idle') && ( 57 | 63 | )} 64 |
65 |
66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /src/00/Timer.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useReducer } from 'react'; 3 | import { faPlay, faPause } from '@fortawesome/free-solid-svg-icons'; 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 5 | import { ProgressCircle } from '../ProgressCircle'; 6 | 7 | // Import the timer machine and its initial state: 8 | // import { ... } from './timerMachine'; 9 | 10 | export const Timer = () => { 11 | const state = ''; // delete me - useReducer instead! 12 | 13 | const { duration, elapsed, interval } = { 14 | duration: 60, 15 | elapsed: 0, 16 | interval: 0.1, 17 | }; 18 | 19 | return ( 20 |
30 |
31 |

Exercise 00

32 |
33 | 34 |
35 |
{state}
36 |
{ 39 | // ... 40 | }} 41 | > 42 | {Math.ceil(duration - elapsed)} 43 |
44 |
45 | 52 |
53 |
54 |
55 | 63 | 64 | 72 |
73 |
74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /src/00/timerMachine.final.js: -------------------------------------------------------------------------------- 1 | export const timerMachineConfig = { 2 | initial: 'idle', 3 | states: { 4 | idle: { 5 | on: { 6 | TOGGLE: 'running', 7 | }, 8 | }, 9 | running: { 10 | on: { 11 | TOGGLE: 'paused', 12 | }, 13 | }, 14 | paused: { 15 | on: { 16 | TOGGLE: 'running', 17 | RESET: 'idle', 18 | }, 19 | }, 20 | }, 21 | }; 22 | 23 | export const timerMachine = (state, event) => { 24 | return timerMachineConfig.states[state]?.on?.[event.type] || state; 25 | }; 26 | -------------------------------------------------------------------------------- /src/00/timerMachine.js: -------------------------------------------------------------------------------- 1 | export const timerMachineConfig = { 2 | // ... 3 | }; 4 | 5 | export const timerMachine = (state, event) => { 6 | // Add the logic that will read the timerMachineConfig 7 | // and return the next state, given the current state 8 | // and event received 9 | 10 | // ... 11 | 12 | return state; 13 | }; 14 | -------------------------------------------------------------------------------- /src/01/README.md: -------------------------------------------------------------------------------- 1 | # Exercise 01 - Using XState 2 | 3 | In this exercise, we're going to take the machine you created and use it within [XState](https://xstate.js.org/docs) with [XState React](https://xstate.js.org/docs/packages/xstate-react/). If you created the machine using object notation, this should be pretty straightforward. 4 | 5 | ## Goals 6 | 7 | - Install `xstate` and `@xstate/react` 8 | - In `timerMachine.js`, use `createMachine(...)` to create a state machine in XState. 9 | - In `Timer.js`, use the `useMachine(...)` hook with that created machine. This should feel just like `useReducer` 10 | - The finite state value is now in `state.value`, so use that instead of `state`. 11 | -------------------------------------------------------------------------------- /src/01/Timer.final.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { faPlay, faPause } from '@fortawesome/free-solid-svg-icons'; 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 4 | 5 | import { useMachine } from '@xstate/react'; 6 | import { ProgressCircle } from '../ProgressCircle'; 7 | 8 | import { timerMachine } from './timerMachine.final'; 9 | 10 | export const Timer = () => { 11 | const [state, send] = useMachine(timerMachine); 12 | 13 | const { duration, elapsed, interval } = { 14 | duration: 60, 15 | elapsed: 0, 16 | interval: 0.1, 17 | }; 18 | 19 | return ( 20 |
30 |
31 |

Exercise 01 Solution

32 |
33 | 34 |
35 |
{state.value}
36 |
send({ type: 'TOGGLE' })}> 37 | {Math.ceil(duration - elapsed)} 38 |
39 |
40 | {state.value === 'paused' && ( 41 | 42 | )} 43 |
44 |
45 |
46 | {state.value === 'running' && ( 47 | 50 | )} 51 | 52 | {(state.value === 'paused' || state.value === 'idle') && ( 53 | 56 | )} 57 |
58 |
59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /src/01/Timer.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { faPlay, faPause } from '@fortawesome/free-solid-svg-icons'; 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 4 | 5 | import { ProgressCircle } from '../ProgressCircle'; 6 | 7 | // import { useMachine } from '@xstate/react'; 8 | import { timerMachine } from './timerMachine'; 9 | 10 | export const Timer = () => { 11 | const [state, send] = [{}, () => {}]; 12 | 13 | const { duration, elapsed, interval } = { 14 | duration: 60, 15 | elapsed: 0, 16 | interval: 0.1, 17 | }; 18 | 19 | return ( 20 |
30 |
31 |

Exercise 01

32 |
33 | 34 |
35 |
{state.value}
36 |
{ 39 | // ... 40 | }} 41 | > 42 | {Math.ceil(duration - elapsed)} 43 |
44 |
45 | {state === 'paused' && ( 46 | 53 | )} 54 |
55 |
56 |
57 | {state === 'running' && ( 58 | 66 | )} 67 | 68 | {(state === 'paused' || state === 'idle') && ( 69 | 77 | )} 78 |
79 |
80 | ); 81 | }; 82 | -------------------------------------------------------------------------------- /src/01/timerMachine.final.js: -------------------------------------------------------------------------------- 1 | import { createMachine } from 'xstate'; 2 | 3 | export const timerMachine = createMachine({ 4 | initial: 'idle', 5 | states: { 6 | idle: { 7 | on: { 8 | TOGGLE: 'running', 9 | }, 10 | }, 11 | running: { 12 | on: { 13 | TOGGLE: 'paused', 14 | }, 15 | }, 16 | paused: { 17 | on: { 18 | TOGGLE: 'running', 19 | RESET: 'idle', 20 | }, 21 | }, 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /src/01/timerMachine.js: -------------------------------------------------------------------------------- 1 | import { createMachine } from 'xstate'; 2 | 3 | // Use the machine you created in Exercise 00 4 | // export const timerMachine = // ... 5 | -------------------------------------------------------------------------------- /src/02/README.md: -------------------------------------------------------------------------------- 1 | # Exercise 02 - Context 2 | 3 | In this exercise, we're going to use `context` to hold extended state (state that isn't finite) and `assign(...)` to update `context` in an action. 4 | 5 | ## Goals 6 | 7 | - Add initial `context` to the machine with these values: 8 | - `duration: 60` (seconds) 9 | - `elapsed: 0` (seconds) 10 | - `interval: 0.1` (seconds - 1/10th of a second) 11 | - Use the `state.context` to display the elapsed time. 12 | - There's a new button for adding a minute that says **+ 1:00**. Make sure that button sends an `'ADD_MINUTE'` event when it is clicked. Don't show the button unless the machine is in the `running` state. 13 | - When in the `running` state, the `'ADD_MINUTE'` event should trigger a transition action (in `actions`) that increments the `context.duration` by `60` seconds. Leave out the `target`; we should still be in the `running` state. 14 | - Whenever the machine enters the `idle` state, we should reset the `duration` and `elapsed` values to their original values. 15 | 16 | The end result should be that clicking **+ 1:00** should add 60 seconds to the remaining time in the UI, and clicking **Reset** should reset the remaining time to 60 seconds. 17 | -------------------------------------------------------------------------------- /src/02/Timer.final.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { faPlay, faPause } from '@fortawesome/free-solid-svg-icons'; 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 4 | 5 | import { useMachine } from '@xstate/react'; 6 | import { timerMachine } from './timerMachine.final'; 7 | import { ProgressCircle } from '../ProgressCircle'; 8 | 9 | export const Timer = () => { 10 | const [state, send] = useMachine(timerMachine); 11 | 12 | const { duration, elapsed, interval } = state.context; 13 | 14 | return ( 15 |
25 |
26 |

Exercise 02 Solution

27 |
28 | 29 |
30 |
{state.value}
31 |
send({ type: 'TOGGLE' })}> 32 | {Math.ceil(duration - elapsed)} 33 |
34 |
35 | {state.value !== 'running' && ( 36 | 37 | )} 38 | 39 | {state.value === 'running' && ( 40 | 41 | )} 42 |
43 |
44 |
45 | {state.value === 'running' && ( 46 | 49 | )} 50 | 51 | {(state.value === 'paused' || state.value === 'idle') && ( 52 | 55 | )} 56 |
57 |
58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /src/02/Timer.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { faPlay, faPause } from '@fortawesome/free-solid-svg-icons'; 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 4 | 5 | import { useMachine } from '@xstate/react'; 6 | import { timerMachine } from './timerMachine'; 7 | import { ProgressCircle } from '../ProgressCircle'; 8 | 9 | export const Timer = () => { 10 | const [state, send] = useMachine(timerMachine); 11 | 12 | // Use state.context instead 13 | const { duration, elapsed, interval } = { 14 | duration: 60, 15 | elapsed: 0, 16 | interval: 0.1, 17 | }; 18 | 19 | return ( 20 |
30 |
31 |

Exercise 02

32 |
33 | 34 |
35 |
{state.value}
36 |
send({ type: 'TOGGLE' })}> 37 | {Math.ceil(duration - elapsed)} 38 |
39 |
40 | {state.value !== 'running' && ( 41 | 42 | )} 43 | 44 | 51 |
52 |
53 |
54 | {state.value === 'running' && ( 55 | 58 | )} 59 | 60 | {(state.value === 'paused' || state.value === 'idle') && ( 61 | 64 | )} 65 |
66 |
67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /src/02/timerMachine.final.js: -------------------------------------------------------------------------------- 1 | import { createMachine, assign } from 'xstate'; 2 | 3 | export const timerMachine = createMachine({ 4 | initial: 'idle', 5 | context: { 6 | duration: 60, 7 | elapsed: 0, 8 | interval: 0.1, 9 | }, 10 | states: { 11 | idle: { 12 | entry: assign({ 13 | duration: 60, 14 | elapsed: 0, 15 | }), 16 | on: { 17 | TOGGLE: 'running', 18 | }, 19 | }, 20 | running: { 21 | on: { 22 | TOGGLE: 'paused', 23 | ADD_MINUTE: { 24 | actions: assign({ 25 | duration: (ctx) => ctx.duration + 60, 26 | }), 27 | }, 28 | }, 29 | }, 30 | paused: { 31 | on: { 32 | TOGGLE: 'running', 33 | RESET: 'idle', 34 | }, 35 | }, 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /src/02/timerMachine.js: -------------------------------------------------------------------------------- 1 | import { createMachine, assign } from 'xstate'; 2 | 3 | export const timerMachine = createMachine({ 4 | initial: 'idle', 5 | // Add initial context 6 | // ... 7 | 8 | states: { 9 | idle: { 10 | // Reset duration and elapsed on entry 11 | // ... 12 | 13 | on: { 14 | TOGGLE: 'running', 15 | }, 16 | }, 17 | running: { 18 | on: { 19 | TOGGLE: 'paused', 20 | 21 | // On ADD_MINUTE, increment context.duration by 60 seconds 22 | // ... 23 | }, 24 | }, 25 | paused: { 26 | on: { 27 | TOGGLE: 'running', 28 | RESET: 'idle', 29 | }, 30 | }, 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /src/03/README.md: -------------------------------------------------------------------------------- 1 | # Exercise 03 - Parameterizing Actions 2 | 3 | In this exercise, we'll move those actions created with `assign(...)` to parameterized actions. We'll also model a `TICK` event using `useEffect` and `setInterval`, for now. 4 | 5 | ## Goals 6 | 7 | - Move all of the `assign(...)` actions to functions. 8 | - When in the `'running'` state, a `TICK` event should increment the `context.elapsed` value by the `context.interval`. 9 | - Add a `useEffect` to start that interval. Ideally, the interval should start and stop whenever we're in and out of the `running` state respectively. 10 | -------------------------------------------------------------------------------- /src/03/Timer.final.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useEffect } from 'react'; 3 | import { faPlay, faPause } from '@fortawesome/free-solid-svg-icons'; 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 5 | 6 | import { useMachine } from '@xstate/react'; 7 | import { ProgressCircle } from '../ProgressCircle'; 8 | 9 | import { timerMachine } from './timerMachine.final'; 10 | 11 | export const Timer = () => { 12 | const [state, send] = useMachine(timerMachine); 13 | 14 | const { duration, elapsed, interval } = state.context; 15 | 16 | useEffect(() => { 17 | if (state.value === 'running') { 18 | const intervalId = setInterval(() => { 19 | send('TICK'); 20 | }, interval * 1000); 21 | 22 | return () => clearInterval(intervalId); 23 | } 24 | }, [state.value]); 25 | 26 | return ( 27 |
37 |
38 |

Exercise 03 Solution

39 |
40 | 41 |
42 |
{state.value}
43 |
send({ type: 'TOGGLE' })}> 44 | {Math.ceil(duration - elapsed)} 45 |
46 |
47 | {state.value !== 'running' && ( 48 | 49 | )} 50 | 51 | {state.value === 'running' && ( 52 | 53 | )} 54 |
55 |
56 |
57 | {state.value === 'running' && ( 58 | 61 | )} 62 | 63 | {(state.value === 'paused' || state.value === 'idle') && ( 64 | 67 | )} 68 |
69 |
70 | ); 71 | }; 72 | -------------------------------------------------------------------------------- /src/03/Timer.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useEffect } from 'react'; 3 | import { faPlay, faPause } from '@fortawesome/free-solid-svg-icons'; 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 5 | 6 | import { useMachine } from '@xstate/react'; 7 | import { ProgressCircle } from '../ProgressCircle'; 8 | 9 | import { timerMachine } from './timerMachine'; 10 | 11 | export const Timer = () => { 12 | const [state, send] = useMachine(timerMachine); 13 | 14 | const { duration, elapsed, interval } = state.context; 15 | 16 | // Add a useEffect(...) here to send a TICK event on every `interval` 17 | // ... 18 | 19 | return ( 20 |
30 |
31 |

Exercise 03

32 |
33 | 34 |
35 |
{state.value}
36 |
send({ type: 'TOGGLE' })}> 37 | {Math.ceil(duration - elapsed)} 38 |
39 |
40 | {state.value !== 'running' && ( 41 | 42 | )} 43 | 44 | {state.value === 'running' && ( 45 | 46 | )} 47 |
48 |
49 |
50 | {state.value === 'running' && ( 51 | 54 | )} 55 | 56 | {(state.value === 'paused' || state.value === 'idle') && ( 57 | 60 | )} 61 |
62 |
63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /src/03/timerMachine.final.js: -------------------------------------------------------------------------------- 1 | import { createMachine, assign } from 'xstate'; 2 | 3 | const tick = assign({ 4 | elapsed: (ctx) => ctx.elapsed + ctx.interval, 5 | }); 6 | 7 | const addMinute = assign({ 8 | duration: (ctx) => ctx.duration + 60, 9 | }); 10 | 11 | const reset = assign({ 12 | duration: 60, 13 | elapsed: 0, 14 | }); 15 | 16 | export const timerMachine = createMachine({ 17 | initial: 'idle', 18 | context: { 19 | duration: 60, 20 | elapsed: 0, 21 | interval: 0.1, 22 | }, 23 | states: { 24 | idle: { 25 | entry: reset, 26 | on: { 27 | TOGGLE: 'running', 28 | }, 29 | }, 30 | running: { 31 | on: { 32 | TICK: { 33 | actions: tick, 34 | }, 35 | TOGGLE: 'paused', 36 | ADD_MINUTE: { 37 | actions: addMinute, 38 | }, 39 | }, 40 | }, 41 | paused: { 42 | on: { 43 | TOGGLE: 'running', 44 | RESET: 'idle', 45 | }, 46 | }, 47 | }, 48 | }); 49 | -------------------------------------------------------------------------------- /src/03/timerMachine.js: -------------------------------------------------------------------------------- 1 | import { createMachine, assign } from 'xstate'; 2 | 3 | // Parameterize the assign actions here: 4 | // const tick = ... 5 | // const addMinute = ... 6 | // const reset = ... 7 | 8 | export const timerMachine = createMachine({ 9 | initial: 'idle', 10 | context: { 11 | duration: 60, 12 | elapsed: 0, 13 | interval: 0.1, 14 | }, 15 | states: { 16 | idle: { 17 | // Parameterize this action: 18 | entry: assign({ 19 | duration: 60, 20 | elapsed: 0, 21 | }), 22 | 23 | on: { 24 | TOGGLE: 'running', 25 | }, 26 | }, 27 | running: { 28 | on: { 29 | // On the TICK event, the context.elapsed should be incremented by context.interval 30 | // ... 31 | 32 | TOGGLE: 'paused', 33 | ADD_MINUTE: { 34 | // Parameterize this action: 35 | actions: assign({ 36 | duration: (ctx) => ctx.duration + 60, 37 | }), 38 | }, 39 | }, 40 | }, 41 | paused: { 42 | on: { 43 | TOGGLE: 'running', 44 | RESET: 'idle', 45 | }, 46 | }, 47 | }, 48 | }); 49 | -------------------------------------------------------------------------------- /src/04/README.md: -------------------------------------------------------------------------------- 1 | # Exercise 04 - Guarded Transitions 2 | 3 | In this exercise, we'll model what happens when the timer runs out using [guarded transitions](https://xstate.js.org/docs/guides/guards.html). 4 | 5 | ## Goals 6 | 7 | - Whenever a `TICK` event happens, make it so that we only increment `context.elapsed` when incrementing it won't exceed the `context.duration`. 8 | - Otherwise, the machine should transition to the `'expired'` state. 9 | - Parameterize the `cond`, and optionally place it in the machine's `guards` option. 10 | - In the `expired` state, a `RESET` event should also transition back to `idle`. 11 | -------------------------------------------------------------------------------- /src/04/Timer.final.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useEffect } from 'react'; 3 | import { faPlay, faPause } from '@fortawesome/free-solid-svg-icons'; 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 5 | 6 | import { useMachine } from '@xstate/react'; 7 | import { ProgressCircle } from '../ProgressCircle'; 8 | 9 | import { timerMachine } from './timerMachine.final'; 10 | 11 | export const Timer = () => { 12 | const [state, send] = useMachine(timerMachine); 13 | 14 | const { duration, elapsed, interval } = state.context; 15 | 16 | useEffect(() => { 17 | const intervalId = setInterval(() => { 18 | send('TICK'); 19 | }, interval * 1000); 20 | 21 | return () => clearInterval(intervalId); 22 | }, []); 23 | 24 | return ( 25 |
35 |
36 |

Exercise 04 Solution

37 |
38 | 39 |
40 |
{state.value}
41 |
send({ type: 'TOGGLE' })}> 42 | {Math.ceil(duration - elapsed)} 43 |
44 |
45 | {state.value !== 'running' && ( 46 | 47 | )} 48 | 49 | {state.value === 'running' && ( 50 | 51 | )} 52 |
53 |
54 |
55 | {state.value === 'running' && ( 56 | 59 | )} 60 | 61 | {(state.value === 'paused' || state.value === 'idle') && ( 62 | 65 | )} 66 |
67 |
68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /src/04/Timer.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useEffect } from 'react'; 3 | import { faPlay, faPause } from '@fortawesome/free-solid-svg-icons'; 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 5 | 6 | import { useMachine } from '@xstate/react'; 7 | import { ProgressCircle } from '../ProgressCircle'; 8 | 9 | import { timerMachine } from './timerMachine'; 10 | 11 | export const Timer = () => { 12 | const [state, send] = useMachine(timerMachine); 13 | 14 | const { duration, elapsed, interval } = state.context; 15 | 16 | useEffect(() => { 17 | const intervalId = setInterval(() => { 18 | send('TICK'); 19 | }, interval * 1000); 20 | 21 | return () => clearInterval(intervalId); 22 | }, []); 23 | 24 | return ( 25 |
35 |
36 |

Exercise 04

37 |
38 | 39 |
40 |
{state.value}
41 |
send({ type: 'TOGGLE' })}> 42 | {Math.ceil(duration - elapsed)} 43 |
44 |
45 | {state.value !== 'running' && ( 46 | 47 | )} 48 | 49 | {state.value === 'running' && ( 50 | 51 | )} 52 |
53 |
54 |
55 | {state.value === 'running' && ( 56 | 59 | )} 60 | 61 | {(state.value === 'paused' || state.value === 'idle') && ( 62 | 65 | )} 66 |
67 |
68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /src/04/timerMachine.final.js: -------------------------------------------------------------------------------- 1 | import { createMachine, assign } from 'xstate'; 2 | 3 | export const timerMachine = createMachine({ 4 | initial: 'idle', 5 | context: { 6 | duration: 60, 7 | elapsed: 0, 8 | interval: 0.1, 9 | }, 10 | states: { 11 | idle: { 12 | entry: assign({ 13 | duration: 60, 14 | elapsed: 0, 15 | }), 16 | on: { 17 | TOGGLE: 'running', 18 | }, 19 | }, 20 | running: { 21 | on: { 22 | TICK: [ 23 | { 24 | actions: assign({ 25 | elapsed: (ctx) => ctx.elapsed + ctx.interval, 26 | }), 27 | cond: (ctx) => { 28 | return ctx.elapsed + ctx.interval <= ctx.duration; 29 | }, 30 | }, 31 | { target: 'expired' }, 32 | ], 33 | TOGGLE: 'paused', 34 | ADD_MINUTE: { 35 | actions: assign({ 36 | duration: (ctx) => ctx.duration + 60, 37 | }), 38 | }, 39 | }, 40 | }, 41 | paused: { 42 | on: { 43 | TOGGLE: 'running', 44 | RESET: 'idle', 45 | }, 46 | }, 47 | expired: { 48 | on: { 49 | RESET: 'idle', 50 | }, 51 | }, 52 | }, 53 | }); 54 | -------------------------------------------------------------------------------- /src/04/timerMachine.js: -------------------------------------------------------------------------------- 1 | import { createMachine, assign } from 'xstate'; 2 | 3 | export const timerMachine = createMachine({ 4 | initial: 'idle', 5 | context: { 6 | duration: 5, 7 | elapsed: 0, 8 | interval: 0.1, 9 | }, 10 | states: { 11 | idle: { 12 | entry: assign({ 13 | duration: 5, 14 | elapsed: 0, 15 | }), 16 | on: { 17 | TOGGLE: 'running', 18 | }, 19 | }, 20 | running: { 21 | on: { 22 | // Change this TICK transition into a guarded transition 23 | // to go to `expired` when `context.elapsed + context.interval` 24 | // is greater than the total `context.duration`. 25 | TICK: { 26 | actions: assign({ 27 | elapsed: (ctx) => ctx.elapsed + ctx.interval, 28 | }), 29 | }, 30 | TOGGLE: 'paused', 31 | ADD_MINUTE: { 32 | actions: assign({ 33 | duration: (ctx) => ctx.duration + 60, 34 | }), 35 | }, 36 | }, 37 | }, 38 | paused: { 39 | on: { 40 | TOGGLE: 'running', 41 | RESET: 'idle', 42 | }, 43 | }, 44 | 45 | // Add an `expired` state here. 46 | // It should go to the `idle` state on the `RESET` event. 47 | // ... 48 | }, 49 | }); 50 | -------------------------------------------------------------------------------- /src/05/README.md: -------------------------------------------------------------------------------- 1 | # Exercise 05 - Eventless Transitions 2 | 3 | In this exercise, we'll be using [eventless transitions](https://xstate.js.org/docs/guides/transitions.html#eventless-always-transitions) to detect when the timer has expired, and simplify the guard logic. 4 | 5 | ## Goals 6 | 7 | - Add an eventless transition in the `running` state that would transition to the `expired` state as soon as the timer expires. 8 | - Clean up the now unnecessary guard from the `TICK` transition in the `running` state – it should only update `context.elapsed`. 9 | -------------------------------------------------------------------------------- /src/05/Timer.final.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useEffect } from 'react'; 3 | import { faPlay, faPause } from '@fortawesome/free-solid-svg-icons'; 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 5 | 6 | import { useMachine } from '@xstate/react'; 7 | import { timerMachine } from './timerMachine.final'; 8 | import { ProgressCircle } from '../ProgressCircle'; 9 | 10 | export const Timer = () => { 11 | const [state, send] = useMachine(timerMachine); 12 | 13 | const { duration, elapsed, interval } = state.context; 14 | 15 | useEffect(() => { 16 | const intervalId = setInterval(() => { 17 | send('TICK'); 18 | }, interval * 1000); 19 | 20 | return () => clearInterval(intervalId); 21 | }, []); 22 | 23 | return ( 24 |
34 |
35 |

Exercise 05 Solution

36 |
37 | 38 |
39 |
{state.value}
40 |
send({ type: 'TOGGLE' })}> 41 | {Math.ceil(duration - elapsed)} 42 |
43 |
44 | {state.value !== 'running' && ( 45 | 46 | )} 47 | 48 | {state.value === 'running' && ( 49 | 50 | )} 51 |
52 |
53 |
54 | {state.value === 'running' && ( 55 | 58 | )} 59 | 60 | {(state.value === 'paused' || state.value === 'idle') && ( 61 | 64 | )} 65 |
66 |
67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /src/05/Timer.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useEffect } from 'react'; 3 | import { faPlay, faPause } from '@fortawesome/free-solid-svg-icons'; 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 5 | 6 | import { useMachine } from '@xstate/react'; 7 | import { timerMachine } from './timerMachine'; 8 | import { ProgressCircle } from '../ProgressCircle'; 9 | 10 | export const Timer = () => { 11 | const [state, send] = useMachine(timerMachine); 12 | 13 | const { duration, elapsed, interval } = state.context; 14 | 15 | useEffect(() => { 16 | const intervalId = setInterval(() => { 17 | send('TICK'); 18 | }, interval * 1000); 19 | 20 | return () => clearInterval(intervalId); 21 | }, []); 22 | 23 | return ( 24 |
34 |
35 |

Exercise 05

36 |
37 | 38 |
39 |
{state.value}
40 |
send({ type: 'TOGGLE' })}> 41 | {Math.ceil(duration - elapsed)} 42 |
43 |
44 | {state.value !== 'running' && ( 45 | 46 | )} 47 | 48 | {state.value === 'running' && ( 49 | 50 | )} 51 |
52 |
53 |
54 | {state.value === 'running' && ( 55 | 58 | )} 59 | 60 | {(state.value === 'paused' || state.value === 'idle') && ( 61 | 64 | )} 65 |
66 |
67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /src/05/timerMachine.final.js: -------------------------------------------------------------------------------- 1 | import { createMachine, assign } from 'xstate'; 2 | 3 | const timerExpired = (ctx) => ctx.elapsed >= ctx.duration; 4 | 5 | export const timerMachine = createMachine({ 6 | initial: 'idle', 7 | context: { 8 | duration: 5, 9 | elapsed: 0, 10 | interval: 0.1, 11 | }, 12 | states: { 13 | idle: { 14 | entry: assign({ 15 | duration: 5, 16 | elapsed: 0, 17 | }), 18 | on: { 19 | TOGGLE: 'running', 20 | }, 21 | }, 22 | running: { 23 | always: { 24 | target: 'expired', 25 | cond: timerExpired, 26 | }, 27 | on: { 28 | TICK: { 29 | actions: assign({ 30 | elapsed: (ctx) => ctx.elapsed + ctx.interval, 31 | }), 32 | }, 33 | TOGGLE: 'paused', 34 | ADD_MINUTE: { 35 | actions: assign({ 36 | duration: (ctx) => ctx.duration + 60, 37 | }), 38 | }, 39 | }, 40 | }, 41 | paused: { 42 | on: { 43 | TOGGLE: 'running', 44 | RESET: 'idle', 45 | }, 46 | }, 47 | expired: { 48 | on: { 49 | RESET: 'idle', 50 | }, 51 | }, 52 | }, 53 | }); 54 | -------------------------------------------------------------------------------- /src/05/timerMachine.js: -------------------------------------------------------------------------------- 1 | import { createMachine, assign } from 'xstate'; 2 | 3 | const timerExpired = (ctx) => ctx.elapsed >= ctx.duration; 4 | 5 | export const timerMachine = createMachine({ 6 | initial: 'idle', 7 | context: { 8 | duration: 5, 9 | elapsed: 0, 10 | interval: 0.1, 11 | }, 12 | states: { 13 | idle: { 14 | entry: assign({ 15 | duration: 5, 16 | elapsed: 0, 17 | }), 18 | on: { 19 | TOGGLE: 'running', 20 | }, 21 | }, 22 | running: { 23 | on: { 24 | // Add an eventless (always) transition that checks if the timer is expired. 25 | // If so, go to the `expired` state. 26 | // ... 27 | 28 | TICK: { 29 | actions: assign({ 30 | elapsed: (ctx) => ctx.elapsed + ctx.interval, 31 | }), 32 | }, 33 | TOGGLE: 'paused', 34 | ADD_MINUTE: { 35 | actions: assign({ 36 | duration: (ctx) => ctx.duration + 60, 37 | }), 38 | }, 39 | }, 40 | }, 41 | paused: { 42 | on: { 43 | TOGGLE: 'running', 44 | RESET: 'idle', 45 | }, 46 | }, 47 | expired: { 48 | on: { 49 | RESET: 'idle', 50 | }, 51 | }, 52 | }, 53 | }); 54 | -------------------------------------------------------------------------------- /src/06/README.md: -------------------------------------------------------------------------------- 1 | # Exercise 06 - Hierarchical States 2 | 3 | In this exercise, we'll be exploring [hierarchical states](https://xstate.js.org/docs/guides/hierarchical.html) to add "overtime" logic. Instead of the machine going to an `expired` state, it should still have the same behavior as if it were running, but we should also be in some `running.overtime` state, so that we know that the time is already expired. 4 | 5 | We'll also use [forbidden transitions](https://xstate.js.org/docs/guides/transitions.html#forbidden-transitions) to tweak the behavior for these nested states. 6 | 7 | ## Goals 8 | 9 | - Add two hierarchical (nested) states to the `running` state: 10 | - `running.normal`, which is the normal timer countdown behavior, and the initial state 11 | - `running.overtime`, which is the expired timer countdown behavior that can also be reset. 12 | - Move the eventless transition from `running` into `running.normal`, and transition to the sibling `overtime` instead of `expired`, since this is what we want our new behavior to be. 13 | - Since we now want to allow `RESET` to transition to `idle` in both `running.overtime` and `paused`, move that transition to the parent, so that a `RESET` event would transition to `.idle`. 14 | - However, we don't want a `RESET` event to do anything in `running.normal`, so forbid that transition. 15 | - We also don't want `TOGGLE` to do anything in `running.overtime`, so forbid that transition as well. 16 | -------------------------------------------------------------------------------- /src/06/Timer.final.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useEffect } from 'react'; 3 | import { faPlay, faPause, faStop } from '@fortawesome/free-solid-svg-icons'; 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 5 | 6 | import { useMachine } from '@xstate/react'; 7 | import { ProgressCircle } from '../ProgressCircle'; 8 | 9 | import { timerMachine } from './timerMachine.final'; 10 | 11 | export const Timer = () => { 12 | const [state, send] = useMachine(timerMachine); 13 | 14 | const { duration, elapsed, interval } = state.context; 15 | 16 | useEffect(() => { 17 | const intervalId = setInterval(() => { 18 | send('TICK'); 19 | }, interval * 1000); 20 | 21 | return () => clearInterval(intervalId); 22 | }, []); 23 | 24 | return ( 25 |
35 |
36 |

Exercise 06 Solution

37 |
38 | 39 |
40 |
{state.toStrings().slice(-1)}
41 |
send('TOGGLE')}> 42 | {Math.ceil(duration - elapsed)} 43 |
44 |
45 | {!state.matches({ running: 'normal' }) && ( 46 | 47 | )} 48 | 49 | {state.matches({ running: 'normal' }) && ( 50 | 51 | )} 52 |
53 |
54 |
55 | {state.matches({ running: 'normal' }) && ( 56 | 59 | )} 60 | {state.matches({ running: 'overtime' }) && ( 61 | 64 | )} 65 | {(state.matches('paused') || state.matches('idle')) && ( 66 | 69 | )} 70 |
71 |
72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /src/06/Timer.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useEffect } from 'react'; 3 | import { faPlay, faPause, faStop } from '@fortawesome/free-solid-svg-icons'; 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 5 | 6 | import { useMachine } from '@xstate/react'; 7 | import { ProgressCircle } from '../ProgressCircle'; 8 | 9 | import { timerMachine } from './timerMachine'; 10 | 11 | export const Timer = () => { 12 | const [state, send] = useMachine(timerMachine); 13 | 14 | const { duration, elapsed, interval } = state.context; 15 | 16 | useEffect(() => { 17 | const intervalId = setInterval(() => { 18 | send('TICK'); 19 | }, interval * 1000); 20 | 21 | return () => clearInterval(intervalId); 22 | }, []); 23 | 24 | return ( 25 |
35 |
36 |

Exercise 06

37 |
38 | 39 |
40 |
{state.toStrings().slice(-1)}
41 |
send('TOGGLE')}> 42 | {Math.ceil(duration - elapsed)} 43 |
44 |
45 | {!state.matches({ running: 'normal' }) && ( 46 | 47 | )} 48 | 49 | {state.matches({ running: 'normal' }) && ( 50 | 51 | )} 52 |
53 |
54 |
55 | {state.matches({ running: 'normal' }) && ( 56 | 59 | )} 60 | {state.matches({ running: 'overtime' }) && ( 61 | 64 | )} 65 | {(state.matches('paused') || state.matches('idle')) && ( 66 | 69 | )} 70 |
71 |
72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /src/06/timerMachine.final.js: -------------------------------------------------------------------------------- 1 | import { createMachine, assign } from 'xstate'; 2 | 3 | const timerExpired = (ctx) => ctx.elapsed >= ctx.duration; 4 | 5 | export const timerMachine = createMachine({ 6 | initial: 'idle', 7 | context: { 8 | duration: 5, 9 | elapsed: 0, 10 | interval: 0.1, 11 | }, 12 | states: { 13 | idle: { 14 | entry: assign({ 15 | duration: 5, 16 | elapsed: 0, 17 | }), 18 | on: { 19 | TOGGLE: 'running', 20 | }, 21 | }, 22 | running: { 23 | initial: 'normal', 24 | states: { 25 | normal: { 26 | always: { 27 | target: 'overtime', 28 | cond: timerExpired, 29 | }, 30 | on: { 31 | RESET: undefined, 32 | }, 33 | }, 34 | overtime: { 35 | on: { 36 | TOGGLE: undefined, 37 | }, 38 | }, 39 | }, 40 | on: { 41 | TICK: { 42 | actions: assign({ 43 | elapsed: (ctx) => ctx.elapsed + ctx.interval, 44 | }), 45 | }, 46 | TOGGLE: 'paused', 47 | ADD_MINUTE: { 48 | actions: assign({ 49 | duration: (ctx) => ctx.duration + 60, 50 | }), 51 | }, 52 | }, 53 | }, 54 | paused: { 55 | on: { 56 | TOGGLE: 'running', 57 | }, 58 | }, 59 | }, 60 | on: { 61 | RESET: { 62 | target: '.idle', 63 | }, 64 | }, 65 | }); 66 | -------------------------------------------------------------------------------- /src/06/timerMachine.js: -------------------------------------------------------------------------------- 1 | import { createMachine, assign } from 'xstate'; 2 | 3 | const timerExpired = (ctx) => ctx.elapsed >= ctx.duration; 4 | 5 | export const timerMachine = createMachine({ 6 | initial: 'idle', 7 | context: { 8 | duration: 5, 9 | elapsed: 0, 10 | interval: 0.1, 11 | }, 12 | states: { 13 | idle: { 14 | entry: assign({ 15 | duration: 5, 16 | elapsed: 0, 17 | }), 18 | on: { 19 | TOGGLE: 'running', 20 | }, 21 | }, 22 | running: { 23 | // Add the `normal` and `overtime` nested states here. 24 | // Don't forget to add the initial state (`normal`)! 25 | // ... 26 | 27 | on: { 28 | TICK: { 29 | actions: assign({ 30 | elapsed: (ctx) => ctx.elapsed + ctx.interval, 31 | }), 32 | }, 33 | TOGGLE: 'paused', 34 | ADD_MINUTE: { 35 | actions: assign({ 36 | duration: (ctx) => ctx.duration + 60, 37 | }), 38 | }, 39 | }, 40 | }, 41 | paused: { 42 | on: { 43 | TOGGLE: 'running', 44 | }, 45 | }, 46 | }, 47 | on: { 48 | RESET: { 49 | target: '.idle', 50 | }, 51 | }, 52 | }); 53 | -------------------------------------------------------------------------------- /src/07/README.md: -------------------------------------------------------------------------------- 1 | # Exercise 07 - Invoking Actors 2 | 3 | In this exercise, we're going to move the ad-hoc interval logic that was in `useEffect` directly into the machine instead by [invoking a callback](https://xstate.js.org/docs/guides/communication.html#invoking-callbacks). 4 | 5 | ## Goals 6 | 7 | - Create a callback service that does one thing: calls back a `'TICK'` event on each interval defined by `context.interval`. This should look very similar to `useEffect()`, so make sure to also return a cleanup function. 8 | - In the `running` state, invoke that callback service using `invoke`. Since we're still listening for that `TICK` event, we're done! 9 | - Remove the `useEffect(...)` from the component, since we no longer need it. 10 | -------------------------------------------------------------------------------- /src/07/Timer.final.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { faPlay, faPause, faStop } from '@fortawesome/free-solid-svg-icons'; 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 4 | import { useMachine } from '@xstate/react'; 5 | 6 | import { ProgressCircle } from '../ProgressCircle'; 7 | import { timerMachine } from './timerMachine.final'; 8 | 9 | export const Timer = () => { 10 | const [state, send] = useMachine(timerMachine); 11 | 12 | const { duration, elapsed, interval } = state.context; 13 | 14 | return ( 15 |
25 |
26 |

Exercise 07 Solution

27 |
28 | 29 |
30 |
{state.toStrings().slice(-1)}
31 |
send('TOGGLE')}> 32 | {Math.ceil(duration - elapsed)} 33 |
34 |
35 | {!state.matches({ running: 'normal' }) && ( 36 | 37 | )} 38 | 39 | {state.matches({ running: 'normal' }) && ( 40 | 41 | )} 42 |
43 |
44 |
45 | {state.matches({ running: 'normal' }) && ( 46 | 49 | )} 50 | {state.matches({ running: 'overtime' }) && ( 51 | 54 | )} 55 | {(state.matches('paused') || state.matches('idle')) && ( 56 | 59 | )} 60 |
61 |
62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /src/07/Timer.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { faPlay, faPause, faStop } from '@fortawesome/free-solid-svg-icons'; 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 4 | import { useMachine } from '@xstate/react'; 5 | 6 | import { ProgressCircle } from '../ProgressCircle'; 7 | import { timerMachine } from './timerMachine'; 8 | 9 | export const Timer = () => { 10 | const [state, send] = useMachine(timerMachine); 11 | 12 | const { duration, elapsed, interval } = state.context; 13 | 14 | return ( 15 |
25 |
26 |

Exercise 07

27 |
28 | 29 |
30 |
{state.toStrings().slice(-1)}
31 |
send('TOGGLE')}> 32 | {Math.ceil(duration - elapsed)} 33 |
34 |
35 | {!state.matches({ running: 'normal' }) && ( 36 | 37 | )} 38 | 39 | {state.matches({ running: 'normal' }) && ( 40 | 41 | )} 42 |
43 |
44 |
45 | {state.matches({ running: 'normal' }) && ( 46 | 49 | )} 50 | {state.matches({ running: 'overtime' }) && ( 51 | 54 | )} 55 | {(state.matches('paused') || state.matches('idle')) && ( 56 | 59 | )} 60 |
61 |
62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /src/07/timerMachine.final.js: -------------------------------------------------------------------------------- 1 | import { createMachine, assign } from 'xstate'; 2 | 3 | const ticker = (ctx) => (cb) => { 4 | const interval = setInterval(() => { 5 | cb('TICK'); 6 | }, ctx.interval * 1000); 7 | return () => clearInterval(interval); 8 | }; 9 | 10 | const timerExpired = (ctx) => ctx.elapsed >= ctx.duration; 11 | 12 | // https://xstate.js.org/viz/?gist=78fef4bd3ae520709ceaee62c0dd59cd 13 | export const timerMachine = createMachine({ 14 | id: 'timer', 15 | initial: 'idle', 16 | context: { 17 | duration: 60, 18 | elapsed: 0, 19 | interval: 0.1, 20 | }, 21 | states: { 22 | idle: { 23 | entry: assign({ 24 | duration: 60, 25 | elapsed: 0, 26 | }), 27 | on: { 28 | TOGGLE: 'running', 29 | RESET: undefined, 30 | }, 31 | }, 32 | running: { 33 | invoke: { 34 | id: 'ticker', // only used for viz 35 | src: ticker, 36 | }, 37 | initial: 'normal', 38 | states: { 39 | normal: { 40 | always: { 41 | target: 'overtime', 42 | cond: timerExpired, 43 | }, 44 | on: { 45 | RESET: undefined, 46 | }, 47 | }, 48 | overtime: { 49 | on: { 50 | TOGGLE: undefined, 51 | }, 52 | }, 53 | }, 54 | on: { 55 | TICK: { 56 | actions: assign({ 57 | elapsed: (ctx) => ctx.elapsed + ctx.interval, 58 | }), 59 | }, 60 | TOGGLE: 'paused', 61 | ADD_MINUTE: { 62 | actions: assign({ 63 | duration: (ctx) => ctx.duration + 60, 64 | }), 65 | }, 66 | }, 67 | }, 68 | paused: { 69 | on: { TOGGLE: 'running' }, 70 | }, 71 | }, 72 | on: { 73 | RESET: { 74 | target: '.idle', 75 | }, 76 | }, 77 | }); 78 | -------------------------------------------------------------------------------- /src/07/timerMachine.js: -------------------------------------------------------------------------------- 1 | import { createMachine, assign } from 'xstate'; 2 | 3 | const ticker = (context, event) => (callback) => { 4 | // This is the callback service creator. 5 | // Add the implementation details here. 6 | // ... 7 | }; 8 | 9 | const timerExpired = (ctx) => ctx.elapsed >= ctx.duration; 10 | 11 | // https://xstate.js.org/viz/?gist=78fef4bd3ae520709ceaee62c0dd59cd 12 | export const timerMachine = createMachine({ 13 | id: 'timer', 14 | initial: 'idle', 15 | context: { 16 | duration: 60, 17 | elapsed: 0, 18 | interval: 0.1, 19 | }, 20 | states: { 21 | idle: { 22 | entry: assign({ 23 | duration: 60, 24 | elapsed: 0, 25 | }), 26 | on: { 27 | TOGGLE: 'running', 28 | RESET: undefined, 29 | }, 30 | }, 31 | running: { 32 | // Invoke the callback service here. 33 | // ... 34 | 35 | initial: 'normal', 36 | states: { 37 | normal: { 38 | always: { 39 | target: 'overtime', 40 | cond: timerExpired, 41 | }, 42 | on: { 43 | RESET: undefined, 44 | }, 45 | }, 46 | overtime: { 47 | on: { 48 | TOGGLE: undefined, 49 | }, 50 | }, 51 | }, 52 | on: { 53 | TICK: { 54 | actions: assign({ 55 | elapsed: (ctx) => ctx.elapsed + ctx.interval, 56 | }), 57 | }, 58 | TOGGLE: 'paused', 59 | ADD_MINUTE: { 60 | actions: assign({ 61 | duration: (ctx) => ctx.duration + 60, 62 | }), 63 | }, 64 | }, 65 | }, 66 | paused: { 67 | on: { TOGGLE: 'running' }, 68 | }, 69 | }, 70 | on: { 71 | RESET: { 72 | target: '.idle', 73 | }, 74 | }, 75 | }); 76 | -------------------------------------------------------------------------------- /src/08/App.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useMachine } from '@xstate/react'; 3 | import { Tabs, Tab, TabList, TabPanels, TabPanel } from '@reach/tabs'; 4 | import { NewTimer } from './NewTimer'; 5 | import { Timer } from './Timer'; 6 | import { Clock } from './Clock'; 7 | import { timerAppMachine } from './timerAppMachine'; 8 | 9 | export const App = () => { 10 | const [state, send] = useMachine(timerAppMachine); 11 | const { timers } = state.context; 12 | 13 | return ( 14 | 20 | 21 | Clock 22 | Timer 23 | 24 | 25 | 26 | 27 | 28 | 29 | { 31 | send({ type: 'ADD', duration }); 32 | }} 33 | onCancel={ 34 | timers.length 35 | ? () => { 36 | send('CANCEL'); 37 | } 38 | : undefined 39 | } 40 | key={state.toStrings().join(' ')} 41 | /> 42 | 59 | 75 | 76 | 77 | 78 | ); 79 | }; 80 | -------------------------------------------------------------------------------- /src/08/Clock.js: -------------------------------------------------------------------------------- 1 | import { useMachine } from '@xstate/react'; 2 | import { createContext } from 'react'; 3 | import { clockMachine } from './clockMachine'; 4 | import { ForeignClock } from './ForeignClock'; 5 | 6 | export const LocalTimeContext = createContext(); 7 | 8 | export function Clock() { 9 | const [state, send, service] = useMachine(clockMachine); 10 | const { time } = state.context; 11 | 12 | return ( 13 | 14 |
15 |
16 |

{time.toLocaleTimeString('en-US')}

17 | {time.toLocaleDateString()} 18 |
19 |
20 | 21 |
22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/08/ForeignClock.js: -------------------------------------------------------------------------------- 1 | import { useMachine, useService } from '@xstate/react'; 2 | import { useContext, useEffect } from 'react'; 3 | import { useQuery } from 'react-query'; 4 | import { LocalTimeContext } from './Clock'; 5 | import { foreignClockMachine } from './foreignClockMachine'; 6 | import mockTimezones from './timezones.json'; 7 | 8 | export function ForeignClock() { 9 | const localTimeService = useContext(LocalTimeContext); 10 | const [localTimeState] = useService(localTimeService); 11 | const [state, send] = useMachine(foreignClockMachine); 12 | 13 | const { data } = useQuery('timezones', () => { 14 | // return Promise.resolve(mockTimezones); 15 | return fetch('http://worldtimeapi.org/api/timezone').then((data) => 16 | data.json() 17 | ); 18 | }); 19 | 20 | useEffect(() => { 21 | if (data) { 22 | send({ 23 | type: 'TIMEZONES.LOADED', 24 | data, 25 | }); 26 | } 27 | }, [data, send]); 28 | 29 | useEffect(() => { 30 | send({ 31 | type: 'LOCAL.UPDATE', 32 | time: localTimeState.context.time, 33 | }); 34 | }, [localTimeState, send]); 35 | 36 | const { timezones, foreignTime } = state.context; 37 | 38 | return ( 39 |
40 | {(state.matches('timezonesLoaded') || timezones) && ( 41 | <> 42 | 58 | {foreignTime || '--'} 59 | 60 | )} 61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /src/08/NewTimer.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 3 | import { faPlay } from '@fortawesome/free-solid-svg-icons'; 4 | import { useMachine } from '@xstate/react'; 5 | import { newTimerMachine } from './newTimerMachine'; 6 | import { useRef } from 'react'; 7 | 8 | export const NewTimer = ({ onSubmit, onCancel }) => { 9 | const inputRef = useRef(); 10 | const [state, send] = useMachine(newTimerMachine, { 11 | actions: { 12 | submit: (context) => { 13 | onSubmit(context.duration); 14 | }, 15 | }, 16 | }); 17 | 18 | React.useEffect(() => { 19 | inputRef.current?.focus(); 20 | }, [inputRef]); 21 | 22 | const { duration } = state.context; 23 | 24 | return ( 25 |
{ 30 | e.preventDefault(); 31 | send(e); 32 | }} 33 | > 34 | 43 |
44 | {onCancel ? ( 45 | 55 | ) : null} 56 | 57 | 63 |
64 |
65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /src/08/README.md: -------------------------------------------------------------------------------- 1 | # Exercise 08 - Spawning Actors 2 | 3 | You've made it to the final exercise! 🎉 We'll learn how to _distribute_ state in localized, private little entities called [actors](https://xstate.js.org/docs/guides/actors.html). 4 | 5 | ## Goals 6 | 7 | - Learn how to `spawn()` actors dynamically and see how this can be helpful in isolating logic and state 8 | - In `timerAppMachine.js`, spawn a new timer machine via `createTimerMachine()` on the `ADD` action. 9 | - Update the `context` via `assign()` so that it has the updated `context.timers` (with the newly spawned timer appended) and `context.currentTimer` (with the index of that timer). 10 | 11 | --- 12 | 13 | Overall, a sample user flow should look like this: 14 | 15 | 1. User sees the `` screen 16 | 2. User enters a number (greater than 0) 17 | 3. User presses the **enter** key or clicks the **Play** button 18 | 4. Now, the `` screen shows with the user's specified duration 19 | 5. That timer should be immediately started. 20 | 6. When the user presses the **Delete** button, we should: 21 | - Go to the previous timer, if there are any. 22 | - Otherwise, go back to the `` screen. 23 | 7. When the user presses the **Add** button, we should go to the `` screen to add a new timer. 24 | - If the user presses the **Cancel** button on this screen, we should go back to the previous timer. 25 | -------------------------------------------------------------------------------- /src/08/Timer.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { faPlay, faPause, faStop } from '@fortawesome/free-solid-svg-icons'; 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 4 | import { useService } from '@xstate/react'; 5 | 6 | import { ProgressCircle } from '../ProgressCircle'; 7 | 8 | export const Timer = ({ onDelete, onAdd, timerRef, ...attrs }) => { 9 | const [state, send] = useService(timerRef); 10 | 11 | const { duration, elapsed, interval } = state.context; 12 | 13 | return ( 14 |
25 |
26 | XState Minute Timer 27 |
28 | 29 |
30 |
{state.toStrings().slice(-1)}
31 |
send('TOGGLE')}> 32 | {Math.ceil(duration - elapsed)} 33 |
34 |
35 | {!state.matches({ running: 'normal' }) && ( 36 | 37 | )} 38 | 39 | {state.matches({ running: 'normal' }) && ( 40 | 41 | )} 42 |
43 |
44 |
45 | 54 | {state.matches({ running: 'normal' }) && ( 55 | 58 | )} 59 | {state.matches({ running: 'overtime' }) && ( 60 | 63 | )} 64 | {(state.matches('paused') || state.matches('idle')) && ( 65 | 68 | )} 69 | 78 |
79 |
80 | ); 81 | }; 82 | -------------------------------------------------------------------------------- /src/08/clockMachine.js: -------------------------------------------------------------------------------- 1 | import { assign, createMachine } from 'xstate'; 2 | 3 | export const clockMachine = createMachine({ 4 | id: 'clock', 5 | initial: 'active', 6 | context: { 7 | time: new Date(), 8 | }, 9 | states: { 10 | active: { 11 | invoke: { 12 | id: 'interval', 13 | src: () => (sendBack) => { 14 | const interval = setInterval(() => { 15 | sendBack({ 16 | type: 'TICK', 17 | time: new Date(), 18 | }); 19 | }, 1000); 20 | 21 | return () => { 22 | clearInterval(interval); 23 | }; 24 | }, 25 | }, 26 | on: { 27 | TICK: { 28 | actions: assign({ 29 | time: (_, event) => event.time, 30 | }), 31 | }, 32 | }, 33 | }, 34 | }, 35 | }); 36 | -------------------------------------------------------------------------------- /src/08/foreignClockMachine.js: -------------------------------------------------------------------------------- 1 | import { assign, createMachine } from 'xstate'; 2 | 3 | export const foreignClockMachine = createMachine({ 4 | initial: 'loadingTimezones', 5 | context: { 6 | timezones: null, 7 | timezone: null, 8 | foreignTime: null, 9 | }, 10 | states: { 11 | loadingTimezones: { 12 | on: { 13 | 'TIMEZONES.LOADED': { 14 | target: 'time', 15 | actions: assign({ 16 | timezones: (_, e) => e.data, 17 | }), 18 | }, 19 | }, 20 | }, 21 | time: { 22 | initial: 'noTimezoneSelected', 23 | states: { 24 | noTimezoneSelected: {}, 25 | timezoneSelected: { 26 | on: { 27 | 'LOCAL.UPDATE': { 28 | actions: assign({ 29 | foreignTime: (ctx, event) => { 30 | return new Date(event.time).toLocaleTimeString('en-US', { 31 | timeZone: ctx.timezone, 32 | }); 33 | }, 34 | }), 35 | }, 36 | }, 37 | }, 38 | }, 39 | on: { 40 | 'TIMEZONE.CHANGE': { 41 | target: '.timezoneSelected', 42 | actions: assign((ctx, e) => ({ 43 | timezone: e.value, 44 | foreignTime: new Date().toLocaleTimeString('en-US', { 45 | timeZone: e.value, 46 | }), 47 | })), 48 | }, 49 | }, 50 | }, 51 | }, 52 | }); 53 | -------------------------------------------------------------------------------- /src/08/newTimerMachine.js: -------------------------------------------------------------------------------- 1 | import { assign, createMachine } from 'xstate'; 2 | 3 | export const durationValid = (context) => { 4 | return !isNaN(context.duration) && context.duration > 0; 5 | }; 6 | 7 | export const assignDuration = assign({ 8 | duration: (_, event) => +event.target.value, 9 | }); 10 | 11 | export const newTimerMachine = createMachine({ 12 | initial: 'active', 13 | context: { 14 | duration: 0, 15 | }, 16 | states: { 17 | active: { 18 | on: { 19 | change: { 20 | actions: assignDuration, 21 | }, 22 | submit: { 23 | cond: durationValid, 24 | actions: 'submit', 25 | }, 26 | }, 27 | }, 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /src/08/timerAppMachine.final.js: -------------------------------------------------------------------------------- 1 | import { createMachine, assign, spawn } from 'xstate'; 2 | import { createTimerMachine } from './timerMachine'; 3 | 4 | export const timerAppMachine = createMachine({ 5 | initial: 'new', 6 | context: { 7 | duration: 0, 8 | currentTimer: -1, 9 | timers: [], 10 | }, 11 | states: { 12 | new: { 13 | on: { 14 | CANCEL: { 15 | target: 'timer', 16 | cond: (ctx) => ctx.timers.length > 0, 17 | }, 18 | }, 19 | }, 20 | timer: { 21 | on: { 22 | DELETE: { 23 | actions: assign((ctx) => { 24 | const timers = ctx.timers.slice(0, -1); 25 | const currentTimer = timers.length - 1; 26 | 27 | return { 28 | timers, 29 | currentTimer, 30 | }; 31 | }), 32 | target: 'deleting', 33 | }, 34 | }, 35 | }, 36 | deleting: { 37 | always: [ 38 | { target: 'new', cond: (ctx) => ctx.timers.length === 0 }, 39 | { target: 'timer' }, 40 | ], 41 | }, 42 | }, 43 | on: { 44 | ADD: { 45 | target: '.timer', 46 | actions: assign((ctx, event) => { 47 | const newTimer = spawn(createTimerMachine(event.duration)); 48 | 49 | const timers = ctx.timers.concat(newTimer); 50 | 51 | return { 52 | timers, 53 | currentTimer: timers.length - 1, 54 | }; 55 | }), 56 | }, 57 | CREATE: 'new', 58 | SWITCH: { 59 | actions: assign({ 60 | currentTimer: (_, event) => event.index, 61 | }), 62 | }, 63 | }, 64 | }); 65 | -------------------------------------------------------------------------------- /src/08/timerAppMachine.js: -------------------------------------------------------------------------------- 1 | import { createMachine, assign, spawn } from 'xstate'; 2 | import { createTimerMachine } from './timerMachine'; 3 | 4 | export const timerAppMachine = createMachine({ 5 | initial: 'new', 6 | context: { 7 | duration: 0, 8 | currentTimer: -1, 9 | timers: [], 10 | }, 11 | states: { 12 | new: { 13 | on: { 14 | CANCEL: { 15 | target: 'timer', 16 | cond: (ctx) => ctx.timers.length > 0, 17 | }, 18 | }, 19 | }, 20 | timer: { 21 | on: { 22 | DELETE: { 23 | actions: assign((ctx) => { 24 | const timers = ctx.timers.slice(0, -1); 25 | const currentTimer = timers.length - 1; 26 | 27 | return { 28 | timers, 29 | currentTimer, 30 | }; 31 | }), 32 | target: 'deleting', 33 | }, 34 | }, 35 | }, 36 | deleting: { 37 | always: [ 38 | { target: 'new', cond: (ctx) => ctx.timers.length === 0 }, 39 | { target: 'timer' }, 40 | ], 41 | }, 42 | }, 43 | on: { 44 | ADD: { 45 | // Uncomment this once you've added the spawn() code: 46 | // target: '.timer', 47 | actions: assign((ctx, event) => { 48 | // Spawn a new timerMachine here (using createTimerMachine) 49 | // and append this timer to context.timers 50 | // ... 51 | 52 | // Change the below line to return the updated context: 53 | // - `context.timers` should contain the appended spawned timer 54 | // - `context.currentTimer` should be the index of that spawned timer 55 | return ctx; 56 | }), 57 | }, 58 | CREATE: 'new', 59 | SWITCH: { 60 | actions: assign({ 61 | currentTimer: (_, event) => event.index, 62 | }), 63 | }, 64 | }, 65 | }); 66 | -------------------------------------------------------------------------------- /src/08/timerMachine.js: -------------------------------------------------------------------------------- 1 | import { createMachine, assign } from 'xstate'; 2 | 3 | const ticker = (ctx) => (sendBack) => { 4 | const interval = setInterval(() => { 5 | sendBack('TICK'); 6 | }, ctx.interval * 1000); 7 | 8 | return () => clearInterval(interval); 9 | }; 10 | 11 | const timerExpired = (ctx) => ctx.elapsed >= ctx.duration; 12 | 13 | // https://xstate.js.org/viz/?gist=78fef4bd3ae520709ceaee62c0dd59cd 14 | export const createTimerMachine = (duration) => 15 | createMachine({ 16 | id: 'timer', 17 | initial: 'running', 18 | context: { 19 | duration, 20 | elapsed: 0, 21 | interval: 0.1, 22 | }, 23 | states: { 24 | idle: { 25 | entry: assign({ 26 | duration, 27 | elapsed: 0, 28 | }), 29 | on: { 30 | TOGGLE: 'running', 31 | RESET: undefined, 32 | }, 33 | }, 34 | running: { 35 | invoke: { 36 | id: 'ticker', // only used for viz 37 | src: ticker, 38 | }, 39 | initial: 'normal', 40 | states: { 41 | normal: { 42 | always: { 43 | target: 'overtime', 44 | cond: timerExpired, 45 | }, 46 | on: { 47 | RESET: undefined, 48 | }, 49 | }, 50 | overtime: { 51 | on: { 52 | TOGGLE: undefined, 53 | }, 54 | }, 55 | }, 56 | on: { 57 | TICK: { 58 | actions: assign({ 59 | elapsed: (ctx) => ctx.elapsed + ctx.interval, 60 | }), 61 | }, 62 | TOGGLE: 'paused', 63 | ADD_MINUTE: { 64 | actions: assign({ 65 | duration: (ctx) => ctx.duration + 60, 66 | }), 67 | }, 68 | }, 69 | }, 70 | paused: { 71 | on: { TOGGLE: 'running' }, 72 | }, 73 | }, 74 | on: { 75 | RESET: { 76 | target: '.idle', 77 | }, 78 | }, 79 | }); 80 | -------------------------------------------------------------------------------- /src/08/timezones.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Africa/Abidjan", 3 | "Africa/Accra", 4 | "Africa/Algiers", 5 | "Africa/Bissau", 6 | "Africa/Cairo", 7 | "Africa/Casablanca", 8 | "Africa/Ceuta", 9 | "Africa/El_Aaiun", 10 | "Africa/Johannesburg", 11 | "Africa/Juba", 12 | "Africa/Khartoum", 13 | "Africa/Lagos", 14 | "Africa/Maputo", 15 | "Africa/Monrovia", 16 | "Africa/Nairobi", 17 | "Africa/Ndjamena", 18 | "Africa/Sao_Tome", 19 | "Africa/Tripoli", 20 | "Africa/Tunis", 21 | "Africa/Windhoek", 22 | "America/Adak", 23 | "America/Anchorage", 24 | "America/Araguaina", 25 | "America/Argentina/Buenos_Aires", 26 | "America/Argentina/Catamarca", 27 | "America/Argentina/Cordoba", 28 | "America/Argentina/Jujuy", 29 | "America/Argentina/La_Rioja", 30 | "America/Argentina/Mendoza", 31 | "America/Argentina/Rio_Gallegos", 32 | "America/Argentina/Salta", 33 | "America/Argentina/San_Juan", 34 | "America/Argentina/San_Luis", 35 | "America/Argentina/Tucuman", 36 | "America/Argentina/Ushuaia", 37 | "America/Asuncion", 38 | "America/Atikokan", 39 | "America/Bahia", 40 | "America/Bahia_Banderas", 41 | "America/Barbados", 42 | "America/Belem", 43 | "America/Belize", 44 | "America/Blanc-Sablon", 45 | "America/Boa_Vista", 46 | "America/Bogota", 47 | "America/Boise", 48 | "America/Cambridge_Bay", 49 | "America/Campo_Grande", 50 | "America/Cancun", 51 | "America/Caracas", 52 | "America/Cayenne", 53 | "America/Chicago", 54 | "America/Chihuahua", 55 | "America/Costa_Rica", 56 | "America/Creston", 57 | "America/Cuiaba", 58 | "America/Curacao", 59 | "America/Danmarkshavn", 60 | "America/Dawson", 61 | "America/Dawson_Creek", 62 | "America/Denver", 63 | "America/Detroit", 64 | "America/Edmonton", 65 | "America/Eirunepe", 66 | "America/El_Salvador", 67 | "America/Fort_Nelson", 68 | "America/Fortaleza", 69 | "America/Glace_Bay", 70 | "America/Goose_Bay", 71 | "America/Grand_Turk", 72 | "America/Guatemala", 73 | "America/Guayaquil", 74 | "America/Guyana", 75 | "America/Halifax", 76 | "America/Havana", 77 | "America/Hermosillo", 78 | "America/Indiana/Indianapolis", 79 | "America/Indiana/Knox", 80 | "America/Indiana/Marengo", 81 | "America/Indiana/Petersburg", 82 | "America/Indiana/Tell_City", 83 | "America/Indiana/Vevay", 84 | "America/Indiana/Vincennes", 85 | "America/Indiana/Winamac", 86 | "America/Inuvik", 87 | "America/Iqaluit", 88 | "America/Jamaica", 89 | "America/Juneau", 90 | "America/Kentucky/Louisville", 91 | "America/Kentucky/Monticello", 92 | "America/La_Paz", 93 | "America/Lima", 94 | "America/Los_Angeles", 95 | "America/Maceio", 96 | "America/Managua", 97 | "America/Manaus", 98 | "America/Martinique", 99 | "America/Matamoros", 100 | "America/Mazatlan", 101 | "America/Menominee", 102 | "America/Merida", 103 | "America/Metlakatla", 104 | "America/Mexico_City", 105 | "America/Miquelon", 106 | "America/Moncton", 107 | "America/Monterrey", 108 | "America/Montevideo", 109 | "America/Nassau", 110 | "America/New_York", 111 | "America/Nipigon", 112 | "America/Nome", 113 | "America/Noronha", 114 | "America/North_Dakota/Beulah", 115 | "America/North_Dakota/Center", 116 | "America/North_Dakota/New_Salem", 117 | "America/Nuuk", 118 | "America/Ojinaga", 119 | "America/Panama", 120 | "America/Pangnirtung", 121 | "America/Paramaribo", 122 | "America/Phoenix", 123 | "America/Port-au-Prince", 124 | "America/Port_of_Spain", 125 | "America/Porto_Velho", 126 | "America/Puerto_Rico", 127 | "America/Punta_Arenas", 128 | "America/Rainy_River", 129 | "America/Rankin_Inlet", 130 | "America/Recife", 131 | "America/Regina", 132 | "America/Resolute", 133 | "America/Rio_Branco", 134 | "America/Santarem", 135 | "America/Santiago", 136 | "America/Santo_Domingo", 137 | "America/Sao_Paulo", 138 | "America/Scoresbysund", 139 | "America/Sitka", 140 | "America/St_Johns", 141 | "America/Swift_Current", 142 | "America/Tegucigalpa", 143 | "America/Thule", 144 | "America/Thunder_Bay", 145 | "America/Tijuana", 146 | "America/Toronto", 147 | "America/Vancouver", 148 | "America/Whitehorse", 149 | "America/Winnipeg", 150 | "America/Yakutat", 151 | "America/Yellowknife", 152 | "Antarctica/Casey", 153 | "Antarctica/Davis", 154 | "Antarctica/DumontDUrville", 155 | "Antarctica/Macquarie", 156 | "Antarctica/Mawson", 157 | "Antarctica/Palmer", 158 | "Antarctica/Rothera", 159 | "Antarctica/Syowa", 160 | "Antarctica/Troll", 161 | "Antarctica/Vostok", 162 | "Asia/Almaty", 163 | "Asia/Amman", 164 | "Asia/Anadyr", 165 | "Asia/Aqtau", 166 | "Asia/Aqtobe", 167 | "Asia/Ashgabat", 168 | "Asia/Atyrau", 169 | "Asia/Baghdad", 170 | "Asia/Baku", 171 | "Asia/Bangkok", 172 | "Asia/Barnaul", 173 | "Asia/Beirut", 174 | "Asia/Bishkek", 175 | "Asia/Brunei", 176 | "Asia/Chita", 177 | "Asia/Choibalsan", 178 | "Asia/Colombo", 179 | "Asia/Damascus", 180 | "Asia/Dhaka", 181 | "Asia/Dili", 182 | "Asia/Dubai", 183 | "Asia/Dushanbe", 184 | "Asia/Famagusta", 185 | "Asia/Gaza", 186 | "Asia/Hebron", 187 | "Asia/Ho_Chi_Minh", 188 | "Asia/Hong_Kong", 189 | "Asia/Hovd", 190 | "Asia/Irkutsk", 191 | "Asia/Jakarta", 192 | "Asia/Jayapura", 193 | "Asia/Jerusalem", 194 | "Asia/Kabul", 195 | "Asia/Kamchatka", 196 | "Asia/Karachi", 197 | "Asia/Kathmandu", 198 | "Asia/Khandyga", 199 | "Asia/Kolkata", 200 | "Asia/Krasnoyarsk", 201 | "Asia/Kuala_Lumpur", 202 | "Asia/Kuching", 203 | "Asia/Macau", 204 | "Asia/Magadan", 205 | "Asia/Makassar", 206 | "Asia/Manila", 207 | "Asia/Nicosia", 208 | "Asia/Novokuznetsk", 209 | "Asia/Novosibirsk", 210 | "Asia/Omsk", 211 | "Asia/Oral", 212 | "Asia/Pontianak", 213 | "Asia/Pyongyang", 214 | "Asia/Qatar", 215 | "Asia/Qostanay", 216 | "Asia/Qyzylorda", 217 | "Asia/Riyadh", 218 | "Asia/Sakhalin", 219 | "Asia/Samarkand", 220 | "Asia/Seoul", 221 | "Asia/Shanghai", 222 | "Asia/Singapore", 223 | "Asia/Srednekolymsk", 224 | "Asia/Taipei", 225 | "Asia/Tashkent", 226 | "Asia/Tbilisi", 227 | "Asia/Tehran", 228 | "Asia/Thimphu", 229 | "Asia/Tokyo", 230 | "Asia/Tomsk", 231 | "Asia/Ulaanbaatar", 232 | "Asia/Urumqi", 233 | "Asia/Ust-Nera", 234 | "Asia/Vladivostok", 235 | "Asia/Yakutsk", 236 | "Asia/Yangon", 237 | "Asia/Yekaterinburg", 238 | "Asia/Yerevan", 239 | "Atlantic/Azores", 240 | "Atlantic/Bermuda", 241 | "Atlantic/Canary", 242 | "Atlantic/Cape_Verde", 243 | "Atlantic/Faroe", 244 | "Atlantic/Madeira", 245 | "Atlantic/Reykjavik", 246 | "Atlantic/South_Georgia", 247 | "Atlantic/Stanley", 248 | "Australia/Adelaide", 249 | "Australia/Brisbane", 250 | "Australia/Broken_Hill", 251 | "Australia/Currie", 252 | "Australia/Darwin", 253 | "Australia/Eucla", 254 | "Australia/Hobart", 255 | "Australia/Lindeman", 256 | "Australia/Lord_Howe", 257 | "Australia/Melbourne", 258 | "Australia/Perth", 259 | "Australia/Sydney", 260 | "CET", 261 | "CST6CDT", 262 | "EET", 263 | "EST", 264 | "EST5EDT", 265 | "Etc/GMT", 266 | "Etc/GMT+1", 267 | "Etc/GMT+10", 268 | "Etc/GMT+11", 269 | "Etc/GMT+12", 270 | "Etc/GMT+2", 271 | "Etc/GMT+3", 272 | "Etc/GMT+4", 273 | "Etc/GMT+5", 274 | "Etc/GMT+6", 275 | "Etc/GMT+7", 276 | "Etc/GMT+8", 277 | "Etc/GMT+9", 278 | "Etc/GMT-1", 279 | "Etc/GMT-10", 280 | "Etc/GMT-11", 281 | "Etc/GMT-12", 282 | "Etc/GMT-13", 283 | "Etc/GMT-14", 284 | "Etc/GMT-2", 285 | "Etc/GMT-3", 286 | "Etc/GMT-4", 287 | "Etc/GMT-5", 288 | "Etc/GMT-6", 289 | "Etc/GMT-7", 290 | "Etc/GMT-8", 291 | "Etc/GMT-9", 292 | "Etc/UTC", 293 | "Europe/Amsterdam", 294 | "Europe/Andorra", 295 | "Europe/Astrakhan", 296 | "Europe/Athens", 297 | "Europe/Belgrade", 298 | "Europe/Berlin", 299 | "Europe/Brussels", 300 | "Europe/Bucharest", 301 | "Europe/Budapest", 302 | "Europe/Chisinau", 303 | "Europe/Copenhagen", 304 | "Europe/Dublin", 305 | "Europe/Gibraltar", 306 | "Europe/Helsinki", 307 | "Europe/Istanbul", 308 | "Europe/Kaliningrad", 309 | "Europe/Kiev", 310 | "Europe/Kirov", 311 | "Europe/Lisbon", 312 | "Europe/London", 313 | "Europe/Luxembourg", 314 | "Europe/Madrid", 315 | "Europe/Malta", 316 | "Europe/Minsk", 317 | "Europe/Monaco", 318 | "Europe/Moscow", 319 | "Europe/Oslo", 320 | "Europe/Paris", 321 | "Europe/Prague", 322 | "Europe/Riga", 323 | "Europe/Rome", 324 | "Europe/Samara", 325 | "Europe/Saratov", 326 | "Europe/Simferopol", 327 | "Europe/Sofia", 328 | "Europe/Stockholm", 329 | "Europe/Tallinn", 330 | "Europe/Tirane", 331 | "Europe/Ulyanovsk", 332 | "Europe/Uzhgorod", 333 | "Europe/Vienna", 334 | "Europe/Vilnius", 335 | "Europe/Volgograd", 336 | "Europe/Warsaw", 337 | "Europe/Zaporozhye", 338 | "Europe/Zurich", 339 | "HST", 340 | "Indian/Chagos", 341 | "Indian/Christmas", 342 | "Indian/Cocos", 343 | "Indian/Kerguelen", 344 | "Indian/Mahe", 345 | "Indian/Maldives", 346 | "Indian/Mauritius", 347 | "Indian/Reunion", 348 | "MET", 349 | "MST", 350 | "MST7MDT", 351 | "PST8PDT", 352 | "Pacific/Apia", 353 | "Pacific/Auckland", 354 | "Pacific/Bougainville", 355 | "Pacific/Chatham", 356 | "Pacific/Chuuk", 357 | "Pacific/Easter", 358 | "Pacific/Efate", 359 | "Pacific/Enderbury", 360 | "Pacific/Fakaofo", 361 | "Pacific/Fiji", 362 | "Pacific/Funafuti", 363 | "Pacific/Galapagos", 364 | "Pacific/Gambier", 365 | "Pacific/Guadalcanal", 366 | "Pacific/Guam", 367 | "Pacific/Honolulu", 368 | "Pacific/Kiritimati", 369 | "Pacific/Kosrae", 370 | "Pacific/Kwajalein", 371 | "Pacific/Majuro", 372 | "Pacific/Marquesas", 373 | "Pacific/Nauru", 374 | "Pacific/Niue", 375 | "Pacific/Norfolk", 376 | "Pacific/Noumea", 377 | "Pacific/Pago_Pago", 378 | "Pacific/Palau", 379 | "Pacific/Pitcairn", 380 | "Pacific/Pohnpei", 381 | "Pacific/Port_Moresby", 382 | "Pacific/Rarotonga", 383 | "Pacific/Tahiti", 384 | "Pacific/Tarawa", 385 | "Pacific/Tongatapu", 386 | "Pacific/Wake", 387 | "Pacific/Wallis", 388 | "WET" 389 | ] 390 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import * as React from 'react'; 3 | import { createModel } from '@xstate/test'; 4 | import { createMachine, assign } from 'xstate'; 5 | import { render, fireEvent, cleanup } from '@testing-library/react'; 6 | import { App } from './complete/App'; 7 | 8 | const testTimerAppMachine = createMachine({ 9 | initial: 'newTimer', 10 | context: { 11 | value: '', 12 | }, 13 | states: { 14 | newTimer: { 15 | initial: 'noTimers', 16 | states: { 17 | noTimers: {}, 18 | afterDeleted: {}, 19 | adding: {}, 20 | }, 21 | on: { 22 | CHANGE: { 23 | actions: assign({ 24 | value: 124, 25 | }), 26 | }, 27 | PLAY: { 28 | cond: (ctx) => ctx.value > 0, 29 | target: 'timer', 30 | }, 31 | }, 32 | meta: { 33 | test: async ({ getByTestId }) => { 34 | getByTestId('new-timer'); // [data-testid="new-timer"] 35 | }, 36 | }, 37 | }, 38 | timer: { 39 | on: { 40 | DELETE: 'newTimer.afterDeleted', 41 | ADD: 'newTimer.adding', 42 | }, 43 | meta: { 44 | test: async ({ getByText }, state) => { 45 | getByText(/XState Minute Timer/i); 46 | }, 47 | }, 48 | }, 49 | }, 50 | }); 51 | 52 | const testTimerAppModel = createModel(testTimerAppMachine).withEvents({ 53 | CHANGE: { 54 | exec: async ({ getByTitle }) => { 55 | const input = getByTitle(/Duration/i); 56 | 57 | fireEvent.change(input, { target: { value: '124' } }); 58 | }, 59 | }, 60 | PLAY: { 61 | exec: async ({ getByTitle }) => { 62 | const addButton = getByTitle(/Start .* timer/i); 63 | 64 | fireEvent.click(addButton); 65 | }, 66 | }, 67 | DELETE: { 68 | exec: async ({ getByTitle }) => { 69 | const deleteButton = getByTitle(/Delete/i); 70 | 71 | fireEvent.click(deleteButton); 72 | }, 73 | }, 74 | ADD: { 75 | exec: async ({ getByTitle }) => { 76 | const addButton = getByTitle(/Add/i); 77 | 78 | fireEvent.click(addButton); 79 | }, 80 | }, 81 | }); 82 | 83 | describe('something', () => { 84 | const testPlans = testTimerAppModel.getSimplePathPlans(); 85 | 86 | testPlans.forEach((plan) => { 87 | describe(plan.description, () => { 88 | afterEach(cleanup); 89 | 90 | plan.paths.forEach((path) => { 91 | it(path.description, () => { 92 | const rendered = render(); 93 | 94 | return path.test(rendered); 95 | }); 96 | }); 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /src/Exercise.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useQuery } from 'react-query'; 3 | import Markdown from 'react-markdown'; 4 | import { Link } from 'react-router-dom'; 5 | 6 | export const Exercise = ({children, markdown, backLink = ← Home}) => { 7 | const { status, data } = useQuery(markdown, () => { 8 | return fetch(markdown).then(res => res.text()) 9 | }); 10 | 11 | return
12 |
13 | {backLink} 14 | 15 | 16 | {data || '...'} 17 | 18 |
19 | {children} 20 |
21 | } 22 | -------------------------------------------------------------------------------- /src/ProgressCircle.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export const ProgressCircle = () => { 4 | return ( 5 | 12 | 13 | 14 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /src/Workshop.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter as Router, Switch, Route } from 'react-router-dom'; 3 | 4 | // To see the final versions of each exercise, append .final to the path; e.g.: 5 | // import { Timer as Timer00 } from './00/Timer.final'; 6 | 7 | import { Timer as Timer00 } from './00/Timer'; 8 | import { Timer as Timer01 } from './01/Timer'; 9 | import { Timer as Timer02 } from './02/Timer'; 10 | import { Timer as Timer03 } from './03/Timer'; 11 | import { Timer as Timer04 } from './04/Timer'; 12 | import { Timer as Timer05 } from './05/Timer'; 13 | import { Timer as Timer06 } from './06/Timer'; 14 | import { Timer as Timer07 } from './07/Timer'; 15 | import { App as App08 } from './08/App'; 16 | import { App as AppComplete } from './complete/App'; 17 | import { ScratchApp } from './scratch'; 18 | import { Exercise } from './Exercise'; 19 | 20 | function getMarkdownLink(exercise) { 21 | return require(`./${exercise}/README.md`).default; 22 | } 23 | 24 | function App() { 25 | return ( 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | ); 86 | } 87 | 88 | export default App; 89 | -------------------------------------------------------------------------------- /src/complete/App.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useMachine } from '@xstate/react'; 3 | import { Tabs, Tab, TabList, TabPanels, TabPanel } from '@reach/tabs'; 4 | import { NewTimer } from './NewTimer'; 5 | import { Timer } from './Timer'; 6 | import { Clock } from './Clock'; 7 | import { timerAppMachine } from './timerAppMachine'; 8 | import { inspect } from '@xstate/inspect'; 9 | 10 | // inspect({ 11 | // iframe: false, 12 | // }); 13 | 14 | export const App = () => { 15 | const [state, send] = useMachine(timerAppMachine, { devTools: true }); 16 | const { timers } = state.context; 17 | 18 | return ( 19 | 25 | 26 | Clock 27 | Timer 28 | 29 | 30 | 31 | 32 | 33 | 34 | { 36 | send({ type: 'ADD', duration }); 37 | }} 38 | onCancel={ 39 | timers.length 40 | ? () => { 41 | send('CANCEL'); 42 | } 43 | : undefined 44 | } 45 | key={state.toStrings().join(' ')} 46 | /> 47 | 64 | 80 | 81 | 82 | 83 | ); 84 | }; 85 | -------------------------------------------------------------------------------- /src/complete/Clock.js: -------------------------------------------------------------------------------- 1 | import { useMachine } from '@xstate/react'; 2 | import { createContext } from 'react'; 3 | import { clockMachine } from './clockMachine'; 4 | import { ForeignClock } from './ForeignClock'; 5 | 6 | export const LocalTimeContext = createContext(); 7 | 8 | export function Clock() { 9 | const [state, send, service] = useMachine(clockMachine); 10 | const { time } = state.context; 11 | 12 | return ( 13 | 14 |
15 |
16 |

17 | {time.toLocaleTimeString('en-US', { 18 | hour: '2-digit', 19 | minute: '2-digit', 20 | })} 21 |

22 | {time.toLocaleDateString()} 23 |
24 |
25 | 26 |
27 |
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/complete/ForeignClock.js: -------------------------------------------------------------------------------- 1 | import { useMachine, useService } from '@xstate/react'; 2 | import { useContext, useEffect } from 'react'; 3 | import { useQuery } from 'react-query'; 4 | import { LocalTimeContext } from './Clock'; 5 | import { foreignClockMachine } from './foreignClockMachine'; 6 | import mockTimezones from './timezones.json'; 7 | 8 | export function ForeignClock() { 9 | const localTimeService = useContext(LocalTimeContext); 10 | const [localTimeState] = useService(localTimeService); 11 | const [state, send] = useMachine(foreignClockMachine); 12 | 13 | const { data } = useQuery('timezones', () => { 14 | // return Promise.resolve(mockTimezones); 15 | return fetch('http://worldtimeapi.org/api/timezone').then((data) => 16 | data.json() 17 | ); 18 | }); 19 | 20 | useEffect(() => { 21 | if (data) { 22 | send({ 23 | type: 'TIMEZONES.LOADED', 24 | data, 25 | }); 26 | } 27 | }, [data, send]); 28 | 29 | useEffect(() => { 30 | send({ 31 | type: 'LOCAL.UPDATE', 32 | time: localTimeState.context.time, 33 | }); 34 | }, [localTimeState, send]); 35 | 36 | const { timezones, foreignTime, timezone } = state.context; 37 | 38 | const formattedTime = foreignTime?.toLocaleTimeString('en-US', { 39 | hour: '2-digit', 40 | minute: '2-digit', 41 | timeZone: timezone, 42 | }); 43 | 44 | return ( 45 |
46 | {(state.matches('timezonesLoaded') || timezones) && ( 47 | <> 48 | 64 | {formattedTime || '--'} 65 | 66 | )} 67 |
68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /src/complete/NewTimer.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 3 | import { faPlay } from '@fortawesome/free-solid-svg-icons'; 4 | import { useMachine } from '@xstate/react'; 5 | import { newTimerMachine } from './newTimerMachine'; 6 | import { useRef } from 'react'; 7 | 8 | export const NewTimer = ({ onSubmit, onCancel }) => { 9 | const inputRef = useRef(); 10 | const [state, send] = useMachine(newTimerMachine, { 11 | actions: { 12 | submit: (context) => { 13 | onSubmit(context.duration); 14 | }, 15 | }, 16 | }); 17 | 18 | React.useEffect(() => { 19 | inputRef.current?.focus(); 20 | }, [inputRef]); 21 | 22 | const { duration } = state.context; 23 | 24 | return ( 25 |
{ 30 | e.preventDefault(); 31 | send(e); 32 | }} 33 | > 34 | 43 |
44 | {onCancel ? ( 45 | 55 | ) : null} 56 | 57 | 63 |
64 |
65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /src/complete/README.md: -------------------------------------------------------------------------------- 1 | # Complete App 2 | -------------------------------------------------------------------------------- /src/complete/Timer.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { faPlay, faPause, faStop } from '@fortawesome/free-solid-svg-icons'; 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 4 | import { useService } from '@xstate/react'; 5 | import { ProgressCircle } from '../ProgressCircle'; 6 | 7 | export const Timer = ({ onDelete, onAdd, timerRef, ...attrs }) => { 8 | const [state, send] = useService(timerRef); 9 | 10 | const { duration, elapsed, interval } = state.context; 11 | 12 | return ( 13 |
24 |
25 | XState Minute Timer 26 |
27 | 28 |
29 |
{state.toStrings().slice(-1)}
30 |
send('TOGGLE')}> 31 | {Math.ceil(duration - elapsed)} 32 |
33 |
34 | {!state.matches({ running: 'normal' }) && ( 35 | 36 | )} 37 | 38 | {state.matches({ running: 'normal' }) && ( 39 | 40 | )} 41 |
42 |
43 |
44 | 53 | {state.matches({ running: 'normal' }) && ( 54 | 57 | )} 58 | {state.matches({ running: 'overtime' }) && ( 59 | 62 | )} 63 | {(state.matches('paused') || state.matches('idle')) && ( 64 | 67 | )} 68 | 77 |
78 |
79 | ); 80 | }; 81 | -------------------------------------------------------------------------------- /src/complete/clockMachine.js: -------------------------------------------------------------------------------- 1 | import { assign, createMachine } from 'xstate'; 2 | 3 | export const clockMachine = createMachine({ 4 | id: 'clock', 5 | initial: 'active', 6 | context: { 7 | time: new Date(), 8 | }, 9 | states: { 10 | active: { 11 | invoke: { 12 | id: 'interval', 13 | src: () => (sendBack) => { 14 | const interval = setInterval(() => { 15 | sendBack({ 16 | type: 'TICK', 17 | time: new Date(), 18 | }); 19 | }, 1000); 20 | 21 | return () => { 22 | clearInterval(interval); 23 | }; 24 | }, 25 | }, 26 | on: { 27 | TICK: { 28 | actions: assign({ 29 | time: (_, event) => event.time, 30 | }), 31 | }, 32 | }, 33 | }, 34 | }, 35 | }); 36 | -------------------------------------------------------------------------------- /src/complete/foreignClockMachine.js: -------------------------------------------------------------------------------- 1 | import { assign, createMachine } from 'xstate'; 2 | 3 | export const foreignClockMachine = createMachine({ 4 | initial: 'loadingTimezones', 5 | context: { 6 | timezones: null, 7 | timezone: null, 8 | foreignTime: null, 9 | }, 10 | states: { 11 | loadingTimezones: { 12 | on: { 13 | 'TIMEZONES.LOADED': { 14 | target: 'time', 15 | actions: assign({ 16 | timezones: (_, e) => e.data, 17 | }), 18 | }, 19 | }, 20 | }, 21 | time: { 22 | initial: 'noTimezoneSelected', 23 | states: { 24 | noTimezoneSelected: {}, 25 | timezoneSelected: { 26 | on: { 27 | 'LOCAL.UPDATE': { 28 | actions: assign({ 29 | foreignTime: (ctx, event) => { 30 | return new Date(event.time); 31 | }, 32 | }), 33 | }, 34 | }, 35 | }, 36 | }, 37 | on: { 38 | 'TIMEZONE.CHANGE': { 39 | target: '.timezoneSelected', 40 | actions: assign((ctx, e) => ({ 41 | timezone: e.value, 42 | foreignTime: new Date(), 43 | })), 44 | }, 45 | }, 46 | }, 47 | }, 48 | }); 49 | -------------------------------------------------------------------------------- /src/complete/newTimerMachine.js: -------------------------------------------------------------------------------- 1 | import { assign, createMachine } from 'xstate'; 2 | 3 | export const durationValid = (context) => { 4 | return !isNaN(context.duration) && context.duration > 0; 5 | }; 6 | 7 | export const assignDuration = assign({ 8 | duration: (_, event) => +event.target.value, 9 | }); 10 | 11 | export const newTimerMachine = createMachine({ 12 | initial: 'active', 13 | context: { 14 | duration: 0, 15 | }, 16 | states: { 17 | active: { 18 | on: { 19 | change: { 20 | actions: assignDuration, 21 | }, 22 | submit: { 23 | cond: durationValid, 24 | actions: 'submit', 25 | }, 26 | }, 27 | }, 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /src/complete/timerAppMachine.js: -------------------------------------------------------------------------------- 1 | import { createMachine, assign, spawn } from 'xstate'; 2 | import { createTimerMachine } from './timerMachine'; 3 | 4 | export const timerAppMachine = createMachine({ 5 | initial: 'new', 6 | context: { 7 | duration: 0, 8 | currentTimer: -1, 9 | timers: [], 10 | }, 11 | states: { 12 | new: { 13 | on: { 14 | CANCEL: { 15 | target: 'timer', 16 | cond: (ctx) => ctx.timers.length > 0, 17 | }, 18 | }, 19 | }, 20 | timer: { 21 | on: { 22 | DELETE: { 23 | actions: assign((ctx, e) => { 24 | const timers = ctx.timers.filter((_, i) => i !== e.index); 25 | const currentTimer = timers.length - 1; 26 | 27 | return { 28 | timers, 29 | currentTimer, 30 | }; 31 | }), 32 | target: 'deleting', 33 | }, 34 | }, 35 | }, 36 | deleting: { 37 | always: [ 38 | { target: 'new', cond: (ctx) => ctx.timers.length === 0 }, 39 | { target: 'timer' }, 40 | ], 41 | }, 42 | }, 43 | on: { 44 | ADD: { 45 | target: '.timer', 46 | actions: assign((ctx, event) => { 47 | const newTimer = spawn(createTimerMachine(event.duration)); 48 | 49 | const timers = ctx.timers.concat(newTimer); 50 | 51 | return { 52 | timers, 53 | currentTimer: timers.length - 1, 54 | }; 55 | }), 56 | }, 57 | CREATE: 'new', 58 | SWITCH: { 59 | actions: assign({ 60 | currentTimer: (_, event) => event.index, 61 | }), 62 | }, 63 | }, 64 | }); 65 | -------------------------------------------------------------------------------- /src/complete/timerMachine.js: -------------------------------------------------------------------------------- 1 | import { createMachine, assign } from 'xstate'; 2 | 3 | const ticker = (ctx) => (sendBack) => { 4 | const interval = setInterval(() => { 5 | sendBack('TICK'); 6 | }, ctx.interval * 1000); 7 | 8 | return () => clearInterval(interval); 9 | }; 10 | 11 | const timerExpired = (ctx) => ctx.elapsed >= ctx.duration; 12 | 13 | // https://xstate.js.org/viz/?gist=78fef4bd3ae520709ceaee62c0dd59cd 14 | export const createTimerMachine = (duration) => 15 | createMachine({ 16 | id: 'timer', 17 | initial: 'running', 18 | context: { 19 | duration, 20 | elapsed: 0, 21 | interval: 0.1, 22 | }, 23 | states: { 24 | idle: { 25 | entry: assign({ 26 | duration, 27 | elapsed: 0, 28 | }), 29 | on: { 30 | TOGGLE: 'running', 31 | RESET: undefined, 32 | }, 33 | }, 34 | running: { 35 | invoke: { 36 | id: 'ticker', // only used for viz 37 | src: ticker, 38 | }, 39 | initial: 'normal', 40 | states: { 41 | normal: { 42 | always: { 43 | target: 'overtime', 44 | cond: timerExpired, 45 | }, 46 | on: { 47 | RESET: undefined, 48 | }, 49 | }, 50 | overtime: { 51 | on: { 52 | TOGGLE: undefined, 53 | }, 54 | }, 55 | }, 56 | on: { 57 | TICK: { 58 | actions: assign({ 59 | elapsed: (ctx) => ctx.elapsed + ctx.interval, 60 | }), 61 | }, 62 | TOGGLE: 'paused', 63 | ADD_MINUTE: { 64 | actions: assign({ 65 | duration: (ctx) => ctx.duration + 60, 66 | }), 67 | }, 68 | }, 69 | }, 70 | paused: { 71 | on: { TOGGLE: 'running' }, 72 | }, 73 | }, 74 | on: { 75 | RESET: { 76 | target: '.idle', 77 | }, 78 | }, 79 | }); 80 | -------------------------------------------------------------------------------- /src/complete/timezones.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Africa/Abidjan", 3 | "Africa/Accra", 4 | "Africa/Algiers", 5 | "Africa/Bissau", 6 | "Africa/Cairo", 7 | "Africa/Casablanca", 8 | "Africa/Ceuta", 9 | "Africa/El_Aaiun", 10 | "Africa/Johannesburg", 11 | "Africa/Juba", 12 | "Africa/Khartoum", 13 | "Africa/Lagos", 14 | "Africa/Maputo", 15 | "Africa/Monrovia", 16 | "Africa/Nairobi", 17 | "Africa/Ndjamena", 18 | "Africa/Sao_Tome", 19 | "Africa/Tripoli", 20 | "Africa/Tunis", 21 | "Africa/Windhoek", 22 | "America/Adak", 23 | "America/Anchorage", 24 | "America/Araguaina", 25 | "America/Argentina/Buenos_Aires", 26 | "America/Argentina/Catamarca", 27 | "America/Argentina/Cordoba", 28 | "America/Argentina/Jujuy", 29 | "America/Argentina/La_Rioja", 30 | "America/Argentina/Mendoza", 31 | "America/Argentina/Rio_Gallegos", 32 | "America/Argentina/Salta", 33 | "America/Argentina/San_Juan", 34 | "America/Argentina/San_Luis", 35 | "America/Argentina/Tucuman", 36 | "America/Argentina/Ushuaia", 37 | "America/Asuncion", 38 | "America/Atikokan", 39 | "America/Bahia", 40 | "America/Bahia_Banderas", 41 | "America/Barbados", 42 | "America/Belem", 43 | "America/Belize", 44 | "America/Blanc-Sablon", 45 | "America/Boa_Vista", 46 | "America/Bogota", 47 | "America/Boise", 48 | "America/Cambridge_Bay", 49 | "America/Campo_Grande", 50 | "America/Cancun", 51 | "America/Caracas", 52 | "America/Cayenne", 53 | "America/Chicago", 54 | "America/Chihuahua", 55 | "America/Costa_Rica", 56 | "America/Creston", 57 | "America/Cuiaba", 58 | "America/Curacao", 59 | "America/Danmarkshavn", 60 | "America/Dawson", 61 | "America/Dawson_Creek", 62 | "America/Denver", 63 | "America/Detroit", 64 | "America/Edmonton", 65 | "America/Eirunepe", 66 | "America/El_Salvador", 67 | "America/Fort_Nelson", 68 | "America/Fortaleza", 69 | "America/Glace_Bay", 70 | "America/Goose_Bay", 71 | "America/Grand_Turk", 72 | "America/Guatemala", 73 | "America/Guayaquil", 74 | "America/Guyana", 75 | "America/Halifax", 76 | "America/Havana", 77 | "America/Hermosillo", 78 | "America/Indiana/Indianapolis", 79 | "America/Indiana/Knox", 80 | "America/Indiana/Marengo", 81 | "America/Indiana/Petersburg", 82 | "America/Indiana/Tell_City", 83 | "America/Indiana/Vevay", 84 | "America/Indiana/Vincennes", 85 | "America/Indiana/Winamac", 86 | "America/Inuvik", 87 | "America/Iqaluit", 88 | "America/Jamaica", 89 | "America/Juneau", 90 | "America/Kentucky/Louisville", 91 | "America/Kentucky/Monticello", 92 | "America/La_Paz", 93 | "America/Lima", 94 | "America/Los_Angeles", 95 | "America/Maceio", 96 | "America/Managua", 97 | "America/Manaus", 98 | "America/Martinique", 99 | "America/Matamoros", 100 | "America/Mazatlan", 101 | "America/Menominee", 102 | "America/Merida", 103 | "America/Metlakatla", 104 | "America/Mexico_City", 105 | "America/Miquelon", 106 | "America/Moncton", 107 | "America/Monterrey", 108 | "America/Montevideo", 109 | "America/Nassau", 110 | "America/New_York", 111 | "America/Nipigon", 112 | "America/Nome", 113 | "America/Noronha", 114 | "America/North_Dakota/Beulah", 115 | "America/North_Dakota/Center", 116 | "America/North_Dakota/New_Salem", 117 | "America/Nuuk", 118 | "America/Ojinaga", 119 | "America/Panama", 120 | "America/Pangnirtung", 121 | "America/Paramaribo", 122 | "America/Phoenix", 123 | "America/Port-au-Prince", 124 | "America/Port_of_Spain", 125 | "America/Porto_Velho", 126 | "America/Puerto_Rico", 127 | "America/Punta_Arenas", 128 | "America/Rainy_River", 129 | "America/Rankin_Inlet", 130 | "America/Recife", 131 | "America/Regina", 132 | "America/Resolute", 133 | "America/Rio_Branco", 134 | "America/Santarem", 135 | "America/Santiago", 136 | "America/Santo_Domingo", 137 | "America/Sao_Paulo", 138 | "America/Scoresbysund", 139 | "America/Sitka", 140 | "America/St_Johns", 141 | "America/Swift_Current", 142 | "America/Tegucigalpa", 143 | "America/Thule", 144 | "America/Thunder_Bay", 145 | "America/Tijuana", 146 | "America/Toronto", 147 | "America/Vancouver", 148 | "America/Whitehorse", 149 | "America/Winnipeg", 150 | "America/Yakutat", 151 | "America/Yellowknife", 152 | "Antarctica/Casey", 153 | "Antarctica/Davis", 154 | "Antarctica/DumontDUrville", 155 | "Antarctica/Macquarie", 156 | "Antarctica/Mawson", 157 | "Antarctica/Palmer", 158 | "Antarctica/Rothera", 159 | "Antarctica/Syowa", 160 | "Antarctica/Troll", 161 | "Antarctica/Vostok", 162 | "Asia/Almaty", 163 | "Asia/Amman", 164 | "Asia/Anadyr", 165 | "Asia/Aqtau", 166 | "Asia/Aqtobe", 167 | "Asia/Ashgabat", 168 | "Asia/Atyrau", 169 | "Asia/Baghdad", 170 | "Asia/Baku", 171 | "Asia/Bangkok", 172 | "Asia/Barnaul", 173 | "Asia/Beirut", 174 | "Asia/Bishkek", 175 | "Asia/Brunei", 176 | "Asia/Chita", 177 | "Asia/Choibalsan", 178 | "Asia/Colombo", 179 | "Asia/Damascus", 180 | "Asia/Dhaka", 181 | "Asia/Dili", 182 | "Asia/Dubai", 183 | "Asia/Dushanbe", 184 | "Asia/Famagusta", 185 | "Asia/Gaza", 186 | "Asia/Hebron", 187 | "Asia/Ho_Chi_Minh", 188 | "Asia/Hong_Kong", 189 | "Asia/Hovd", 190 | "Asia/Irkutsk", 191 | "Asia/Jakarta", 192 | "Asia/Jayapura", 193 | "Asia/Jerusalem", 194 | "Asia/Kabul", 195 | "Asia/Kamchatka", 196 | "Asia/Karachi", 197 | "Asia/Kathmandu", 198 | "Asia/Khandyga", 199 | "Asia/Kolkata", 200 | "Asia/Krasnoyarsk", 201 | "Asia/Kuala_Lumpur", 202 | "Asia/Kuching", 203 | "Asia/Macau", 204 | "Asia/Magadan", 205 | "Asia/Makassar", 206 | "Asia/Manila", 207 | "Asia/Nicosia", 208 | "Asia/Novokuznetsk", 209 | "Asia/Novosibirsk", 210 | "Asia/Omsk", 211 | "Asia/Oral", 212 | "Asia/Pontianak", 213 | "Asia/Pyongyang", 214 | "Asia/Qatar", 215 | "Asia/Qostanay", 216 | "Asia/Qyzylorda", 217 | "Asia/Riyadh", 218 | "Asia/Sakhalin", 219 | "Asia/Samarkand", 220 | "Asia/Seoul", 221 | "Asia/Shanghai", 222 | "Asia/Singapore", 223 | "Asia/Srednekolymsk", 224 | "Asia/Taipei", 225 | "Asia/Tashkent", 226 | "Asia/Tbilisi", 227 | "Asia/Tehran", 228 | "Asia/Thimphu", 229 | "Asia/Tokyo", 230 | "Asia/Tomsk", 231 | "Asia/Ulaanbaatar", 232 | "Asia/Urumqi", 233 | "Asia/Ust-Nera", 234 | "Asia/Vladivostok", 235 | "Asia/Yakutsk", 236 | "Asia/Yangon", 237 | "Asia/Yekaterinburg", 238 | "Asia/Yerevan", 239 | "Atlantic/Azores", 240 | "Atlantic/Bermuda", 241 | "Atlantic/Canary", 242 | "Atlantic/Cape_Verde", 243 | "Atlantic/Faroe", 244 | "Atlantic/Madeira", 245 | "Atlantic/Reykjavik", 246 | "Atlantic/South_Georgia", 247 | "Atlantic/Stanley", 248 | "Australia/Adelaide", 249 | "Australia/Brisbane", 250 | "Australia/Broken_Hill", 251 | "Australia/Currie", 252 | "Australia/Darwin", 253 | "Australia/Eucla", 254 | "Australia/Hobart", 255 | "Australia/Lindeman", 256 | "Australia/Lord_Howe", 257 | "Australia/Melbourne", 258 | "Australia/Perth", 259 | "Australia/Sydney", 260 | "CET", 261 | "CST6CDT", 262 | "EET", 263 | "EST", 264 | "EST5EDT", 265 | "Etc/GMT", 266 | "Etc/GMT+1", 267 | "Etc/GMT+10", 268 | "Etc/GMT+11", 269 | "Etc/GMT+12", 270 | "Etc/GMT+2", 271 | "Etc/GMT+3", 272 | "Etc/GMT+4", 273 | "Etc/GMT+5", 274 | "Etc/GMT+6", 275 | "Etc/GMT+7", 276 | "Etc/GMT+8", 277 | "Etc/GMT+9", 278 | "Etc/GMT-1", 279 | "Etc/GMT-10", 280 | "Etc/GMT-11", 281 | "Etc/GMT-12", 282 | "Etc/GMT-13", 283 | "Etc/GMT-14", 284 | "Etc/GMT-2", 285 | "Etc/GMT-3", 286 | "Etc/GMT-4", 287 | "Etc/GMT-5", 288 | "Etc/GMT-6", 289 | "Etc/GMT-7", 290 | "Etc/GMT-8", 291 | "Etc/GMT-9", 292 | "Etc/UTC", 293 | "Europe/Amsterdam", 294 | "Europe/Andorra", 295 | "Europe/Astrakhan", 296 | "Europe/Athens", 297 | "Europe/Belgrade", 298 | "Europe/Berlin", 299 | "Europe/Brussels", 300 | "Europe/Bucharest", 301 | "Europe/Budapest", 302 | "Europe/Chisinau", 303 | "Europe/Copenhagen", 304 | "Europe/Dublin", 305 | "Europe/Gibraltar", 306 | "Europe/Helsinki", 307 | "Europe/Istanbul", 308 | "Europe/Kaliningrad", 309 | "Europe/Kiev", 310 | "Europe/Kirov", 311 | "Europe/Lisbon", 312 | "Europe/London", 313 | "Europe/Luxembourg", 314 | "Europe/Madrid", 315 | "Europe/Malta", 316 | "Europe/Minsk", 317 | "Europe/Monaco", 318 | "Europe/Moscow", 319 | "Europe/Oslo", 320 | "Europe/Paris", 321 | "Europe/Prague", 322 | "Europe/Riga", 323 | "Europe/Rome", 324 | "Europe/Samara", 325 | "Europe/Saratov", 326 | "Europe/Simferopol", 327 | "Europe/Sofia", 328 | "Europe/Stockholm", 329 | "Europe/Tallinn", 330 | "Europe/Tirane", 331 | "Europe/Ulyanovsk", 332 | "Europe/Uzhgorod", 333 | "Europe/Vienna", 334 | "Europe/Vilnius", 335 | "Europe/Volgograd", 336 | "Europe/Warsaw", 337 | "Europe/Zaporozhye", 338 | "Europe/Zurich", 339 | "HST", 340 | "Indian/Chagos", 341 | "Indian/Christmas", 342 | "Indian/Cocos", 343 | "Indian/Kerguelen", 344 | "Indian/Mahe", 345 | "Indian/Maldives", 346 | "Indian/Mauritius", 347 | "Indian/Reunion", 348 | "MET", 349 | "MST", 350 | "MST7MDT", 351 | "PST8PDT", 352 | "Pacific/Apia", 353 | "Pacific/Auckland", 354 | "Pacific/Bougainville", 355 | "Pacific/Chatham", 356 | "Pacific/Chuuk", 357 | "Pacific/Easter", 358 | "Pacific/Efate", 359 | "Pacific/Enderbury", 360 | "Pacific/Fakaofo", 361 | "Pacific/Fiji", 362 | "Pacific/Funafuti", 363 | "Pacific/Galapagos", 364 | "Pacific/Gambier", 365 | "Pacific/Guadalcanal", 366 | "Pacific/Guam", 367 | "Pacific/Honolulu", 368 | "Pacific/Kiritimati", 369 | "Pacific/Kosrae", 370 | "Pacific/Kwajalein", 371 | "Pacific/Majuro", 372 | "Pacific/Marquesas", 373 | "Pacific/Nauru", 374 | "Pacific/Niue", 375 | "Pacific/Norfolk", 376 | "Pacific/Noumea", 377 | "Pacific/Pago_Pago", 378 | "Pacific/Palau", 379 | "Pacific/Pitcairn", 380 | "Pacific/Pohnpei", 381 | "Pacific/Port_Moresby", 382 | "Pacific/Rarotonga", 383 | "Pacific/Tahiti", 384 | "Pacific/Tarawa", 385 | "Pacific/Tongatapu", 386 | "Pacific/Wake", 387 | "Pacific/Wallis", 388 | "WET" 389 | ] 390 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.scss'; 4 | import App from './Workshop'; 5 | import * as serviceWorker from './serviceWorker'; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById('root') 12 | ); 13 | 14 | // If you want your app to work offline and load faster, you can change 15 | // unregister() to register() below. Note this comes with some pitfalls. 16 | // Learn more about service workers: https://bit.ly/CRA-PWA 17 | serviceWorker.unregister(); 18 | -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Lato:wght@400&display=swap'); 2 | @import './styles/exercise.scss'; 3 | @import './styles/dots.scss'; 4 | @import './styles/newTimer.scss'; 5 | @import './styles/scratch.scss'; 6 | @import './styles/alarm.scss'; 7 | 8 | *, 9 | *:before, 10 | *:after { 11 | box-sizing: border-box; 12 | position: relative; 13 | } 14 | 15 | [hidden] { 16 | display: none !important; 17 | } 18 | 19 | html, 20 | body, 21 | #root { 22 | height: 100%; 23 | width: 100%; 24 | margin: 0; 25 | padding: 0; 26 | font-family: 'Lato', sans-serif; 27 | font-weight: 400; 28 | font-size: 16px; 29 | } 30 | 31 | :root { 32 | --color-primary: #5192e6; 33 | --color-primary-light: hsla(214, 75%, 72%, 1); 34 | --color-secondary: #8b9096; 35 | --color-gray: #373b41; 36 | --color-dark-gray: #25272a; 37 | } 38 | 39 | #root { 40 | background-color: #18191c; 41 | color: white; 42 | } 43 | 44 | .app { 45 | height: 100%; 46 | width: 100%; 47 | display: grid; 48 | grid-template-columns: 1fr; 49 | grid-template-rows: auto 1fr; 50 | overflow: hidden; 51 | 52 | > .app-panels { 53 | grid-template-rows: 2 / -1; 54 | display: grid; 55 | grid-template-columns: 1fr; 56 | grid-template-rows: 1fr; 57 | overflow: scroll; 58 | 59 | > .app-panel { 60 | grid-area: 1 / 1; 61 | display: grid; 62 | grid-template-columns: 1fr; 63 | grid-template-rows: 1fr; 64 | overflow: scroll; 65 | } 66 | 67 | .timers, 68 | .timer, 69 | [data-screen] { 70 | grid-area: 1 / 1; 71 | transition: transform 0.6s ease-in-out, opacity 0.3s ease-out; 72 | } 73 | 74 | .dots { 75 | position: absolute; 76 | top: 0; 77 | right: 0; 78 | width: 5rem; 79 | height: 100%; 80 | } 81 | 82 | .timer:not([data-active]) { 83 | display: none; 84 | } 85 | } 86 | 87 | &[data-state='timer'] { 88 | [data-screen='new-timer'] { 89 | transform: translateY(-50%); 90 | opacity: 0; 91 | } 92 | 93 | .timer { 94 | animation: slide-up 0.6s ease-in-out both; 95 | 96 | @keyframes slide-up { 97 | from { 98 | opacity: 0; 99 | transform: translateY(50%); 100 | } 101 | 50% { 102 | opacity: 1; 103 | } 104 | } 105 | } 106 | } 107 | } 108 | 109 | .app-tabs { 110 | display: flex; 111 | flex-direction: row; 112 | justify-content: stretch; 113 | background-color: var(--color-dark-gray); 114 | } 115 | 116 | .app-tab { 117 | appearance: none; 118 | background: transparent; 119 | flex-grow: 1; 120 | display: flex; 121 | justify-content: center; 122 | align-items: center; 123 | padding: 1rem 0; 124 | border-bottom: 0.25rem solid transparent; 125 | 126 | &[data-selected] { 127 | border-color: var(--color-primary); 128 | } 129 | } 130 | 131 | input[type='number']::-webkit-inner-spin-button, 132 | input[type='number']::-webkit-outer-spin-button { 133 | -webkit-appearance: none; 134 | margin: 0; 135 | } 136 | 137 | .timer, 138 | .clock, 139 | [data-screen] { 140 | display: grid; 141 | grid-template-rows: 20vh 1fr 25vh; 142 | grid-template-areas: 'header' 'main' 'actions'; 143 | grid-row-gap: 1rem; 144 | } 145 | 146 | .timer { 147 | --progress: calc((var(--elapsed)) / var(--duration)); 148 | height: 100%; 149 | overflow: auto; 150 | 151 | > header { 152 | grid-area: header; 153 | } 154 | 155 | > .circles, 156 | > .display { 157 | grid-area: main; 158 | align-self: center; 159 | justify-self: center; 160 | } 161 | 162 | > .actions { 163 | grid-area: actions; 164 | } 165 | 166 | &[data-state~='idle'] { 167 | .progress { 168 | transition-duration: 0.3s; 169 | } 170 | } 171 | 172 | &[data-state~='paused'] { 173 | .elapsed { 174 | animation: pulse 1s steps(1) infinite; 175 | } 176 | } 177 | 178 | &[data-state~='running.overtime'], 179 | &[data-state~='expired'] { 180 | circle { 181 | animation: pulse 1s steps(1) infinite; 182 | } 183 | .elapsed { 184 | color: white; 185 | } 186 | } 187 | 188 | &[data-state~='running.overtime'], 189 | &[data-state~='expired'] { 190 | --progress: 1; 191 | } 192 | 193 | &[data-state~='running.normal'], 194 | &[data-state~='paused'] { 195 | --progress: calc((var(--elapsed) + var(--interval)) / var(--duration)); 196 | } 197 | } 198 | 199 | .actions { 200 | display: grid; 201 | grid-template-columns: repeat(3, 1fr); 202 | grid-template-areas: 'left center right'; 203 | align-items: center; 204 | justify-items: center; 205 | 206 | > button:not(.transparent) { 207 | grid-area: center; 208 | appearance: none; 209 | background-color: var(--color-primary); 210 | color: white; 211 | height: 4rem; 212 | width: 4rem; 213 | border-radius: 4rem; 214 | border: none; 215 | font-size: 1.5rem; 216 | font-weight: bold; 217 | transition: background-color 0.3s ease; 218 | 219 | &:hover { 220 | background-color: var(--color-primary-light); 221 | } 222 | } 223 | 224 | > button.transparent { 225 | width: auto; 226 | background: transparent; 227 | padding: 1rem; 228 | } 229 | } 230 | 231 | button { 232 | appearance: none; 233 | color: white; 234 | background: rgba(white, 0.2); 235 | padding: 0.5rem 1rem; 236 | border: none; 237 | font-size: 1.5rem; 238 | cursor: pointer; // I know, I know 239 | transition: transform 0.3s; 240 | 241 | &:focus { 242 | outline: none; // I KNOW DO NOT @ ME 243 | } 244 | 245 | &:disabled { 246 | opacity: 0.2; 247 | } 248 | 249 | &[hidden] { 250 | transform: scale(0); 251 | } 252 | } 253 | 254 | .display { 255 | display: flex; 256 | flex-direction: column; 257 | justify-content: center; 258 | align-items: center; 259 | } 260 | 261 | .elapsed { 262 | color: var(--color-primary); 263 | font-size: 5rem; 264 | padding: 1rem; 265 | transition: color 0.3s ease; 266 | cursor: pointer; 267 | 268 | &:hover { 269 | color: white; 270 | } 271 | } 272 | 273 | .label { 274 | color: var(--color-secondary); 275 | } 276 | 277 | .controls { 278 | display: flex; 279 | flex-direction: row; 280 | 281 | > button { 282 | appearance: none; 283 | border: none; 284 | background: transparent; 285 | color: white; 286 | font-size: 1rem; 287 | } 288 | } 289 | 290 | .circles { 291 | transform: rotate(-0.25turn); 292 | height: 100%; 293 | width: 100%; 294 | pointer-events: none; 295 | } 296 | 297 | circle { 298 | stroke: white; 299 | stroke-linejoin: round; 300 | stroke-linecap: round; 301 | stroke-width: 1px; 302 | } 303 | 304 | .progress { 305 | --interval-duration: calc(var(--interval) * 1s); 306 | transition: stroke-dashoffset var(--interval-duration) linear; 307 | stroke: var(--color-primary); 308 | stroke-dashoffset: calc(1px - var(--progress) * -1px); 309 | stroke-dasharray: 1 1; 310 | stroke-width: 1.1px; 311 | } 312 | 313 | @keyframes pulse { 314 | from, 315 | to { 316 | opacity: 0; 317 | } 318 | 50% { 319 | opacity: 1; 320 | } 321 | } 322 | 323 | header { 324 | display: flex; 325 | justify-content: center; 326 | align-items: center; 327 | 328 | a { 329 | color: white; 330 | opacity: 0.5; 331 | transition: opacity 0.3s ease; 332 | 333 | &:hover { 334 | opacity: 1; 335 | } 336 | } 337 | } 338 | 339 | // ..... 340 | 341 | .local { 342 | align-self: self-end; 343 | } 344 | 345 | .localTime { 346 | font-size: 5rem; 347 | font-weight: normal; 348 | margin: 0; 349 | text-align: center; 350 | color: var(--color-primary); 351 | } 352 | 353 | .localDate { 354 | font-size: 1.5rem; 355 | display: block; 356 | text-align: center; 357 | } 358 | 359 | .foreignItem { 360 | display: grid; 361 | padding: 1rem; 362 | grid-template-columns: 1fr 1fr; 363 | grid-template-rows: 1fr; 364 | grid-column-gap: 1rem; 365 | grid-row-gap: 0.5rem; 366 | grid-template-areas: 'city time'; 367 | border-top: 1px solid #333; 368 | 369 | > .foreignCity { 370 | grid-area: city; 371 | } 372 | > .foreignTime { 373 | grid-area: time; 374 | } 375 | > .foreignDetails { 376 | grid-area: details; 377 | } 378 | } 379 | 380 | .foreignCity { 381 | appearance: none; 382 | background: transparent; 383 | color: white; 384 | border: 1px solid white; 385 | padding: 0 1rem; 386 | justify-self: self-start; 387 | display: inline-block; 388 | 389 | &:focus { 390 | outline: none; 391 | } 392 | } 393 | 394 | .foreignTime { 395 | font-size: 2rem; 396 | align-self: center; 397 | } 398 | -------------------------------------------------------------------------------- /src/scratch/README.md: -------------------------------------------------------------------------------- 1 | # Frontend Masters React + XState Workshop 2 | 3 | Welcome to the Frontend Masters workshop on modeling state with XState in React applications! While working on these exercises, be sure to check out these resources: 4 | 5 | - [XState docs](https://xstate.js.org/docs/) 6 | - [@xstate/react docs](https://xstate.js.org/docs/packages/xstate-react/) 7 | 8 | Edit the `/scratch/index.js` file to practice new lessons and techniques. The `` component will show up to the right. 9 | 10 | ## Exercises 11 | 12 | - [Exercise 00](./00) 13 | - [Exercise 01](./01) 14 | - [Exercise 02](./02) 15 | - [Exercise 03](./03) 16 | - [Exercise 04](./04) 17 | - [Exercise 05](./05) 18 | - [Exercise 06](./06) 19 | - [Exercise 07](./07) 20 | - [Exercise 08](./08) 21 | - [Complete App](./complete) 22 | -------------------------------------------------------------------------------- /src/scratch/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createMachine } from 'xstate'; 3 | import { useMachine } from '@xstate/react'; 4 | 5 | export const ScratchApp = () => { 6 | return ( 7 |
8 |
9 |
10 | {new Date().toLocaleTimeString('en-US', { 11 | hour: '2-digit', 12 | minute: '2-digit', 13 | })} 14 |
15 |
16 |
17 |
18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { 'Service-Worker': 'script' }, 105 | }) 106 | .then(response => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get('content-type'); 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf('javascript') === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then(registration => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log( 126 | 'No internet connection found. App is running in offline mode.' 127 | ); 128 | }); 129 | } 130 | 131 | export function unregister() { 132 | if ('serviceWorker' in navigator) { 133 | navigator.serviceWorker.ready 134 | .then(registration => { 135 | registration.unregister(); 136 | }) 137 | .catch(error => { 138 | console.error(error.message); 139 | }); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /src/styles/alarm.scss: -------------------------------------------------------------------------------- 1 | .alarm { 2 | display: grid; 3 | grid-template-columns: 1fr 2rem; 4 | grid-column-gap: 1rem; 5 | 6 | > * { 7 | align-self: center; 8 | } 9 | } 10 | 11 | .alarmTime { 12 | font-size: 2rem; 13 | } 14 | 15 | .alarmToggle { 16 | --height: 1rem; 17 | --color-current: white; 18 | height: var(--height); 19 | width: calc(var(--height) * 2); 20 | background: var(--color-gray); 21 | border-radius: var(--height); 22 | cursor: pointer; 23 | 24 | &:before { 25 | content: ''; 26 | position: absolute; 27 | top: 0; 28 | left: 0; 29 | height: var(--height); 30 | width: var(--height); 31 | border-radius: var(--height); 32 | background: var(--color-current); 33 | transition: all 0.3s; 34 | } 35 | 36 | &[data-active] { 37 | --color-current: var(--color-primary); 38 | &:before { 39 | transform: translateX(100%); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/styles/dots.scss: -------------------------------------------------------------------------------- 1 | .dots { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: center; 5 | align-items: center; 6 | transition: all 0.3s; 7 | 8 | &[hidden] { 9 | opacity: 0; 10 | transform: translateX(100%); 11 | } 12 | } 13 | 14 | .dot { 15 | height: 1rem; 16 | width: 1rem; 17 | border: 1px solid white; 18 | border-radius: 0.5rem; 19 | margin: 0.5rem 0; 20 | 21 | &[data-active] { 22 | background: white; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/styles/exercise.scss: -------------------------------------------------------------------------------- 1 | .exercise { 2 | display: grid; 3 | grid-template-columns: 1fr 1fr; 4 | height: 100%; 5 | width: 100%; 6 | overflow: hidden; 7 | line-height: 1.6; 8 | 9 | a { 10 | color: var(--color-primary); 11 | } 12 | } 13 | 14 | .exerciseDescription { 15 | padding: 2rem; 16 | overflow-y: auto; 17 | border-right: 1px solid #555; 18 | 19 | code { 20 | display: inline-block; 21 | background: rgba(white, 0.2); 22 | border-radius: 0.25rem; 23 | padding: 0.1rem 0.25rem; 24 | margin-bottom: 0.125rem; 25 | } 26 | 27 | li { 28 | margin: 1rem; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/styles/newTimer.scss: -------------------------------------------------------------------------------- 1 | [data-screen='new-timer'] { 2 | > input { 3 | grid-area: header; 4 | align-self: self-end; 5 | justify-self: center; 6 | appearance: none; 7 | background: transparent; 8 | font-size: 5rem; 9 | width: 10ch; 10 | border: none; 11 | border-bottom: 1px solid white; 12 | color: white; 13 | direction: rtl; 14 | &:focus { 15 | outline: none; 16 | } 17 | } 18 | 19 | > .actions { 20 | grid-area: actions; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/styles/scratch.scss: -------------------------------------------------------------------------------- 1 | .scratch { 2 | padding: 2rem; 3 | } 4 | --------------------------------------------------------------------------------