├── first-example ├── .gitignore ├── README.md ├── package.json ├── pnpm-lock.yaml ├── src │ └── index.ts └── tsconfig.json ├── actor-emit-events ├── src │ ├── vite-env.d.ts │ ├── main.tsx │ ├── App.tsx │ └── machine.ts ├── vite.config.ts ├── tsconfig.node.json ├── .gitignore ├── index.html ├── tsconfig.json ├── .eslintrc.cjs ├── package.json ├── README.md └── pnpm-lock.yaml ├── audio-player-react ├── src │ ├── vite-env.d.ts │ ├── main.tsx │ ├── types.ts │ ├── machine-types.ts │ ├── App.tsx │ ├── effect.ts │ └── machine.ts ├── vite.config.ts ├── tsconfig.node.json ├── .gitignore ├── index.html ├── tsconfig.json ├── .eslintrc.cjs ├── package.json ├── README.md └── pnpm-lock.yaml └── README.md /first-example/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /actor-emit-events/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /audio-player-react/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /actor-emit-events/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /audio-player-react/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /actor-emit-events/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App.tsx"; 4 | 5 | ReactDOM.createRoot(document.getElementById("root")!).render( 6 | 7 | 8 | 9 | ); 10 | -------------------------------------------------------------------------------- /audio-player-react/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App.tsx"; 4 | 5 | ReactDOM.createRoot(document.getElementById("root")!).render( 6 | 7 | 8 | 9 | ); 10 | -------------------------------------------------------------------------------- /actor-emit-events/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /audio-player-react/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /audio-player-react/src/types.ts: -------------------------------------------------------------------------------- 1 | export type MachineParams>> = 2 | keyof A extends infer Type 3 | ? Type extends keyof A 4 | ? keyof A[Type] extends "" 5 | ? { readonly type: Type } 6 | : { readonly type: Type; readonly params: A[Type] } 7 | : never 8 | : never; 9 | -------------------------------------------------------------------------------- /first-example/README.md: -------------------------------------------------------------------------------- 1 | # `First example` 2 | Initial exploration into all the features of XState. 3 | 4 | ## Resources 5 | - [Editor](https://stately.ai/registry/projects) 6 | - [Stately docs](https://stately.ai/docs/quick-start) (XState docs) 7 | - [Actor model](https://stately.ai/blog/what-is-the-actor-model) 8 | - [API docs](https://tsdocs.dev/docs/xstate/5.3.0) -------------------------------------------------------------------------------- /actor-emit-events/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /audio-player-react/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /actor-emit-events/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | XState + Effect・Audio Player 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /audio-player-react/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | XState + Effect・Audio Player 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /first-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "first-example", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "typescript": "^5.3.3" 14 | }, 15 | "dependencies": { 16 | "@effect/schema": "^0.53.3", 17 | "effect": "2.0.0-next.60", 18 | "xstate": "^5.3.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /audio-player-react/src/machine-types.ts: -------------------------------------------------------------------------------- 1 | import { MachineParams } from "./types"; 2 | 3 | export interface Context { 4 | readonly currentTime: number; 5 | readonly audioRef: HTMLAudioElement | null; 6 | readonly audioContext: AudioContext | null; 7 | readonly trackSource: MediaElementAudioSourceNode | null; 8 | } 9 | 10 | export type Events = MachineParams<{ 11 | play: {}; 12 | restart: {}; 13 | end: {}; 14 | pause: {}; 15 | loaded: {}; 16 | loading: { readonly audioRef: HTMLAudioElement }; 17 | error: { readonly message: unknown }; 18 | "init-error": { readonly message: unknown }; 19 | time: { readonly updatedTime: number }; 20 | }>; 21 | -------------------------------------------------------------------------------- /actor-emit-events/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "strictNullChecks": true 23 | // "exactOptionalPropertyTypes": true 24 | }, 25 | "include": ["src"], 26 | "references": [{ "path": "./tsconfig.node.json" }] 27 | } 28 | -------------------------------------------------------------------------------- /audio-player-react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "strictNullChecks": true 23 | // "exactOptionalPropertyTypes": true 24 | }, 25 | "include": ["src"], 26 | "references": [{ "path": "./tsconfig.node.json" }] 27 | } 28 | -------------------------------------------------------------------------------- /actor-emit-events/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "plugin:react-hooks/recommended", 8 | ], 9 | ignorePatterns: ["dist", ".eslintrc.cjs"], 10 | parser: "@typescript-eslint/parser", 11 | plugins: ["react-refresh"], 12 | rules: { 13 | "require-yield": "off", 14 | "@typescript-eslint/no-unused-vars": "warn", 15 | "@typescript-eslint/no-explicit-any": "warn", 16 | "react-refresh/only-export-components": [ 17 | "warn", 18 | { allowConstantExport: true }, 19 | ], 20 | "@typescript-eslint/ban-types": [ 21 | "error", 22 | { 23 | types: { 24 | "{}": false, 25 | }, 26 | extendDefaults: true, 27 | }, 28 | ], 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /audio-player-react/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "plugin:react-hooks/recommended", 8 | ], 9 | ignorePatterns: ["dist", ".eslintrc.cjs"], 10 | parser: "@typescript-eslint/parser", 11 | plugins: ["react-refresh"], 12 | rules: { 13 | "require-yield": "off", 14 | "@typescript-eslint/no-unused-vars": "warn", 15 | "@typescript-eslint/no-explicit-any": "warn", 16 | "react-refresh/only-export-components": [ 17 | "warn", 18 | { allowConstantExport: true }, 19 | ], 20 | "@typescript-eslint/ban-types": [ 21 | "error", 22 | { 23 | types: { 24 | "{}": false, 25 | }, 26 | extendDefaults: true, 27 | }, 28 | ], 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /audio-player-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "audio-player-react", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "preinstall": "npx only-allow pnpm", 8 | "dev": "vite", 9 | "build": "tsc && vite build", 10 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "@xstate/react": "^4.0.1", 15 | "effect": "2.0.0-next.60", 16 | "react": "^18.2.0", 17 | "react-dom": "^18.2.0", 18 | "xstate": "^5.3.0" 19 | }, 20 | "devDependencies": { 21 | "@types/react": "^18.2.43", 22 | "@types/react-dom": "^18.2.17", 23 | "@typescript-eslint/eslint-plugin": "^6.14.0", 24 | "@typescript-eslint/parser": "^6.14.0", 25 | "@vitejs/plugin-react": "^4.2.1", 26 | "eslint": "^8.55.0", 27 | "eslint-plugin-react-hooks": "^4.6.0", 28 | "eslint-plugin-react-refresh": "^0.4.5", 29 | "typescript": "^5.2.2", 30 | "vite": "^5.0.8" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /actor-emit-events/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "audio-player-react", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "preinstall": "npx only-allow pnpm", 8 | "dev": "vite", 9 | "build": "tsc && vite build", 10 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "@effect/schema": "^0.66.14", 15 | "@xstate/react": "^4.1.1", 16 | "effect": "3.1.2", 17 | "react": "^18.2.0", 18 | "react-dom": "^18.2.0", 19 | "xstate": "^5.13.0" 20 | }, 21 | "devDependencies": { 22 | "@types/react": "^18.2.43", 23 | "@types/react-dom": "^18.2.17", 24 | "@typescript-eslint/eslint-plugin": "^6.14.0", 25 | "@typescript-eslint/parser": "^6.14.0", 26 | "@vitejs/plugin-react": "^4.2.1", 27 | "eslint": "^8.55.0", 28 | "eslint-plugin-react-hooks": "^4.6.0", 29 | "eslint-plugin-react-refresh": "^0.4.5", 30 | "typescript": "^5.4.5", 31 | "vite": "^5.0.8" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /actor-emit-events/README.md: -------------------------------------------------------------------------------- 1 | # `XState + Effect・Audio Player` 2 | 3 | Core dependencies: 4 | 5 | ```bash 6 | pnpm install xstate @xstate/react effect @effect/schema 7 | ``` 8 | 9 | `tsconfig.json` additions: 10 | 11 | ```json 12 | "strictNullChecks": true, 13 | "exactOptionalPropertyTypes": true 14 | ``` 15 | 16 | ## Notes 17 | - Keep copy-pasting all machine from editor only to change a single state 18 | - Great for copying states, while others params can stay the same (`types`, `actions`) 19 | - Maybe allow to copy only a specific parameter (e.g. **only states**) 20 | - **Note**: Editor for initial logic, then switching all on code for implementation 21 | - `snapshot.matches` is untyped! (not always it seems, and the match is not always a `string` but it can also be an object for nested states) 22 | - Actually this may be again an issue with `exactOptionalPropertyTypes` 23 | - An `assign` action (`entry`) requires a sync operation 24 | - Possibly fix the issue with `missing audioRef` by working with sub-machines and having a valid ref or error (probably not) 25 | - Issues with `exactOptionalPropertyTypes` in `tsconfig.json` causes `matches` type to become `never` (possibly work on a reproduction) 26 | 27 | ```bash 28 | Types of property '_out_TActor' are incompatible. 29 | 30 | Type 'ProvidedActor' is not assignable to type '{ src: string; logic: UnknownActorLogic; id: string | undefined; }'.ts(2322) 31 | ``` 32 | 33 | - Issues with using `assign` in `setup` `actions` (expected?) 34 | - How to keep machine implementation and editor in sync (should you)? -------------------------------------------------------------------------------- /audio-player-react/README.md: -------------------------------------------------------------------------------- 1 | # `XState + Effect・Audio Player` 2 | 3 | Core dependencies: 4 | 5 | ```bash 6 | pnpm install xstate @xstate/react effect @effect/schema 7 | ``` 8 | 9 | `tsconfig.json` additions: 10 | 11 | ```json 12 | "strictNullChecks": true, 13 | "exactOptionalPropertyTypes": true 14 | ``` 15 | 16 | ## Notes 17 | - Keep copy-pasting all machine from editor only to change a single state 18 | - Great for copying states, while others params can stay the same (`types`, `actions`) 19 | - Maybe allow to copy only a specific parameter (e.g. **only states**) 20 | - **Note**: Editor for initial logic, then switching all on code for implementation 21 | - `snapshot.matches` is untyped! (not always it seems, and the match is not always a `string` but it can also be an object for nested states) 22 | - Actually this may be again an issue with `exactOptionalPropertyTypes` 23 | - An `assign` action (`entry`) requires a sync operation 24 | - Possibly fix the issue with `missing audioRef` by working with sub-machines and having a valid ref or error (probably not) 25 | - Issues with `exactOptionalPropertyTypes` in `tsconfig.json` causes `matches` type to become `never` (possibly work on a reproduction) 26 | 27 | ```bash 28 | Types of property '_out_TActor' are incompatible. 29 | 30 | Type 'ProvidedActor' is not assignable to type '{ src: string; logic: UnknownActorLogic; id: string | undefined; }'.ts(2322) 31 | ``` 32 | 33 | - Issues with using `assign` in `setup` `actions` (expected?) 34 | - How to keep machine implementation and editor in sync (should you)? -------------------------------------------------------------------------------- /audio-player-react/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useMachine } from "@xstate/react"; 2 | import { machine } from "./machine"; 3 | 4 | export default function App() { 5 | const [snapshot, send] = useMachine(machine); 6 | return ( 7 |
8 |
{JSON.stringify(snapshot.value, null, 2)}
9 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /first-example/pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '6.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | dependencies: 8 | '@effect/schema': 9 | specifier: ^0.53.3 10 | version: 0.53.3(effect@2.0.0-next.60)(fast-check@3.14.0) 11 | effect: 12 | specifier: 2.0.0-next.60 13 | version: 2.0.0-next.60 14 | xstate: 15 | specifier: ^5.3.0 16 | version: 5.3.0 17 | 18 | devDependencies: 19 | typescript: 20 | specifier: ^5.3.3 21 | version: 5.3.3 22 | 23 | packages: 24 | 25 | /@effect/schema@0.53.3(effect@2.0.0-next.60)(fast-check@3.14.0): 26 | resolution: {integrity: sha512-QycmIqn2E7v0IPzHqSXmdnX7fhgr102z90BAjBUsifJy5MOfC9EdxTJ5DY8Vxiq9gjbLz7QWBQuCyqRDJ6eNAQ==} 27 | peerDependencies: 28 | effect: 2.0.0-next.60 29 | fast-check: ^3.13.2 30 | dependencies: 31 | effect: 2.0.0-next.60 32 | fast-check: 3.14.0 33 | dev: false 34 | 35 | /effect@2.0.0-next.60: 36 | resolution: {integrity: sha512-23KhlVACgrg5UPFu9i4szybSU4cCU4T/7CX4pe0jV84QBZX0zm96WzwCtg6dqOnmUzBL7hm6S+iiPW2Rab13Uw==} 37 | dev: false 38 | 39 | /fast-check@3.14.0: 40 | resolution: {integrity: sha512-9Z0zqASzDNjXBox/ileV/fd+4P+V/f3o4shM6QawvcdLFh8yjPG4h5BrHUZ8yzY6amKGDTAmRMyb/JZqe+dCgw==} 41 | engines: {node: '>=8.0.0'} 42 | dependencies: 43 | pure-rand: 6.0.4 44 | dev: false 45 | 46 | /pure-rand@6.0.4: 47 | resolution: {integrity: sha512-LA0Y9kxMYv47GIPJy6MI84fqTd2HmYZI83W/kM/SkKfDlajnZYfmXFTxkbY+xSBPkLJxltMa9hIkmdc29eguMA==} 48 | dev: false 49 | 50 | /typescript@5.3.3: 51 | resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==} 52 | engines: {node: '>=14.17'} 53 | hasBin: true 54 | dev: true 55 | 56 | /xstate@5.3.0: 57 | resolution: {integrity: sha512-7Fx5TgxAFA1vhjtYp46U3+6XbCIZNobBJpT3MdeRb4msOioyKP1YJte9R8GMXh7ilFrGuHx72E836/7KYVkK1w==} 58 | dev: false 59 | -------------------------------------------------------------------------------- /actor-emit-events/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useMachine, useSelector } from "@xstate/react"; 2 | import { useEffect } from "react"; 3 | import type { ActorRefFrom } from "xstate"; 4 | import { 5 | rootMachine, 6 | type notifierMachine, 7 | type uploadMachine, 8 | } from "./machine"; 9 | 10 | export default function App() { 11 | const [snapshot] = useMachine(rootMachine); 12 | const notifierActor = snapshot.children["notifier"]; 13 | 14 | /// 🔥 Root machine can react to events triggered by child machine 15 | useEffect(() => { 16 | /// 👇 Access emitted event from `child` 17 | const { unsubscribe } = snapshot.context.child.on( 18 | "uploaded", 19 | ({ value }) => { 20 | console.log("Uploaded value", { value }); 21 | } 22 | ); 23 | 24 | return unsubscribe; 25 | }, []); 26 | 27 | return ( 28 | <> 29 | {notifierActor && } 30 | 31 | 32 | ); 33 | } 34 | 35 | const NotifierMachine = ({ 36 | actor, 37 | }: { 38 | actor: ActorRefFrom; 39 | }) => { 40 | const context = useSelector(actor, (snapshot) => snapshot.context); 41 | return ( 42 |
43 |

Notifier machine context

44 |
{JSON.stringify(context, null, 2)}
45 |
46 | ); 47 | }; 48 | 49 | /// 👇 Isolated logic for "child" machine, no reference to `root` 50 | const Child = ({ child }: { child: ActorRefFrom }) => { 51 | /// 🪄 `useSelector` to extract context from `child` actor 52 | const value = useSelector(child, (snapshot) => snapshot.context.value); 53 | return ( 54 | <> 55 |

{`Child value: ${value}`}

56 | 66 | 67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `XState & Effect` 2 |

3 | 4 | GitHub: SandroMaglione 5 | 6 | 7 | Twitter: SandroMaglione 8 | 9 |

10 | 11 | *** 12 | 13 | This project is part of my weekly newsletter at [**sandromaglione.com**](https://www.sandromaglione.com/newsletter?ref=Github&utm_medium=newsletter_project&utm_term=xstate&utm_term=effect). 14 | 15 | 16 | 17 | sandromaglione.com Newsletter weekly project 18 | 19 | 20 | ## Project structure 21 | The project contains 2 folders: 22 | - [`first-example`](./first-example/): My initial exploration with all the features of XState and state machines. I was specifically interested in working with types to try making the machine as type-safe as possible 23 | - [`audio-player-react`](./audio-player-react/): This is the implementation of the weekly project. It contains a Vite app that uses **XState and Effect to implement and audio player** (`