├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .vscode └── extensions.json ├── CHANGELOG.md ├── README.md ├── apps ├── angular-xstate-demo-e2e │ ├── .eslintrc.json │ ├── playwright.config.ts │ ├── project.json │ ├── src │ │ └── example.spec.ts │ └── tsconfig.json └── angular-xstate-demo │ ├── .eslintrc.json │ ├── jest.config.ts │ ├── project.json │ ├── public │ └── favicon.ico │ ├── src │ ├── app │ │ ├── app.component.css │ │ ├── app.component.html │ │ ├── app.component.ts │ │ ├── app.config.ts │ │ ├── app.routes.ts │ │ ├── counter │ │ │ ├── counter.component.spec.ts │ │ │ └── counter.component.ts │ │ ├── routing │ │ │ └── routing.component.ts │ │ └── tic-tac-toe │ │ │ ├── tic-tac-toe.component.ts │ │ │ ├── ticTacToeMachine.ts │ │ │ └── tile.component.ts │ ├── index.html │ ├── main.ts │ ├── styles.css │ └── test-setup.ts │ ├── tsconfig.app.json │ ├── tsconfig.editor.json │ ├── tsconfig.json │ └── tsconfig.spec.json ├── jest.config.ts ├── jest.preset.js ├── lib └── xstate-ngx │ ├── .eslintrc.json │ ├── README.md │ ├── jest.config.ts │ ├── ng-package.json │ ├── package.json │ ├── project.json │ ├── src │ ├── index.ts │ ├── lib │ │ ├── useActor.test.ts │ │ ├── useActor.ts │ │ ├── useActorRef.ts │ │ ├── useMachine.ts │ │ └── useSelector.ts │ └── test-setup.ts │ ├── tsconfig.json │ ├── tsconfig.lib.json │ ├── tsconfig.lib.prod.json │ └── tsconfig.spec.json ├── nx.json ├── package.json ├── pnpm-lock.yaml └── tsconfig.base.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["**/*"], 4 | "plugins": ["@nx"], 5 | "overrides": [ 6 | { 7 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 8 | "rules": { 9 | "@nx/enforce-module-boundaries": [ 10 | "error", 11 | { 12 | "enforceBuildableLibDependency": true, 13 | "allow": [], 14 | "depConstraints": [ 15 | { 16 | "sourceTag": "*", 17 | "onlyDependOnLibsWithTags": ["*"] 18 | } 19 | ] 20 | } 21 | ] 22 | } 23 | }, 24 | { 25 | "files": ["*.ts", "*.tsx"], 26 | "extends": ["plugin:@nx/typescript"], 27 | "rules": {} 28 | }, 29 | { 30 | "files": ["*.js", "*.jsx"], 31 | "extends": ["plugin:@nx/javascript"], 32 | "rules": {} 33 | }, 34 | { 35 | "files": ["*.spec.ts", "*.spec.tsx", "*.spec.js", "*.spec.jsx"], 36 | "env": { 37 | "jest": true 38 | }, 39 | "rules": {} 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | permissions: 10 | actions: read 11 | contents: read 12 | 13 | jobs: 14 | main: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | 21 | - uses: pnpm/action-setup@v4 22 | with: 23 | version: 8 24 | 25 | # Connect your workspace on nx.app and uncomment this to enable task distribution. 26 | # The "--stop-agents-after" is optional, but allows idle agents to shut down once the "e2e-ci" targets have been requested 27 | # - run: pnpm dlx nx-cloud start-ci-run --distribute-on="5 linux-medium-js" --stop-agents-after="e2e-ci" 28 | 29 | # Cache node_modules 30 | - uses: actions/setup-node@v3 31 | with: 32 | node-version: 20 33 | cache: 'pnpm' 34 | 35 | - run: pnpm install --frozen-lockfile 36 | - uses: nrwl/nx-set-shas@v4 37 | 38 | # Prepend any command with "nx-cloud record --" to record its logs to Nx Cloud 39 | # - run: pnpm exec nx-cloud record -- echo Hello World 40 | - run: pnpm exec nx affected -t lint test build --skip-nx-cache 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | dist 5 | tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.sass-cache 29 | /connect.lock 30 | /coverage 31 | /libpeerconnection.log 32 | npm-debug.log 33 | yarn-error.log 34 | testem.log 35 | /typings 36 | 37 | # System Files 38 | .DS_Store 39 | Thumbs.db 40 | 41 | .nx/cache 42 | .nx/workspace-data 43 | 44 | .angular 45 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | strict-peer-dependencies=false 2 | auto-install-peers=true 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Add files here to ignore them from prettier formatting 2 | /dist 3 | /coverage 4 | /.nx/cache 5 | /.nx/workspace-data 6 | .angular 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "nrwl.angular-console", 4 | "esbenp.prettier-vscode", 5 | "firsttris.vscode-jest-runner", 6 | "ms-playwright.playwright" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.0.0-alpha.9 (2024-07-05) 2 | 3 | This was a version bump only, there were no code changes. 4 | 5 | ## 1.0.0-alpha.8 (2024-07-05) 6 | 7 | This was a version bump only, there were no code changes. 8 | 9 | ## 1.0.0-alpha.7 (2024-07-05) 10 | 11 | This was a version bump only, there were no code changes. 12 | 13 | ## 1.0.0-alpha.6 (2024-07-05) 14 | 15 | This was a version bump only, there were no code changes. 16 | 17 | ## 1.0.0-alpha.5 (2024-07-05) 18 | 19 | This was a version bump only, there were no code changes. 20 | 21 | ## 1.0.0-alpha.4 (2024-07-05) 22 | 23 | This was a version bump only, there were no code changes. 24 | 25 | ## 1.0.0-alpha.3 (2024-07-05) 26 | 27 | This was a version bump only, there were no code changes. 28 | 29 | ## 1.0.0-alpha.2 (2024-07-05) 30 | 31 | This was a version bump only, there were no code changes. 32 | 33 | ## 1.0.0-alpha.1 (2024-07-05) 34 | 35 | This was a version bump only, there were no code changes. 36 | 37 | ## 1.0.0-alpha.0 (2024-07-05) 38 | 39 | 40 | ### 🚀 Features 41 | 42 | - **nx:** Added Nx Cloud token to your nx.json ([51527d6](https://github.com/niklas-wortmann/xstate-angular/commit/51527d6)) 43 | - **nx:** Generated CI workflow ([950e756](https://github.com/niklas-wortmann/xstate-angular/commit/950e756)) 44 | 45 | ### ❤️ Thank You 46 | 47 | - Jan-Niklas Wortmann -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # xstate-ngx 2 | 3 | This is just a POC while the [PR in the Xstate Monorepository](https://github.com/statelyai/xstate/pull/4816) is being discussed. Eventually this will be moved and deprecated! 4 | 5 | This package contains utilities for using [XState](https://github.com/statelyai/xstate) with [Angular](https://github.com/angular/angular). 6 | 7 | - [Read the full documentation in the XState docs](https://stately.ai/docs/xstate-angular). 8 | - [Read our contribution guidelines](https://github.com/statelyai/xstate/blob/main/CONTRIBUTING.md). 9 | 10 | ## Quick start 11 | 12 | 1. Install `xstate` and `xstate-ngx`: 13 | 14 | ```bash 15 | npm i xstate xstate-ngx 16 | ``` 17 | 18 | 2. Import the `useMachine` function: 19 | 20 | ```angular-ts 21 | import { useMachine } from 'xstate-ngx'; 22 | import { createMachine } from 'xstate'; 23 | import {Component, inject} from '@angular/core'; 24 | const toggleMachine = createMachine({ 25 | id: 'toggle', 26 | initial: 'inactive', 27 | states: { 28 | inactive: { 29 | on: { TOGGLE: 'active' } 30 | }, 31 | active: { 32 | on: { TOGGLE: 'inactive' } 33 | } 34 | } 35 | }); 36 | const ToggleMachine = useMachine(toggleMachine, {providedIn: 'root'}) 37 | @Component({ 38 | selector: 'app-toggle', 39 | standalone: true, 40 | imports: [], 41 | template: ` 42 | {{ 43 | toggleMachine.snapshot().value === 'inactive' 44 | ? 'Click to activate' 45 | : 'Active! Click to deactivate' 46 | }} 47 | 48 | `, 49 | styleUrl: './toggle.component.css' 50 | }) 51 | export class ToggleComponent { 52 | public toggleMachine = inject(ToggleMachine); 53 | } 54 | ``` 55 | 56 | ## API 57 | 58 | ### `useActor(actorLogic, options?)` 59 | 60 | **Returns** `{ snapshot, send, actorRef }`: 61 | 62 | - `snapshot` - Represents the current snapshot (state) of the machine as an XState `State` object. Returns a Signal. 63 | - `send` - A function that sends events to the running actor. 64 | - `actorRef` - The created actor ref. 65 | - `matches` - indicating whether the provided path matches the machine snapshot. 66 | - `hasTag` - machine or machine snapshot has the specified tag. 67 | - `can` - path can be transitioned to in the state machine 68 | 69 | ### `useMachine(machine, options?)` 70 | 71 | A function that returns an Injectable that creates an actor from the given `machine` and starts an actor that runs for the lifetime of the component or DI context. 72 | 73 | #### Arguments 74 | 75 | - `machine` - An [XState machine](https://stately.ai/docs/machines) 76 | - `options` (optional) - Actor options 77 | 78 | **Returns** `{ snapshot, send, actorRef }`: 79 | 80 | - `snapshot` - Represents the current snapshot (state) of the machine as an XState `State` object. Returns a Signal. 81 | - `send` - A function that sends events to the running actor. 82 | - `actorRef` - The created actor ref. 83 | - `matches` - indicating whether the provided path matches the machine snapshot. 84 | - `hasTag` - machine or machine snapshot has the specified tag. 85 | - `can` - path can be transitioned to in the state machine 86 | -------------------------------------------------------------------------------- /apps/angular-xstate-demo-e2e/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["plugin:playwright/recommended", "../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | }, 17 | { 18 | "files": ["src/**/*.{ts,js,tsx,jsx}"], 19 | "rules": {} 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /apps/angular-xstate-demo-e2e/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | import { nxE2EPreset } from '@nx/playwright/preset'; 3 | 4 | import { workspaceRoot } from '@nx/devkit'; 5 | 6 | // For CI, you may want to set BASE_URL to the deployed application. 7 | const baseURL = process.env['BASE_URL'] || 'http://localhost:4200'; 8 | 9 | /** 10 | * Read environment variables from file. 11 | * https://github.com/motdotla/dotenv 12 | */ 13 | // require('dotenv').config(); 14 | 15 | /** 16 | * See https://playwright.dev/docs/test-configuration. 17 | */ 18 | export default defineConfig({ 19 | ...nxE2EPreset(__filename, { testDir: './src' }), 20 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 21 | use: { 22 | baseURL, 23 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 24 | trace: 'on-first-retry', 25 | }, 26 | /* Run your local dev server before starting the tests */ 27 | webServer: { 28 | command: 'pnpm exec nx serve angular-xstate-demo', 29 | url: 'http://localhost:4200', 30 | reuseExistingServer: !process.env.CI, 31 | cwd: workspaceRoot, 32 | }, 33 | projects: [ 34 | { 35 | name: 'chromium', 36 | use: { ...devices['Desktop Chrome'] }, 37 | }, 38 | 39 | { 40 | name: 'firefox', 41 | use: { ...devices['Desktop Firefox'] }, 42 | }, 43 | 44 | { 45 | name: 'webkit', 46 | use: { ...devices['Desktop Safari'] }, 47 | }, 48 | 49 | // Uncomment for mobile browsers support 50 | /* { 51 | name: 'Mobile Chrome', 52 | use: { ...devices['Pixel 5'] }, 53 | }, 54 | { 55 | name: 'Mobile Safari', 56 | use: { ...devices['iPhone 12'] }, 57 | }, */ 58 | 59 | // Uncomment for branded browsers 60 | /* { 61 | name: 'Microsoft Edge', 62 | use: { ...devices['Desktop Edge'], channel: 'msedge' }, 63 | }, 64 | { 65 | name: 'Google Chrome', 66 | use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 67 | } */ 68 | ], 69 | }); 70 | -------------------------------------------------------------------------------- /apps/angular-xstate-demo-e2e/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-xstate-demo-e2e", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "projectType": "application", 5 | "sourceRoot": "apps/angular-xstate-demo-e2e/src", 6 | "implicitDependencies": ["angular-xstate-demo"], 7 | "// targets": "to see all targets run: nx show project angular-xstate-demo-e2e --web", 8 | "targets": {} 9 | } 10 | -------------------------------------------------------------------------------- /apps/angular-xstate-demo-e2e/src/example.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test('has title', async ({ page }) => { 4 | await page.goto('/'); 5 | 6 | // Expect h1 to contain a substring. 7 | expect(await page.locator('h1').innerText()).toContain('Welcome'); 8 | }); 9 | -------------------------------------------------------------------------------- /apps/angular-xstate-demo-e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "outDir": "../../dist/out-tsc", 6 | "module": "commonjs", 7 | "sourceMap": false, 8 | "forceConsistentCasingInFileNames": true, 9 | "strict": true, 10 | "noImplicitOverride": true, 11 | "noPropertyAccessFromIndexSignature": true, 12 | "noImplicitReturns": true, 13 | "noFallthroughCasesInSwitch": true 14 | }, 15 | "include": [ 16 | "**/*.ts", 17 | "**/*.js", 18 | "playwright.config.ts", 19 | "src/**/*.spec.ts", 20 | "src/**/*.spec.js", 21 | "src/**/*.test.ts", 22 | "src/**/*.test.js", 23 | "src/**/*.d.ts" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /apps/angular-xstate-demo/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts"], 7 | "extends": [ 8 | "plugin:@nx/angular", 9 | "plugin:@angular-eslint/template/process-inline-templates" 10 | ], 11 | "rules": { 12 | "@angular-eslint/directive-selector": [ 13 | "error", 14 | { 15 | "type": "attribute", 16 | "prefix": "app", 17 | "style": "camelCase" 18 | } 19 | ], 20 | "@angular-eslint/component-selector": [ 21 | "error", 22 | { 23 | "type": "element", 24 | "prefix": "app", 25 | "style": "kebab-case" 26 | } 27 | ] 28 | } 29 | }, 30 | { 31 | "files": ["*.html"], 32 | "extends": ["plugin:@nx/angular-template"], 33 | "rules": {} 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /apps/angular-xstate-demo/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: 'angular-xstate-demo', 4 | preset: '../../jest.preset.js', 5 | setupFilesAfterEnv: ['/src/test-setup.ts'], 6 | coverageDirectory: '../../coverage/apps/angular-xstate-demo', 7 | transform: { 8 | '^.+\\.(ts|mjs|js|html)$': [ 9 | 'jest-preset-angular', 10 | { 11 | tsconfig: '/tsconfig.spec.json', 12 | stringifyContentPathRegex: '\\.(html|svg)$', 13 | }, 14 | ], 15 | }, 16 | transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], 17 | snapshotSerializers: [ 18 | 'jest-preset-angular/build/serializers/no-ng-attributes', 19 | 'jest-preset-angular/build/serializers/ng-snapshot', 20 | 'jest-preset-angular/build/serializers/html-comment', 21 | ], 22 | }; 23 | -------------------------------------------------------------------------------- /apps/angular-xstate-demo/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-xstate-demo", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "projectType": "application", 5 | "prefix": "app", 6 | "sourceRoot": "apps/angular-xstate-demo/src", 7 | "tags": [], 8 | "targets": { 9 | "build": { 10 | "executor": "@angular-devkit/build-angular:application", 11 | "outputs": ["{options.outputPath}"], 12 | "options": { 13 | "outputPath": "dist/apps/angular-xstate-demo", 14 | "index": "apps/angular-xstate-demo/src/index.html", 15 | "browser": "apps/angular-xstate-demo/src/main.ts", 16 | "polyfills": ["zone.js"], 17 | "tsConfig": "apps/angular-xstate-demo/tsconfig.app.json", 18 | "assets": [ 19 | { 20 | "glob": "**/*", 21 | "input": "apps/angular-xstate-demo/public" 22 | } 23 | ], 24 | "styles": ["apps/angular-xstate-demo/src/styles.css"], 25 | "scripts": [] 26 | }, 27 | "configurations": { 28 | "production": { 29 | "budgets": [ 30 | { 31 | "type": "initial", 32 | "maximumWarning": "500kb", 33 | "maximumError": "1mb" 34 | }, 35 | { 36 | "type": "anyComponentStyle", 37 | "maximumWarning": "2kb", 38 | "maximumError": "4kb" 39 | } 40 | ], 41 | "outputHashing": "all" 42 | }, 43 | "development": { 44 | "optimization": false, 45 | "extractLicenses": false, 46 | "sourceMap": true 47 | } 48 | }, 49 | "defaultConfiguration": "production" 50 | }, 51 | "serve": { 52 | "executor": "@angular-devkit/build-angular:dev-server", 53 | "configurations": { 54 | "production": { 55 | "buildTarget": "angular-xstate-demo:build:production" 56 | }, 57 | "development": { 58 | "buildTarget": "angular-xstate-demo:build:development" 59 | } 60 | }, 61 | "defaultConfiguration": "development" 62 | }, 63 | "extract-i18n": { 64 | "executor": "@angular-devkit/build-angular:extract-i18n", 65 | "options": { 66 | "buildTarget": "angular-xstate-demo:build" 67 | } 68 | }, 69 | "lint": { 70 | "executor": "@nx/eslint:lint" 71 | }, 72 | "test": { 73 | "executor": "@nx/jest:jest", 74 | "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], 75 | "options": { 76 | "jestConfig": "apps/angular-xstate-demo/jest.config.ts" 77 | } 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /apps/angular-xstate-demo/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/niklas-wortmann/xstate-angular/b5f3a33dc6f5f2f189598c7ffa611c1c91e4f627/apps/angular-xstate-demo/public/favicon.ico -------------------------------------------------------------------------------- /apps/angular-xstate-demo/src/app/app.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/niklas-wortmann/xstate-angular/b5f3a33dc6f5f2f189598c7ffa611c1c91e4f627/apps/angular-xstate-demo/src/app/app.component.css -------------------------------------------------------------------------------- /apps/angular-xstate-demo/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /apps/angular-xstate-demo/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { RouterModule } from '@angular/router'; 3 | 4 | @Component({ 5 | standalone: true, 6 | imports: [RouterModule], 7 | selector: 'app-root', 8 | templateUrl: './app.component.html', 9 | styleUrl: './app.component.css', 10 | }) 11 | export class AppComponent { 12 | title = 'angular-xstate-demo'; 13 | } 14 | -------------------------------------------------------------------------------- /apps/angular-xstate-demo/src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; 2 | import { provideRouter } from '@angular/router'; 3 | import { appRoutes } from './app.routes'; 4 | 5 | export const appConfig: ApplicationConfig = { 6 | providers: [ 7 | provideZoneChangeDetection({ eventCoalescing: true }), 8 | provideRouter(appRoutes), 9 | ], 10 | }; 11 | -------------------------------------------------------------------------------- /apps/angular-xstate-demo/src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import { Route } from '@angular/router'; 2 | import { CounterComponent } from './counter/counter.component'; 3 | import { TicTacToeComponent } from './tic-tac-toe/tic-tac-toe.component'; 4 | import { RoutingComponent } from './routing/routing.component'; 5 | 6 | export const appRoutes: Route[] = [ 7 | { path: 'counter', component: CounterComponent }, 8 | { path: 'tic-tac-toe', component: TicTacToeComponent }, 9 | { path: 'routing', component: RoutingComponent }, 10 | ]; 11 | -------------------------------------------------------------------------------- /apps/angular-xstate-demo/src/app/counter/counter.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { CounterComponent } from './counter.component'; 3 | 4 | describe('CounterComponent', () => { 5 | let component: CounterComponent; 6 | let fixture: ComponentFixture; 7 | 8 | beforeEach(async () => { 9 | await TestBed.configureTestingModule({ 10 | imports: [CounterComponent], 11 | }).compileComponents(); 12 | 13 | fixture = TestBed.createComponent(CounterComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /apps/angular-xstate-demo/src/app/counter/counter.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { assign, setup } from 'xstate'; 4 | import { useMachine } from 'xstate-ngx'; 5 | 6 | const countMachine = setup({ 7 | types: { 8 | context: {} as { count: number }, 9 | events: {} as 10 | | { type: 'INC' } 11 | | { type: 'DEC' } 12 | | { type: 'SET'; value: number }, 13 | }, 14 | }).createMachine({ 15 | context: { 16 | count: 0, 17 | }, 18 | on: { 19 | INC: { 20 | actions: assign({ 21 | count: ({ context }) => context.count + 1, 22 | }), 23 | }, 24 | DEC: { 25 | actions: assign({ 26 | count: ({ context }) => context.count - 1, 27 | }), 28 | }, 29 | SET: { 30 | actions: assign({ 31 | count: ({ event }) => event.value, 32 | }), 33 | }, 34 | }, 35 | }); 36 | 37 | const CountService = useMachine(countMachine); 38 | 39 | @Component({ 40 | selector: 'app-counter', 41 | standalone: true, 42 | imports: [CommonModule], 43 | providers: [CountService], 44 | template: ` 45 | Decrement 46 | {{ counterMachine.snapshot().context.count }} 47 | Increment 48 | 49 | Reset 50 | 51 | `, 52 | styles: ``, 53 | }) 54 | export class CounterComponent { 55 | protected counterMachine = inject(CountService); 56 | 57 | constructor() { 58 | this.counterMachine.snapshot().context.count; 59 | this.counterMachine.send({ type: 'SET', value: 0 }); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /apps/angular-xstate-demo/src/app/routing/routing.component.ts: -------------------------------------------------------------------------------- 1 | import { assign, fromPromise, setup } from 'xstate'; 2 | import { Router } from '@angular/router'; 3 | import { 4 | Component, 5 | inject, 6 | Injector, 7 | runInInjectionContext, 8 | } from '@angular/core'; 9 | import { useMachine } from 'xstate-ngx'; 10 | import { CommonModule } from '@angular/common'; 11 | 12 | const routeMachine = setup({ 13 | types: { 14 | context: {} as { id: number }, 15 | events: {} as { type: 'navigate' }, 16 | }, 17 | actors: { 18 | navigateActor: fromPromise(({ input }) => { 19 | const router = inject(Router); 20 | return router.navigate(['/counter']); 21 | }), 22 | }, 23 | }).createMachine({ 24 | context: { 25 | id: 0, 26 | }, 27 | initial: 'idle', 28 | states: { 29 | idle: { 30 | on: { 31 | navigate: { target: 'NEXT' }, 32 | }, 33 | }, 34 | NEXT: { 35 | invoke: { 36 | id: 'navigateToCounter', 37 | src: 'navigateActor', 38 | onDone: 'success', 39 | onError: 'failure', 40 | }, 41 | }, 42 | success: {}, 43 | failure: {}, 44 | }, 45 | }); 46 | 47 | const RoutingMachine = useMachine(routeMachine); 48 | 49 | @Component({ 50 | selector: 'app-router', 51 | standalone: true, 52 | imports: [CommonModule], 53 | providers: [RoutingMachine], 54 | template: ` 55 | {{ routingMachine.snapshot() | json }} 56 | Navigate 57 | `, 58 | styles: ``, 59 | }) 60 | export class RoutingComponent { 61 | protected routingMachine = inject(RoutingMachine); 62 | private injector = inject(Injector); 63 | 64 | navigate() { 65 | console.log('send event'); 66 | runInInjectionContext(this.injector, () => 67 | this.routingMachine.send({ type: 'navigate' }) 68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /apps/angular-xstate-demo/src/app/tic-tac-toe/tic-tac-toe.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject } from '@angular/core'; 2 | import { TicTacToeSerice } from './ticTacToeMachine'; 3 | import { TileComponent } from './tile.component'; 4 | 5 | @Component({ 6 | selector: 'app-tic-tac-toe-component', 7 | template: ` 8 | Tic-Tac-Toe 9 | @if (hasWinner()) { 10 | 11 | @if (isWinner()) { 12 | Winner: {{ ticTacToeService.snapshot().context.winner }} 13 | } @else if (isDraw()) { 14 | Draw 15 | } 16 | 17 | Reset 18 | 19 | 20 | } 21 | 22 | 23 | @for (tile of board; track $index) { 24 | 25 | } 26 | 27 | `, 28 | styles: ` 29 | `, 30 | providers: [TicTacToeSerice], 31 | standalone: true, 32 | imports: [TileComponent], 33 | }) 34 | export class TicTacToeComponent { 35 | protected ticTacToeService = inject(TicTacToeSerice); 36 | protected board = this.range(0, 9); 37 | 38 | protected hasWinner = this.ticTacToeService.matches('gameOver'); 39 | protected isWinner = this.ticTacToeService.hasTag('winner'); 40 | protected isDraw = this.ticTacToeService.hasTag('draw'); 41 | 42 | private range(start: number, end: number) { 43 | return Array(end - start) 44 | .fill(null) 45 | .map((_, i) => i + start); 46 | } 47 | 48 | tileClicked(index: number) { 49 | this.ticTacToeService.send({ type: 'PLAY', value: index }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /apps/angular-xstate-demo/src/app/tic-tac-toe/ticTacToeMachine.ts: -------------------------------------------------------------------------------- 1 | import { EventObject, createMachine, assign } from 'xstate'; 2 | import { useActor, useMachine } from 'xstate-ngx'; 3 | 4 | function assertEvent( 5 | ev: TEvent, 6 | type: Type 7 | ): asserts ev is Extract { 8 | if (ev.type !== type) { 9 | throw new Error('Unexpected event type.'); 10 | } 11 | } 12 | 13 | type Player = 'x' | 'o'; 14 | 15 | const context = { 16 | board: Array(9).fill(null) as Array, 17 | moves: 0, 18 | player: 'x' as Player, 19 | winner: undefined as Player | undefined, 20 | }; 21 | 22 | export const ticTacToeMachine = createMachine( 23 | { 24 | initial: 'playing', 25 | types: {} as { 26 | context: typeof context; 27 | events: { type: 'PLAY'; value: number } | { type: 'RESET' }; 28 | }, 29 | context, 30 | states: { 31 | playing: { 32 | always: [ 33 | { target: 'gameOver.winner', guard: 'checkWin' }, 34 | { target: 'gameOver.draw', guard: 'checkDraw' }, 35 | ], 36 | on: { 37 | PLAY: [ 38 | { 39 | target: 'playing', 40 | guard: 'isValidMove', 41 | actions: 'updateBoard', 42 | }, 43 | ], 44 | }, 45 | }, 46 | gameOver: { 47 | initial: 'winner', 48 | states: { 49 | winner: { 50 | tags: 'winner', 51 | entry: 'setWinner', 52 | }, 53 | draw: { 54 | tags: 'draw', 55 | }, 56 | }, 57 | on: { 58 | RESET: { 59 | target: 'playing', 60 | actions: 'resetGame', 61 | }, 62 | }, 63 | }, 64 | }, 65 | }, 66 | { 67 | actions: { 68 | updateBoard: assign({ 69 | board: ({ context, event }) => { 70 | assertEvent(event, 'PLAY'); 71 | const updatedBoard = [...context.board]; 72 | updatedBoard[event.value] = context.player; 73 | return updatedBoard; 74 | }, 75 | moves: ({ context }) => context.moves + 1, 76 | player: ({ context }) => (context.player === 'x' ? 'o' : 'x'), 77 | }), 78 | resetGame: assign(context), 79 | setWinner: assign({ 80 | winner: ({ context }) => (context.player === 'x' ? 'o' : 'x'), 81 | }), 82 | }, 83 | guards: { 84 | checkWin: ({ context }) => { 85 | const { board } = context; 86 | const winningLines = [ 87 | [0, 1, 2], 88 | [3, 4, 5], 89 | [6, 7, 8], 90 | [0, 3, 6], 91 | [1, 4, 7], 92 | [2, 5, 8], 93 | [0, 4, 8], 94 | [2, 4, 6], 95 | ]; 96 | 97 | for (const line of winningLines) { 98 | const xWon = line.every((index) => { 99 | return board[index] === 'x'; 100 | }); 101 | 102 | if (xWon) { 103 | return true; 104 | } 105 | 106 | const oWon = line.every((index) => { 107 | return board[index] === 'o'; 108 | }); 109 | 110 | if (oWon) { 111 | return true; 112 | } 113 | } 114 | 115 | return false; 116 | }, 117 | checkDraw: ({ context }) => { 118 | return context.moves === 9; 119 | }, 120 | isValidMove: ({ context, event }) => { 121 | if (event.type !== 'PLAY') { 122 | return false; 123 | } 124 | 125 | return context.board[event.value] === null; 126 | }, 127 | }, 128 | } 129 | ); 130 | 131 | export const TicTacToeSerice = useActor(ticTacToeMachine); 132 | -------------------------------------------------------------------------------- /apps/angular-xstate-demo/src/app/tic-tac-toe/tile.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, input } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-tile', 5 | standalone: true, 6 | template: ` `, 7 | styles: ` 8 | :host { 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | font-size: 10vmin; 13 | background: white; 14 | } 15 | 16 | .tile:before { 17 | content: attr(data-player); 18 | } 19 | 20 | `, 21 | }) 22 | export class TileComponent { 23 | player = input<'x' | 'o' | null>(null); 24 | } 25 | -------------------------------------------------------------------------------- /apps/angular-xstate-demo/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | angular-xstate-demo 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /apps/angular-xstate-demo/src/main.ts: -------------------------------------------------------------------------------- 1 | import { bootstrapApplication } from '@angular/platform-browser'; 2 | import { appConfig } from './app/app.config'; 3 | import { AppComponent } from './app/app.component'; 4 | 5 | bootstrapApplication(AppComponent, appConfig).catch((err) => 6 | console.error(err) 7 | ); 8 | -------------------------------------------------------------------------------- /apps/angular-xstate-demo/src/styles.css: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | html, 3 | body, 4 | .root { 5 | height: 100%; 6 | width: 100%; 7 | margin: 0; 8 | padding: 0; 9 | } 10 | 11 | .root { 12 | display: flex; 13 | justify-content: center; 14 | align-items: center; 15 | } 16 | 17 | .board { 18 | display: grid; 19 | height: 50vmin; 20 | width: 50vmin; 21 | grid-template-columns: repeat(3, 1fr); 22 | grid-template-rows: repeat(3, 1fr); 23 | grid-column-gap: 0.25rem; 24 | grid-row-gap: 0.25rem; 25 | background: #ddd; 26 | } 27 | -------------------------------------------------------------------------------- /apps/angular-xstate-demo/src/test-setup.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error https://thymikee.github.io/jest-preset-angular/docs/getting-started/test-environment 2 | globalThis.ngJest = { 3 | testEnvironmentOptions: { 4 | errorOnUnknownElements: true, 5 | errorOnUnknownProperties: true, 6 | }, 7 | }; 8 | import 'jest-preset-angular/setup-jest'; 9 | -------------------------------------------------------------------------------- /apps/angular-xstate-demo/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "types": [], 6 | "strict": true 7 | }, 8 | "files": ["src/main.ts"], 9 | "include": ["src/**/*.d.ts"], 10 | "exclude": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /apps/angular-xstate-demo/tsconfig.editor.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/**/*.ts"], 4 | "compilerOptions": {}, 5 | "exclude": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts"] 6 | } 7 | -------------------------------------------------------------------------------- /apps/angular-xstate-demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "useDefineForClassFields": false, 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "noImplicitOverride": true, 9 | "noPropertyAccessFromIndexSignature": true, 10 | "noImplicitReturns": true, 11 | "noFallthroughCasesInSwitch": true 12 | }, 13 | "files": [], 14 | "include": [], 15 | "references": [ 16 | { 17 | "path": "./tsconfig.editor.json" 18 | }, 19 | { 20 | "path": "./tsconfig.app.json" 21 | }, 22 | { 23 | "path": "./tsconfig.spec.json" 24 | } 25 | ], 26 | "extends": "../../tsconfig.base.json", 27 | "angularCompilerOptions": { 28 | "enableI18nLegacyMessageIdFormat": false, 29 | "strictInjectionParameters": true, 30 | "strictInputAccessModifiers": true, 31 | "strictTemplates": true 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/angular-xstate-demo/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "target": "es2016", 7 | "types": ["jest", "node"] 8 | }, 9 | "files": ["src/test-setup.ts"], 10 | "include": [ 11 | "jest.config.ts", 12 | "src/**/*.test.ts", 13 | "src/**/*.spec.ts", 14 | "src/**/*.d.ts" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import { getJestProjectsAsync } from '@nx/jest'; 2 | 3 | export default async () => ({ 4 | projects: await getJestProjectsAsync(), 5 | }); 6 | -------------------------------------------------------------------------------- /jest.preset.js: -------------------------------------------------------------------------------- 1 | const nxPreset = require('@nx/jest/preset').default; 2 | 3 | module.exports = { ...nxPreset }; 4 | -------------------------------------------------------------------------------- /lib/xstate-ngx/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts"], 7 | "extends": [ 8 | "plugin:@nx/angular", 9 | "plugin:@angular-eslint/template/process-inline-templates" 10 | ], 11 | "rules": { 12 | "@angular-eslint/directive-selector": [ 13 | "error", 14 | { 15 | "type": "attribute", 16 | "prefix": "lib", 17 | "style": "camelCase" 18 | } 19 | ], 20 | "@angular-eslint/component-selector": [ 21 | "error", 22 | { 23 | "type": "element", 24 | "prefix": "lib", 25 | "style": "kebab-case" 26 | } 27 | ] 28 | } 29 | }, 30 | { 31 | "files": ["*.html"], 32 | "extends": ["plugin:@nx/angular-template"], 33 | "rules": {} 34 | }, 35 | { 36 | "files": ["*.json"], 37 | "parser": "jsonc-eslint-parser", 38 | "rules": { 39 | "@nx/dependency-checks": "error" 40 | } 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /lib/xstate-ngx/README.md: -------------------------------------------------------------------------------- 1 | # xstate-ngx 2 | 3 | This is just a POC while the [PR in the Xstate Monorepository](https://github.com/statelyai/xstate/pull/4816) is being discussed. Eventually this will be moved and deprecated! 4 | 5 | This package contains utilities for using [XState](https://github.com/statelyai/xstate) with [Angular](https://github.com/angular/angular). 6 | 7 | - [Read the full documentation in the XState docs](https://stately.ai/docs/xstate-angular). 8 | - [Read our contribution guidelines](https://github.com/statelyai/xstate/blob/main/CONTRIBUTING.md). 9 | 10 | ## Quick start 11 | 12 | 1. Install `xstate` and `xstate-ngx`: 13 | 14 | ```bash 15 | npm i xstate xstate-ngx 16 | ``` 17 | 18 | 2. Import the `useMachine` function: 19 | 20 | ```angular-ts 21 | import { useMachine } from 'xstate-ngx'; 22 | import { createMachine } from 'xstate'; 23 | import {Component, inject} from '@angular/core'; 24 | const toggleMachine = createMachine({ 25 | id: 'toggle', 26 | initial: 'inactive', 27 | states: { 28 | inactive: { 29 | on: { TOGGLE: 'active' } 30 | }, 31 | active: { 32 | on: { TOGGLE: 'inactive' } 33 | } 34 | } 35 | }); 36 | const ToggleMachine = useMachine(toggleMachine, {providedIn: 'root'}) 37 | @Component({ 38 | selector: 'app-toggle', 39 | standalone: true, 40 | imports: [], 41 | template: ` 42 | {{ 43 | toggleMachine.snapshot().value === 'inactive' 44 | ? 'Click to activate' 45 | : 'Active! Click to deactivate' 46 | }} 47 | 48 | `, 49 | styleUrl: './toggle.component.css' 50 | }) 51 | export class ToggleComponent { 52 | public toggleMachine = inject(ToggleMachine); 53 | } 54 | ``` 55 | 56 | ## API 57 | 58 | ### `useActor(actorLogic, options?)` 59 | 60 | **Returns** `{ snapshot, send, actorRef }`: 61 | 62 | - `snapshot` - Represents the current snapshot (state) of the machine as an XState `State` object. Returns a Signal. 63 | - `send` - A function that sends events to the running actor. 64 | - `actorRef` - The created actor ref. 65 | - `matches` - indicating whether the provided path matches the machine snapshot. 66 | - `hasTag` - machine or machine snapshot has the specified tag. 67 | - `can` - path can be transitioned to in the state machine 68 | 69 | ### `useMachine(machine, options?)` 70 | 71 | A function that returns an Injectable that creates an actor from the given `machine` and starts an actor that runs for the lifetime of the component or DI context. 72 | 73 | #### Arguments 74 | 75 | - `machine` - An [XState machine](https://stately.ai/docs/machines) 76 | - `options` (optional) - Actor options 77 | 78 | **Returns** `{ snapshot, send, actorRef }`: 79 | 80 | - `snapshot` - Represents the current snapshot (state) of the machine as an XState `State` object. Returns a Signal. 81 | - `send` - A function that sends events to the running actor. 82 | - `actorRef` - The created actor ref. 83 | - `matches` - indicating whether the provided path matches the machine snapshot. 84 | - `hasTag` - machine or machine snapshot has the specified tag. 85 | - `can` - path can be transitioned to in the state machine 86 | -------------------------------------------------------------------------------- /lib/xstate-ngx/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: 'xstate-ngx', 4 | preset: '../../jest.preset.js', 5 | setupFilesAfterEnv: ['/src/test-setup.ts'], 6 | coverageDirectory: '../../coverage/lib/xstate-ngx', 7 | transform: { 8 | '^.+\\.(ts|mjs|js|html)$': [ 9 | 'jest-preset-angular', 10 | { 11 | tsconfig: '/tsconfig.spec.json', 12 | stringifyContentPathRegex: '\\.(html|svg)$', 13 | }, 14 | ], 15 | }, 16 | transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], 17 | snapshotSerializers: [ 18 | 'jest-preset-angular/build/serializers/no-ng-attributes', 19 | 'jest-preset-angular/build/serializers/ng-snapshot', 20 | 'jest-preset-angular/build/serializers/html-comment', 21 | ], 22 | }; 23 | -------------------------------------------------------------------------------- /lib/xstate-ngx/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/lib/xstate-ngx", 4 | "lib": { 5 | "entryFile": "src/index.ts" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /lib/xstate-ngx/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xstate-ngx", 3 | "version": "1.0.0-alpha.8", 4 | "keywords": [ 5 | "state", 6 | "machine", 7 | "statechart", 8 | "scxml", 9 | "state", 10 | "graph", 11 | "angular", 12 | "signals" 13 | ], 14 | "homepage": "https://github.com/niklas-wortmann/xstate-angular#readme", 15 | "repository": { 16 | "type": "git", 17 | "url": "git+ssh://git@github.com/niklas-wortmann/xstate-angular.git" 18 | }, 19 | "scripts": {}, 20 | "bugs": { 21 | "url": "https://github.com/niklas-wortmann/xstate-angular/issues" 22 | }, 23 | "peerDependencies": { 24 | "@angular/core": ">17.0.0", 25 | "xstate": "^5.11.0" 26 | }, 27 | "license": "MIT", 28 | "sideEffects": false 29 | } 30 | -------------------------------------------------------------------------------- /lib/xstate-ngx/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xstate-ngx", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "lib/xstate-ngx/src", 5 | "prefix": "lib", 6 | "projectType": "library", 7 | "tags": [], 8 | "targets": { 9 | "build": { 10 | "executor": "@nx/angular:package", 11 | "outputs": ["{workspaceRoot}/dist/{projectRoot}"], 12 | "options": { 13 | "project": "lib/xstate-ngx/ng-package.json" 14 | }, 15 | "configurations": { 16 | "production": { 17 | "tsConfig": "lib/xstate-ngx/tsconfig.lib.prod.json" 18 | }, 19 | "development": { 20 | "tsConfig": "lib/xstate-ngx/tsconfig.lib.json" 21 | } 22 | }, 23 | "defaultConfiguration": "production" 24 | }, 25 | "test": { 26 | "executor": "@nx/jest:jest", 27 | "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], 28 | "options": { 29 | "jestConfig": "lib/xstate-ngx/jest.config.ts" 30 | } 31 | }, 32 | "lint": { 33 | "executor": "@nx/eslint:lint" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/xstate-ngx/src/index.ts: -------------------------------------------------------------------------------- 1 | export { useActor } from './lib/useActor'; 2 | export { useActorRef } from './lib/useActorRef'; 3 | export { useMachine } from './lib/useMachine'; 4 | export { useSelector } from './lib/useSelector'; 5 | -------------------------------------------------------------------------------- /lib/xstate-ngx/src/lib/useActor.test.ts: -------------------------------------------------------------------------------- 1 | import { useActor } from './useActor'; 2 | import { createMachine } from 'xstate'; 3 | import { isSignal } from '@angular/core'; 4 | import { TestBed } from '@angular/core/testing'; 5 | 6 | describe('xstate-angular', () => { 7 | const machine = createMachine({ 8 | initial: 'inactive', 9 | states: { 10 | inactive: { 11 | tags: ['inactive'], 12 | on: { 13 | TOGGLE: 'active', 14 | }, 15 | }, 16 | active: { 17 | on: { 18 | TOGGLE: 'inactive', 19 | }, 20 | }, 21 | }, 22 | }); 23 | 24 | it('creates an actor as injectable service', () => { 25 | const ToggleActor = useActor(machine); 26 | TestBed.configureTestingModule({ providers: [ToggleActor] }); 27 | const actor = TestBed.inject(ToggleActor); 28 | expect(isSignal(actor.snapshot)).toBe(true); 29 | }); 30 | 31 | it('creates an actor that is provided in root when providedIn option is specified', () => { 32 | const ToggleActor = useActor(machine, { providedIn: 'root' }); 33 | 34 | const store1 = TestBed.inject(ToggleActor); 35 | const store2 = TestBed.inject(ToggleActor); 36 | 37 | expect(store1).toBe(store2); 38 | expect(isSignal(store1.snapshot)).toBe(true); 39 | }); 40 | 41 | it('update snapshot value', () => { 42 | const ToggleActor = useActor(machine, { providedIn: 'root' }); 43 | const store1 = TestBed.inject(ToggleActor); 44 | expect(store1.snapshot().value).toBe('inactive'); 45 | store1.send({ type: 'TOGGLE' }); 46 | expect(store1.snapshot().value).toBe('active'); 47 | }); 48 | 49 | it('can should be directly exposed', () => { 50 | const ToggleActor = useActor(machine, { providedIn: 'root' }); 51 | const store1 = TestBed.inject(ToggleActor); 52 | expect(store1.can({ type: 'TOGGLE' })()).toBe(true); 53 | }); 54 | 55 | it('hasTag should be directly exposed', () => { 56 | const ToggleActor = useActor(machine, { providedIn: 'root' }); 57 | const store1 = TestBed.inject(ToggleActor); 58 | expect(store1.hasTag('inactive')()).toBe(true); 59 | }); 60 | 61 | it('matches should be directly exposed', () => { 62 | const ToggleActor = useActor(machine, { providedIn: 'root' }); 63 | const store1 = TestBed.inject(ToggleActor); 64 | expect(store1.matches('inactive')()).toBe(true); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /lib/xstate-ngx/src/lib/useActor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Actor, 3 | ActorOptions, 4 | AnyActorLogic, 5 | isMachineSnapshot, 6 | Snapshot, 7 | SnapshotFrom, 8 | AnyMachineSnapshot, 9 | EventFromLogic, 10 | } from 'xstate'; 11 | import { 12 | computed, 13 | inject, 14 | Injectable, 15 | Injector, 16 | runInInjectionContext, 17 | Signal, 18 | signal, 19 | Type, 20 | WritableSignal, 21 | } from '@angular/core'; 22 | import { useActorRef } from './useActorRef'; 23 | 24 | /** 25 | * Creates an Angular service that provides an instance of an actor store. 26 | * 27 | * @param actorLogic - The logic implementation for the actor. 28 | * @param _options - Optional options to configure the actor store. Can include "providedIn" property to specify the Angular module where the service is provided. 29 | * 30 | * @return The Angular service that provides the actor store. 31 | */ 32 | export function useActor( 33 | actorLogic: TLogic, 34 | _options?: ActorOptions & { providedIn?: 'root' } 35 | ): Type> { 36 | const { providedIn, ...options } = _options ?? {}; 37 | 38 | @Injectable({ providedIn: providedIn ?? null }) 39 | class ActorStore implements ActorStoreProps { 40 | public actorRef: Actor; 41 | public send: Actor['send']; 42 | public snapshot: Signal>; 43 | private injector = inject(Injector); 44 | 45 | private _isMachine = true; 46 | private _snapshot: WritableSignal>; 47 | 48 | constructor() { 49 | const listener = (nextSnapshot: Snapshot) => { 50 | this._snapshot?.set(nextSnapshot as any); 51 | }; 52 | 53 | this.actorRef = useActorRef(actorLogic, options, listener); 54 | this._snapshot = signal(this.actorRef.getSnapshot()); 55 | this.send = (event: EventFromLogic) => 56 | runInInjectionContext(this.injector, () => this.actorRef.send(event)); 57 | this.snapshot = this._snapshot.asReadonly(); 58 | this._isMachine = isMachineSnapshot(this.snapshot()); 59 | } 60 | 61 | matches(path: MatchType) { 62 | return computed(() => { 63 | const machineSnapshot = this.snapshot(); 64 | if (this._isMachine) { 65 | return (machineSnapshot as AnyMachineSnapshot).matches(path); 66 | } 67 | return false; 68 | }); 69 | } 70 | 71 | hasTag(path: HasTagType) { 72 | return computed(() => { 73 | const machineSnapshot = this.snapshot(); 74 | if (this._isMachine) { 75 | return (machineSnapshot as AnyMachineSnapshot).hasTag(path); 76 | } 77 | return false; 78 | }); 79 | } 80 | 81 | can(path: CanType) { 82 | return computed(() => { 83 | const machineSnapshot = this.snapshot(); 84 | if (this._isMachine) { 85 | return (machineSnapshot as AnyMachineSnapshot).can(path); 86 | } 87 | return false; 88 | }); 89 | } 90 | } 91 | return ActorStore; 92 | } 93 | 94 | type FnParameter< 95 | TLogic extends AnyActorLogic, 96 | functionName extends string 97 | > = Parameters[functionName]>[0]; 98 | 99 | type MatchType = FnParameter; 100 | type CanType = FnParameter; 101 | type HasTagType = FnParameter; 102 | 103 | export interface ActorStoreProps { 104 | /** 105 | * Represents a reference to an actor. 106 | */ 107 | actorRef: Actor; 108 | /** 109 | * Represents a snapshot of the actor as Signal. 110 | */ 111 | snapshot: Signal>; 112 | /** 113 | * Sends a message to the actor associated with the specified logic. 114 | */ 115 | send: Actor['send']; 116 | /** 117 | * Returns a computed value indicating whether the provided path matches the machine snapshot. 118 | * 119 | * @param {MatchType} path - The path to be matched against the machine snapshot. 120 | * @return {Signal} - A computed boolean value indicating whether the path matches the machine snapshot. 121 | */ 122 | matches: (path: MatchType) => Signal; 123 | /** 124 | * Determines whether the specified path can be transitioned to in the state machine. 125 | * 126 | * @param {CanType} path - The path to check if it can be transitioned to. 127 | * @return {Signal} - Returns true if the specified path can be transitioned to, otherwise returns false. 128 | */ 129 | can: (event: CanType) => Signal; 130 | /** 131 | * Determines if the machine or machine snapshot has the specified tag. 132 | * 133 | * @param {HasTagType} path - The tag path. 134 | * @return {Signal<>boolean>} - True if the machine or machine snapshot has the tag, otherwise false. 135 | */ 136 | hasTag: (event: HasTagType) => Signal; 137 | } 138 | -------------------------------------------------------------------------------- /lib/xstate-ngx/src/lib/useActorRef.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Actor, 3 | ActorOptions, 4 | AnyActorLogic, 5 | createActor, 6 | Observer, 7 | SnapshotFrom, 8 | Subscription, 9 | toObserver, 10 | } from 'xstate'; 11 | import { DestroyRef, inject } from '@angular/core'; 12 | 13 | export function useActorRef( 14 | actorLogic: TLogic, 15 | options: ActorOptions = {}, 16 | observerOrListener?: 17 | | Observer> 18 | | ((value: SnapshotFrom) => void) 19 | ): Actor { 20 | const actorRef = createActor(actorLogic, options); 21 | 22 | let sub: Subscription; 23 | if (observerOrListener) { 24 | sub = actorRef.subscribe(toObserver(observerOrListener)); 25 | } 26 | 27 | actorRef.start(); 28 | 29 | inject(DestroyRef).onDestroy(() => { 30 | actorRef.stop(); 31 | sub?.unsubscribe(); 32 | }); 33 | 34 | return actorRef; 35 | } 36 | -------------------------------------------------------------------------------- /lib/xstate-ngx/src/lib/useMachine.ts: -------------------------------------------------------------------------------- 1 | import { ActorOptions, AnyStateMachine } from 'xstate'; 2 | import { useActor } from './useActor'; 3 | 4 | /** 5 | * Uses a machine to create an actor logic and returns it. 6 | * The actor logic can be used to interact with the given machine. 7 | * 8 | * @param {TMachine} actorLogic - The machine to create an actor logic for. 9 | * @param {ActorOptions} options - Optional configuration options for the actor logic. 10 | * @param {string} options.providedIn - The scope in which the actor logic should be provided. Defaults to 'root'. 11 | * 12 | * @returns {ActorLogic} The created actor logic. 13 | * @alias useActor 14 | */ 15 | export function useMachine( 16 | actorLogic: TMachine, 17 | options?: ActorOptions & { providedIn: 'root' } 18 | ) { 19 | return useActor(actorLogic, options); 20 | } 21 | -------------------------------------------------------------------------------- /lib/xstate-ngx/src/lib/useSelector.ts: -------------------------------------------------------------------------------- 1 | import { Signal, effect, isSignal, signal } from '@angular/core'; 2 | import type { AnyActorRef } from 'xstate'; 3 | 4 | function defaultCompare(a: T, b: T) { 5 | return a === b; 6 | } 7 | 8 | const noop = () => { 9 | /* ... */ 10 | }; 11 | 12 | export const useSelector = < 13 | TActor extends Pick, 14 | T 15 | >( 16 | actor: TActor | Signal, 17 | selector: ( 18 | snapshot: TActor extends { getSnapshot(): infer TSnapshot } 19 | ? TSnapshot 20 | : undefined 21 | ) => T, 22 | compare: (a: T, b: T) => boolean = defaultCompare 23 | ) => { 24 | const actorRefRef = isSignal(actor) ? actor : signal(actor); 25 | const selected = signal(selector(actorRefRef()?.getSnapshot())); 26 | 27 | const updateSelectedIfChanged = (nextSelected: T) => { 28 | if (!compare(selected(), nextSelected)) { 29 | selected.set(nextSelected); 30 | } 31 | }; 32 | 33 | effect( 34 | (onCleanup) => { 35 | const newActor = actorRefRef(); 36 | selected.set(selector(newActor?.getSnapshot())); 37 | if (!newActor) { 38 | return; 39 | } 40 | const sub = newActor.subscribe({ 41 | next: (emitted) => { 42 | updateSelectedIfChanged(selector(emitted)); 43 | }, 44 | error: noop, 45 | complete: noop, 46 | }); 47 | onCleanup(() => sub.unsubscribe()); 48 | }, 49 | { 50 | allowSignalWrites: true, 51 | } 52 | ); 53 | 54 | return selected; 55 | }; 56 | -------------------------------------------------------------------------------- /lib/xstate-ngx/src/test-setup.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error https://thymikee.github.io/jest-preset-angular/docs/getting-started/test-environment 2 | globalThis.ngJest = { 3 | testEnvironmentOptions: { 4 | errorOnUnknownElements: true, 5 | errorOnUnknownProperties: true, 6 | }, 7 | }; 8 | import 'jest-preset-angular/setup-jest'; 9 | -------------------------------------------------------------------------------- /lib/xstate-ngx/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "useDefineForClassFields": false, 5 | "forceConsistentCasingInFileNames": true, 6 | "strict": true, 7 | "noImplicitOverride": true, 8 | "noPropertyAccessFromIndexSignature": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true 11 | }, 12 | "files": [], 13 | "include": [], 14 | "references": [ 15 | { 16 | "path": "./tsconfig.lib.json" 17 | }, 18 | { 19 | "path": "./tsconfig.spec.json" 20 | } 21 | ], 22 | "extends": "../../tsconfig.base.json", 23 | "angularCompilerOptions": { 24 | "enableI18nLegacyMessageIdFormat": false, 25 | "strictInjectionParameters": true, 26 | "strictInputAccessModifiers": true, 27 | "strictTemplates": true 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/xstate-ngx/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "declaration": true, 6 | "declarationMap": true, 7 | "inlineSources": true, 8 | "types": [] 9 | }, 10 | "exclude": [ 11 | "src/**/*.spec.ts", 12 | "src/test-setup.ts", 13 | "jest.config.ts", 14 | "src/**/*.test.ts" 15 | ], 16 | "include": ["src/**/*.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /lib/xstate-ngx/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.lib.json", 3 | "compilerOptions": { 4 | "declarationMap": false 5 | }, 6 | "angularCompilerOptions": { 7 | "compilationMode": "partial" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /lib/xstate-ngx/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "target": "es2016", 7 | "types": ["jest", "node"] 8 | }, 9 | "files": ["src/test-setup.ts"], 10 | "include": [ 11 | "jest.config.ts", 12 | "src/**/*.test.ts", 13 | "src/**/*.spec.ts", 14 | "src/**/*.d.ts" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /nx.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/nx/schemas/nx-schema.json", 3 | "namedInputs": { 4 | "default": ["{projectRoot}/**/*", "sharedGlobals"], 5 | "production": [ 6 | "default", 7 | "!{projectRoot}/.eslintrc.json", 8 | "!{projectRoot}/eslint.config.js", 9 | "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)", 10 | "!{projectRoot}/tsconfig.spec.json", 11 | "!{projectRoot}/jest.config.[jt]s", 12 | "!{projectRoot}/src/test-setup.[jt]s", 13 | "!{projectRoot}/test-setup.[jt]s" 14 | ], 15 | "sharedGlobals": [] 16 | }, 17 | "targetDefaults": { 18 | "nx-release-publish": { 19 | "dependsOn": ["build"], 20 | "options": { 21 | "packageRoot": "dist/{projectRoot}" 22 | } 23 | }, 24 | "@angular-devkit/build-angular:application": { 25 | "cache": true, 26 | "dependsOn": ["^build"], 27 | "inputs": ["production", "^production"] 28 | }, 29 | "@nx/eslint:lint": { 30 | "cache": true, 31 | "inputs": [ 32 | "default", 33 | "{workspaceRoot}/.eslintrc.json", 34 | "{workspaceRoot}/.eslintignore", 35 | "{workspaceRoot}/eslint.config.js" 36 | ] 37 | }, 38 | "@nx/jest:jest": { 39 | "cache": true, 40 | "inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"], 41 | "options": { 42 | "passWithNoTests": true 43 | }, 44 | "configurations": { 45 | "ci": { 46 | "ci": true, 47 | "codeCoverage": true 48 | } 49 | } 50 | }, 51 | "@nx/angular:package": { 52 | "cache": true, 53 | "dependsOn": ["^build"], 54 | "inputs": ["production", "^production"] 55 | } 56 | }, 57 | "plugins": [ 58 | { 59 | "plugin": "@nx/playwright/plugin", 60 | "options": { 61 | "targetName": "e2e" 62 | } 63 | }, 64 | { 65 | "plugin": "@nx/eslint/plugin", 66 | "options": { 67 | "targetName": "lint" 68 | } 69 | } 70 | ], 71 | "generators": { 72 | "@nx/angular:application": { 73 | "e2eTestRunner": "playwright", 74 | "linter": "eslint", 75 | "style": "css", 76 | "unitTestRunner": "jest" 77 | }, 78 | "@nx/angular:library": { 79 | "linter": "eslint", 80 | "unitTestRunner": "jest" 81 | }, 82 | "@nx/angular:component": { 83 | "style": "css" 84 | } 85 | }, 86 | "nxCloudAccessToken": "YjM5MzI1NGItMGMyYS00MDU4LTkyODgtMjE1ZmRjMGMyYWY5fHJlYWQtd3JpdGU=", 87 | "release": { 88 | "version": { 89 | "generatorOptions": { 90 | "packageRoot": "dist/{projectRoot}", 91 | "currentVersionResolver": "git-tag" 92 | } 93 | }, 94 | "changelog": { 95 | "workspaceChangelog": { 96 | "createRelease": "github" 97 | } 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@repo/source", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "scripts": {}, 6 | "private": true, 7 | "dependencies": { 8 | "@angular/animations": "~18.0.0", 9 | "@angular/common": "~18.0.0", 10 | "@angular/compiler": "~18.0.0", 11 | "@angular/core": "~18.0.0", 12 | "@angular/forms": "~18.0.0", 13 | "@angular/platform-browser": "~18.0.0", 14 | "@angular/platform-browser-dynamic": "~18.0.0", 15 | "@angular/router": "~18.0.0", 16 | "@nx/angular": "19.3.2", 17 | "rxjs": "~7.8.0", 18 | "tslib": "^2.3.0", 19 | "xstate": "5.14.0", 20 | "zone.js": "~0.14.3" 21 | }, 22 | "devDependencies": { 23 | "@angular-devkit/build-angular": "~18.0.0", 24 | "@angular-devkit/core": "~18.0.0", 25 | "@angular-devkit/schematics": "~18.0.0", 26 | "@angular-eslint/eslint-plugin": "^18.0.1", 27 | "@angular-eslint/eslint-plugin-template": "^18.0.1", 28 | "@angular-eslint/template-parser": "^18.0.1", 29 | "@angular/cli": "~18.0.0", 30 | "@angular/compiler-cli": "~18.0.0", 31 | "@angular/language-service": "~18.0.0", 32 | "@nx/devkit": "19.3.2", 33 | "@nx/eslint": "19.3.2", 34 | "@nx/eslint-plugin": "19.3.2", 35 | "@nx/jest": "19.3.2", 36 | "@nx/js": "19.3.2", 37 | "@nx/playwright": "19.3.2", 38 | "@nx/workspace": "19.3.2", 39 | "@playwright/test": "^1.36.0", 40 | "@schematics/angular": "~18.0.0", 41 | "@swc-node/register": "~1.9.1", 42 | "@swc/core": "~1.5.7", 43 | "@swc/helpers": "~0.5.11", 44 | "@types/jest": "^29.4.0", 45 | "@types/node": "18.16.9", 46 | "@typescript-eslint/eslint-plugin": "^7.3.0", 47 | "@typescript-eslint/parser": "^7.3.0", 48 | "@typescript-eslint/utils": "^8.0.0-alpha.28", 49 | "autoprefixer": "^10.4.0", 50 | "eslint": "~8.57.0", 51 | "eslint-config-prettier": "^9.0.0", 52 | "eslint-plugin-playwright": "^0.15.3", 53 | "jest": "^29.4.1", 54 | "jest-environment-jsdom": "^29.4.1", 55 | "jest-preset-angular": "~14.1.0", 56 | "jsonc-eslint-parser": "^2.1.0", 57 | "ng-packagr": "~18.0.0", 58 | "nx": "19.3.2", 59 | "postcss": "^8.4.5", 60 | "postcss-url": "~10.1.3", 61 | "prettier": "^2.6.2", 62 | "ts-jest": "^29.1.0", 63 | "ts-node": "10.9.1", 64 | "typescript": "~5.4.2" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "sourceMap": true, 6 | "declaration": false, 7 | "moduleResolution": "node", 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "importHelpers": true, 11 | "target": "es2015", 12 | "module": "esnext", 13 | "lib": ["es2020", "dom"], 14 | "skipLibCheck": true, 15 | "skipDefaultLibCheck": true, 16 | "baseUrl": ".", 17 | "paths": { 18 | "xstate-ngx": ["lib/xstate-ngx/src/index.ts"] 19 | } 20 | }, 21 | "exclude": ["node_modules", "tmp"] 22 | } 23 | --------------------------------------------------------------------------------