├── .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 | You need to enable JavaScript to run this app.
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 | dispatch({ type: 'RESET' })}>Reset
44 | )}
45 |
46 |
47 |
48 | {state === 'running' && (
49 | dispatch({ type: 'TOGGLE' })}
51 | title="Pause timer"
52 | >
53 |
54 |
55 | )}
56 | {(state === 'paused' || state === 'idle') && (
57 | dispatch({ type: 'TOGGLE' })}
59 | title="Start timer"
60 | >
61 |
62 |
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 |
33 |
34 |
35 |
{state}
36 |
{
39 | // ...
40 | }}
41 | >
42 | {Math.ceil(duration - elapsed)}
43 |
44 |
45 | {
47 | // ...
48 | }}
49 | >
50 | Reset
51 |
52 |
53 |
54 |
55 | {
57 | // ...
58 | }}
59 | title="Pause timer"
60 | >
61 |
62 |
63 |
64 | {
66 | // ...
67 | }}
68 | title="Start timer"
69 | >
70 |
71 |
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 | send({ type: 'RESET' })}>Reset
42 | )}
43 |
44 |
45 |
46 | {state.value === 'running' && (
47 | send({ type: 'TOGGLE' })} title="Pause timer">
48 |
49 |
50 | )}
51 |
52 | {(state.value === 'paused' || state.value === 'idle') && (
53 | send({ type: 'TOGGLE' })} title="Start timer">
54 |
55 |
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 |
33 |
34 |
35 |
{state.value}
36 |
{
39 | // ...
40 | }}
41 | >
42 | {Math.ceil(duration - elapsed)}
43 |
44 |
45 | {state === 'paused' && (
46 | {
48 | // ...
49 | }}
50 | >
51 | Reset
52 |
53 | )}
54 |
55 |
56 |
57 | {state === 'running' && (
58 | {
60 | // ...
61 | }}
62 | title="Pause timer"
63 | >
64 |
65 |
66 | )}
67 |
68 | {(state === 'paused' || state === 'idle') && (
69 | {
71 | // ...
72 | }}
73 | title="Start timer"
74 | >
75 |
76 |
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 | send('RESET')}>Reset
37 | )}
38 |
39 | {state.value === 'running' && (
40 | send('ADD_MINUTE')}>+ 1:00
41 | )}
42 |
43 |
44 |
45 | {state.value === 'running' && (
46 | send({ type: 'TOGGLE' })} title="Pause timer">
47 |
48 |
49 | )}
50 |
51 | {(state.value === 'paused' || state.value === 'idle') && (
52 | send({ type: 'TOGGLE' })} title="Start timer">
53 |
54 |
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 |
33 |
34 |
35 |
{state.value}
36 |
send({ type: 'TOGGLE' })}>
37 | {Math.ceil(duration - elapsed)}
38 |
39 |
40 | {state.value !== 'running' && (
41 | send('RESET')}>Reset
42 | )}
43 |
44 | {
46 | // ...
47 | }}
48 | >
49 | + 1:00
50 |
51 |
52 |
53 |
54 | {state.value === 'running' && (
55 | send({ type: 'TOGGLE' })} title="Pause timer">
56 |
57 |
58 | )}
59 |
60 | {(state.value === 'paused' || state.value === 'idle') && (
61 | send({ type: 'TOGGLE' })} title="Start timer">
62 |
63 |
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 | send('RESET')}>Reset
49 | )}
50 |
51 | {state.value === 'running' && (
52 | send('ADD_MINUTE')}>+ 1:00
53 | )}
54 |
55 |
56 |
57 | {state.value === 'running' && (
58 | send({ type: 'TOGGLE' })} title="Pause timer">
59 |
60 |
61 | )}
62 |
63 | {(state.value === 'paused' || state.value === 'idle') && (
64 | send({ type: 'TOGGLE' })} title="Start timer">
65 |
66 |
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 |
33 |
34 |
35 |
{state.value}
36 |
send({ type: 'TOGGLE' })}>
37 | {Math.ceil(duration - elapsed)}
38 |
39 |
40 | {state.value !== 'running' && (
41 | send('RESET')}>Reset
42 | )}
43 |
44 | {state.value === 'running' && (
45 | send('ADD_MINUTE')}>+ 1:00
46 | )}
47 |
48 |
49 |
50 | {state.value === 'running' && (
51 | send({ type: 'TOGGLE' })} title="Pause timer">
52 |
53 |
54 | )}
55 |
56 | {(state.value === 'paused' || state.value === 'idle') && (
57 | send({ type: 'TOGGLE' })} title="Start timer">
58 |
59 |
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 | send('RESET')}>Reset
47 | )}
48 |
49 | {state.value === 'running' && (
50 | send('ADD_MINUTE')}>+ 1:00
51 | )}
52 |
53 |
54 |
55 | {state.value === 'running' && (
56 | send({ type: 'TOGGLE' })} title="Pause timer">
57 |
58 |
59 | )}
60 |
61 | {(state.value === 'paused' || state.value === 'idle') && (
62 | send({ type: 'TOGGLE' })} title="Start timer">
63 |
64 |
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 |
38 |
39 |
40 |
{state.value}
41 |
send({ type: 'TOGGLE' })}>
42 | {Math.ceil(duration - elapsed)}
43 |
44 |
45 | {state.value !== 'running' && (
46 | send('RESET')}>Reset
47 | )}
48 |
49 | {state.value === 'running' && (
50 | send('ADD_MINUTE')}>+ 1:00
51 | )}
52 |
53 |
54 |
55 | {state.value === 'running' && (
56 | send({ type: 'TOGGLE' })} title="Pause timer">
57 |
58 |
59 | )}
60 |
61 | {(state.value === 'paused' || state.value === 'idle') && (
62 | send({ type: 'TOGGLE' })} title="Start timer">
63 |
64 |
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 | send('RESET')}>Reset
46 | )}
47 |
48 | {state.value === 'running' && (
49 | send('ADD_MINUTE')}>+ 1:00
50 | )}
51 |
52 |
53 |
54 | {state.value === 'running' && (
55 | send({ type: 'TOGGLE' })} title="Pause timer">
56 |
57 |
58 | )}
59 |
60 | {(state.value === 'paused' || state.value === 'idle') && (
61 | send({ type: 'TOGGLE' })} title="Start timer">
62 |
63 |
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 |
37 |
38 |
39 |
{state.value}
40 |
send({ type: 'TOGGLE' })}>
41 | {Math.ceil(duration - elapsed)}
42 |
43 |
44 | {state.value !== 'running' && (
45 | send('RESET')}>Reset
46 | )}
47 |
48 | {state.value === 'running' && (
49 | send('ADD_MINUTE')}>+ 1:00
50 | )}
51 |
52 |
53 |
54 | {state.value === 'running' && (
55 | send({ type: 'TOGGLE' })} title="Pause timer">
56 |
57 |
58 | )}
59 |
60 | {(state.value === 'paused' || state.value === 'idle') && (
61 | send({ type: 'TOGGLE' })} title="Start timer">
62 |
63 |
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 | send('RESET')}>Reset
47 | )}
48 |
49 | {state.matches({ running: 'normal' }) && (
50 | send('ADD_MINUTE')}>+ 1:00
51 | )}
52 |
53 |
54 |
55 | {state.matches({ running: 'normal' }) && (
56 | send('TOGGLE')} title="Pause timer">
57 |
58 |
59 | )}
60 | {state.matches({ running: 'overtime' }) && (
61 | send('RESET')} title="Reset timer">
62 |
63 |
64 | )}
65 | {(state.matches('paused') || state.matches('idle')) && (
66 | send('TOGGLE')} title="Start timer">
67 |
68 |
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 |
38 |
39 |
40 |
{state.toStrings().slice(-1)}
41 |
send('TOGGLE')}>
42 | {Math.ceil(duration - elapsed)}
43 |
44 |
45 | {!state.matches({ running: 'normal' }) && (
46 | send('RESET')}>Reset
47 | )}
48 |
49 | {state.matches({ running: 'normal' }) && (
50 | send('ADD_MINUTE')}>+ 1:00
51 | )}
52 |
53 |
54 |
55 | {state.matches({ running: 'normal' }) && (
56 | send('TOGGLE')} title="Pause timer">
57 |
58 |
59 | )}
60 | {state.matches({ running: 'overtime' }) && (
61 | send('RESET')} title="Reset timer">
62 |
63 |
64 | )}
65 | {(state.matches('paused') || state.matches('idle')) && (
66 | send('TOGGLE')} title="Start timer">
67 |
68 |
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 | send('RESET')}>Reset
37 | )}
38 |
39 | {state.matches({ running: 'normal' }) && (
40 | send('ADD_MINUTE')}>+ 1:00
41 | )}
42 |
43 |
44 |
45 | {state.matches({ running: 'normal' }) && (
46 | send('TOGGLE')} title="Pause timer">
47 |
48 |
49 | )}
50 | {state.matches({ running: 'overtime' }) && (
51 | send('RESET')} title="Reset timer">
52 |
53 |
54 | )}
55 | {(state.matches('paused') || state.matches('idle')) && (
56 | send('TOGGLE')} title="Start timer">
57 |
58 |
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 |
28 |
29 |
30 |
{state.toStrings().slice(-1)}
31 |
send('TOGGLE')}>
32 | {Math.ceil(duration - elapsed)}
33 |
34 |
35 | {!state.matches({ running: 'normal' }) && (
36 | send('RESET')}>Reset
37 | )}
38 |
39 | {state.matches({ running: 'normal' }) && (
40 | send('ADD_MINUTE')}>+ 1:00
41 | )}
42 |
43 |
44 |
45 | {state.matches({ running: 'normal' }) && (
46 | send('TOGGLE')} title="Pause timer">
47 |
48 |
49 | )}
50 | {state.matches({ running: 'overtime' }) && (
51 | send('RESET')} title="Reset timer">
52 |
53 |
54 | )}
55 | {(state.matches('paused') || state.matches('idle')) && (
56 | send('TOGGLE')} title="Start timer">
57 |
58 |
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 |
43 | {state.context.timers.map((timer, i) => {
44 | return (
45 | {
49 | send('DELETE');
50 | }}
51 | onAdd={() => {
52 | send('CREATE');
53 | }}
54 | data-active={i === state.context.currentTimer || undefined}
55 | />
56 | );
57 | })}
58 |
59 |
60 | {state.context.timers.map((_, index) => {
61 | return (
62 |
{
69 | send({ type: 'SWITCH', index: index });
70 | }}
71 | >
72 | );
73 | })}
74 |
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 | {
45 | send({
46 | type: 'TIMEZONE.CHANGE',
47 | value: e.target.value,
48 | });
49 | }}
50 | >
51 |
52 | Select a timezone
53 |
54 | {state.context.timezones.map((timezone) => {
55 | return {timezone} ;
56 | })}
57 |
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 |
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 | send('RESET')}>Reset
37 | )}
38 |
39 | {state.matches({ running: 'normal' }) && (
40 | send('ADD_MINUTE')}>+ 1:00
41 | )}
42 |
43 |
44 |
45 | {
49 | onDelete();
50 | }}
51 | >
52 | Delete
53 |
54 | {state.matches({ running: 'normal' }) && (
55 | send('TOGGLE')} title="Pause timer">
56 |
57 |
58 | )}
59 | {state.matches({ running: 'overtime' }) && (
60 | send('RESET')} title="Reset timer">
61 |
62 |
63 | )}
64 | {(state.matches('paused') || state.matches('idle')) && (
65 | send('TOGGLE')} title="Start timer">
66 |
67 |
68 | )}
69 | {
73 | onAdd();
74 | }}
75 | >
76 | Add Timer
77 |
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 |
48 | {state.context.timers.map((timer, i) => {
49 | return (
50 | {
54 | send({ type: 'DELETE', index: i });
55 | }}
56 | onAdd={() => {
57 | send('CREATE');
58 | }}
59 | data-active={i === state.context.currentTimer || undefined}
60 | />
61 | );
62 | })}
63 |
64 |
65 | {state.context.timers.map((_, index) => {
66 | return (
67 |
{
74 | send({ type: 'SWITCH', index: index });
75 | }}
76 | >
77 | );
78 | })}
79 |
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 | {
51 | send({
52 | type: 'TIMEZONE.CHANGE',
53 | value: e.target.value,
54 | });
55 | }}
56 | >
57 |
58 | Select a timezone
59 |
60 | {state.context.timezones.map((timezone) => {
61 | return {timezone} ;
62 | })}
63 |
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 |
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 | send('RESET')}>Reset
36 | )}
37 |
38 | {state.matches({ running: 'normal' }) && (
39 | send('ADD_MINUTE')}>+ 1:00
40 | )}
41 |
42 |
43 |
44 | {
48 | onDelete();
49 | }}
50 | >
51 | Delete
52 |
53 | {state.matches({ running: 'normal' }) && (
54 | send('TOGGLE')} title="Pause timer">
55 |
56 |
57 | )}
58 | {state.matches({ running: 'overtime' }) && (
59 | send('RESET')} title="Reset timer">
60 |
61 |
62 | )}
63 | {(state.matches('paused') || state.matches('idle')) && (
64 | send('TOGGLE')} title="Start timer">
65 |
66 |
67 | )}
68 | {
72 | onAdd();
73 | }}
74 | >
75 | Add Timer
76 |
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 |
--------------------------------------------------------------------------------