├── .gitignore ├── .prettierrc ├── README.md ├── app ├── asyncInterpret.js ├── components │ ├── Confetti.jsx │ └── EventButton.jsx ├── confetti.css ├── cookies.ts ├── db.js ├── entry.client.jsx ├── entry.server.jsx ├── root.jsx ├── routes │ ├── $state │ │ └── index.jsx │ └── index.jsx ├── store │ ├── Billing.jsx │ ├── Cart.jsx │ ├── NotFound.jsx │ ├── OrderSuccess.jsx │ └── Shipping.jsx └── swagStoreMachine.js ├── package.json ├── public └── favicon.ico ├── remix.config.js ├── remix.env.d.ts ├── slides ├── complexEvent.jsx └── simpleEvent.jsx ├── styles └── app.css ├── tailwind.config.js ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /.cache 4 | /build 5 | /public/build 6 | /app/styles/app.css 7 | 8 | worker.js -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all" 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Backend XState Machines on Remix 2 | -------------------------------------------------------------------------------- /app/asyncInterpret.js: -------------------------------------------------------------------------------- 1 | import { interpret } from "xstate"; 2 | import { waitFor } from "xstate/lib/waitFor"; 3 | 4 | export async function asyncInterpret( 5 | machine, 6 | msToWait, 7 | initialState, 8 | initialEvent, 9 | ) { 10 | const service = interpret(machine); 11 | service.start(initialState); 12 | if (initialEvent) { 13 | service.send(initialEvent); 14 | } 15 | return await waitFor( 16 | service, 17 | (state) => state.hasTag("pause") || state.done, 18 | { timeout: msToWait }, 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /app/components/Confetti.jsx: -------------------------------------------------------------------------------- 1 | export function Confetti() { 2 | return ( 3 |
4 | {[...Array(1000)].map((_, i) => ( 5 |
6 | ))} 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /app/components/EventButton.jsx: -------------------------------------------------------------------------------- 1 | import { Form } from "remix"; 2 | 3 | export function EventButton({ children, className, event, payload }) { 4 | return ( 5 |
6 | {payload && 7 | Object.keys(payload).length > 0 && 8 | Object.entries(payload).map(([key, value]) => ( 9 | 10 | ))} 11 | 12 | 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /app/cookies.ts: -------------------------------------------------------------------------------- 1 | import { createCookie } from "remix"; 2 | 3 | export const swagStoreMachineCookie = createCookie("swag-store-machine", { 4 | secrets: ["r3m1x-c0nF-2022"], 5 | }); 6 | -------------------------------------------------------------------------------- /app/db.js: -------------------------------------------------------------------------------- 1 | const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 2 | 3 | export const db = { 4 | fetchShippingStates: async (products) => { 5 | await sleep(1_000); 6 | const states = ["North Carolina"]; 7 | if (products.includes("Centered.app Hoodie")) { 8 | states.push("California"); 9 | } 10 | if (products.includes("Remix Conf 2022 T-shirt")) { 11 | states.push("Utah"); 12 | } 13 | states.sort(); 14 | return states; 15 | }, 16 | 17 | createOrder: async (products, billing, shipping) => { 18 | await sleep(2_000); 19 | return { 20 | products, 21 | billing, 22 | shipping, 23 | }; 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /app/entry.client.jsx: -------------------------------------------------------------------------------- 1 | import { RemixBrowser } from "remix"; 2 | import { hydrate } from "react-dom"; 3 | 4 | hydrate(, document); 5 | -------------------------------------------------------------------------------- /app/entry.server.jsx: -------------------------------------------------------------------------------- 1 | import { RemixServer } from "remix"; 2 | import { renderToString } from "react-dom/server"; 3 | 4 | export default function handleRequest( 5 | request, 6 | responseStatusCode, 7 | responseHeaders, 8 | remixContext, 9 | ) { 10 | const markup = renderToString( 11 | , 12 | ); 13 | 14 | responseHeaders.set("Content-Type", "text/html"); 15 | 16 | return new Response("" + markup, { 17 | status: responseStatusCode, 18 | headers: responseHeaders, 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /app/root.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Links, 3 | LiveReload, 4 | Meta, 5 | Outlet, 6 | Scripts, 7 | ScrollRestoration, 8 | } from "remix"; 9 | 10 | import styles from "./styles/app.css"; 11 | 12 | export function links() { 13 | return [{ rel: "stylesheet", href: styles }]; 14 | } 15 | 16 | export const meta = () => { 17 | return { title: "Remix Conf 2022 – State Machines On The Edge" }; 18 | }; 19 | 20 | export default function App() { 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 | 32 |
33 | 34 | 35 | {process.env.NODE_ENV === "development" && } 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /app/routes/$state/index.jsx: -------------------------------------------------------------------------------- 1 | import { json, redirect, useLoaderData } from "remix"; 2 | import { State } from "xstate"; 3 | import { asyncInterpret } from "../../asyncInterpret"; 4 | import confetti from "../../confetti.css"; 5 | import { swagStoreMachineCookie } from "../../cookies"; 6 | import { Billing } from "../../store/Billing"; 7 | import { Cart } from "../../store/Cart"; 8 | import { NotFound } from "../../store/NotFound"; 9 | import { OrderSuccess } from "../../store/OrderSuccess"; 10 | import { Shipping } from "../../store/Shipping"; 11 | import { swagStoreMachine } from "../../swagStoreMachine"; 12 | 13 | export const readCookie = async (request) => { 14 | const oldCookie = request.headers.get("Cookie"); 15 | return await swagStoreMachineCookie.parse(oldCookie); 16 | }; 17 | 18 | export function headers() { 19 | return { 20 | "Cache-control": "no-store", 21 | Pragma: "no-cache", 22 | }; 23 | } 24 | 25 | export function links() { 26 | return [{ rel: "stylesheet", href: confetti }]; 27 | } 28 | 29 | export const loader = async ({ request, params: { state } }) => { 30 | const stateConfig = await readCookie(request); 31 | if (!stateConfig || !state) { 32 | // No cookie, so start over 33 | return redirect(".."); 34 | } 35 | // Convert cookie into machine state 36 | const currentState = await swagStoreMachine.resolveState( 37 | State.create(stateConfig), 38 | ); 39 | if (stateConfig.value === state) { 40 | // The state from the cookie matches the url 41 | return json( 42 | currentState, 43 | currentState.done // Clear the cookie if we are done 44 | ? { 45 | headers: { 46 | "Set-Cookie": await swagStoreMachineCookie.serialize( 47 | {}, 48 | { expires: new Date(0) }, 49 | ), 50 | }, 51 | } 52 | : undefined, 53 | ); 54 | } else { 55 | // Transition to the state that matches the url, and return that 56 | const transitionState = await asyncInterpret( 57 | swagStoreMachine, // machine definition 58 | 3_000, // timeout 59 | currentState, // current state 60 | { type: "Goto", destination: state }, // event to send 61 | ); 62 | return json(transitionState, { 63 | headers: { 64 | "Set-Cookie": await swagStoreMachineCookie.serialize(transitionState), 65 | }, 66 | }); 67 | } 68 | }; 69 | 70 | export const action = async ({ request, params: { state } }) => { 71 | const stateConfig = await readCookie(request); 72 | if (!stateConfig) return redirect(".."); // No cookie, so start over 73 | 74 | const currentState = swagStoreMachine.resolveState(stateConfig); 75 | const event = Object.fromEntries(await request.formData()); 76 | 77 | const nextState = await asyncInterpret( 78 | swagStoreMachine, 79 | 3_000, 80 | currentState, 81 | event, 82 | ); 83 | return redirect(String(nextState.value), { 84 | headers: { 85 | "Set-Cookie": await swagStoreMachineCookie.serialize(nextState), 86 | }, 87 | }); 88 | }; 89 | 90 | export default function Store() { 91 | const state = useLoaderData(); 92 | 93 | switch (state.value) { 94 | case "Cart": 95 | return ; 96 | case "Shipping": 97 | return ; 98 | case "Billing": 99 | return ; 100 | case "Order Success": 101 | return ; 102 | default: 103 | return ; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /app/routes/index.jsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "remix"; 2 | import { asyncInterpret } from "../asyncInterpret"; 3 | import { swagStoreMachineCookie } from "../cookies"; 4 | import { swagStoreMachine } from "../swagStoreMachine"; 5 | import { readCookie } from "./$state"; 6 | 7 | export const loader = async ({ request }) => { 8 | const stateConfig = await readCookie(request); 9 | if (stateConfig) { 10 | // already have cookie. Redirect to correct url 11 | return redirect(String(stateConfig.value)); 12 | } 13 | 14 | const swagStoreState = await asyncInterpret(swagStoreMachine, 3_000); 15 | return redirect(String(swagStoreState.value), { 16 | headers: { 17 | "Set-Cookie": await swagStoreMachineCookie.serialize(swagStoreState), 18 | }, 19 | }); 20 | }; 21 | 22 | export default function Index() { 23 | return null; 24 | } 25 | -------------------------------------------------------------------------------- /app/store/Billing.jsx: -------------------------------------------------------------------------------- 1 | import { Form, useLoaderData } from "remix"; 2 | import { EventButton } from "../components/EventButton"; 3 | 4 | export function Billing() { 5 | const state = useLoaderData(); 6 | return ( 7 |
8 |

Billing

9 | « Back to Shipping 10 | 11 |
12 |
13 | 25 |
26 |
27 | 42 |
43 |
44 | 52 |
53 |
54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /app/store/Cart.jsx: -------------------------------------------------------------------------------- 1 | import { useLoaderData } from "remix"; 2 | import { EventButton } from "../components/EventButton"; 3 | 4 | export function Cart() { 5 | return ( 6 |
7 |

Cart

8 | 9 | 10 | 11 |
12 | 16 | Proceed to Checkout 17 | 18 |
19 |
20 | ); 21 | } 22 | 23 | function ProductRow({ name }) { 24 | const state = useLoaderData(); 25 | return ( 26 |
27 |
{name}
28 |
29 |
{state.context.cart[name] ?? 0}
30 |
31 | 36 | - 37 | 38 | 43 | + 44 | 45 |
46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /app/store/NotFound.jsx: -------------------------------------------------------------------------------- 1 | import { Link } from "remix"; 2 | 3 | export function NotFound() { 4 | return ( 5 |
6 |

Not Found

7 |
8 | 12 | Begin Shopping 13 | 14 |
15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /app/store/OrderSuccess.jsx: -------------------------------------------------------------------------------- 1 | import { Link } from "remix"; 2 | import { Confetti } from "../components/Confetti"; 3 | import { EventButton } from "../components/EventButton"; 4 | 5 | export function OrderSuccess() { 6 | return ( 7 | <> 8 |
9 |
10 |
11 |

Order Success!

12 |
13 | 17 | Buy More Swag 18 | 19 |
20 |
21 |
22 |
23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /app/store/Shipping.jsx: -------------------------------------------------------------------------------- 1 | import { Form, useLoaderData } from "remix"; 2 | import { EventButton } from "../components/EventButton"; 3 | 4 | export function Shipping() { 5 | const state = useLoaderData(); 6 | return ( 7 |
8 |

Shipping

9 | « Back to Cart 10 |
11 |
12 | 24 |
25 |
26 | 41 |
42 |
43 | 51 |
52 |
53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /app/swagStoreMachine.js: -------------------------------------------------------------------------------- 1 | import { assign, createMachine } from "xstate"; 2 | import { db } from "./db"; 3 | 4 | export const swagStoreMachine = 5 | /** @xstate-layout N4IgpgJg5mDOIC5QGUDuBDKACZAXA9gE5gB0AwuobgMQAiYAxsQLZgB2uWACofhAK4NciUAAd8sAJa5J+NiJAAPRACYA7GpIA2AKwAOAIwAGACxH9BtSbUAaEAE9EavSQDMJgJwGtW1660q5jpqAL4hdmiYOATE5JQ0AJJsTGCsHNy8AkIK4lIycgrKCHqavvpaXpZ6rtU6do4Izm6e3r7+gTrBYREY2HhEpBRU1GQAFowA1vj8wkggudKy8nNFKtYkKjV+ejoqOgauB671iK6BG7trHlpGPiYqKt0gkX0xpMijkqKikmxQ1AAhdAMCZYAhYIazMQSRYFFaIYxGIzaayGAz6cxqAwqE4IPY6bRqTZeDzOQ5qIx6J4vaIDEgfL4-P7UAByYEUUPmMPyy1ART0ei0JFuFMsrh0fi8uJMgrcriMByMHhqBnuVPCz16tNiABl8OgIDhPt9fq90Lg4NQGSa-tFzXAsHqDZActyloVENVNJ0TGZdN5KZ5ceKVCQ1JdAu57mZXNStf1YlwADbA01YADyhAgYEI1Ez2cI3BTDBdcwWPI9eNu2hKvpUOxJehluN0JhIOhM-mcalJ+l2caiCdIAMkSaTpuoyeBYAzWZzrry7vhCBMvnblOCnb09aM6lxOl3Fx0Hn2WgOAtVOgHrzpI7HE6BIIXsN5SkQHgVYflHQ7Zj3DkQPsNksLRqjRX0iWvbUwGoABxfACGfCtly0KwSFXNRQKsVdrmPaVXBIJtXFFFRrnFA9QieNg+DgBQaSHOIqCQpc+VUAwXCxdRMVA7cdF8XEHiFHtxQ0DQSiRDx1R6Qc3npY0mSgZi4VYhBjAPNxt2MaoPGVHt93RI8T2xPwZV0KCGPzHMcEEEtYHgMs3WUt8EGPFwdK9Ix5SscNbAAhAtBlYVOwqQUe0pWMNXo2SnUNa0FLtC17OhRcnKKawPHQ0DiJuJFTBPYMzAuB4TAMLwbg6czZKnBg00swglNfIpvAy0j3F2LxtwVY4-JlDLw2PCVPGC7FKtvUdxz+BrKyJAiqgCvQFSsbwDH0gl8SMzZ3EFK9IvjN4ppQgiykFSoyVqExcQAWhKkh0QCUkWh0iVHjCEIgA */ 6 | createMachine( 7 | { 8 | context: { cart: {} }, 9 | id: "Swag Store", 10 | initial: "Cart", 11 | on: { 12 | Goto: [ 13 | { 14 | cond: "isCart", 15 | target: ".Cart", 16 | }, 17 | { 18 | cond: "isShipping", 19 | target: ".Shipping", 20 | }, 21 | { 22 | cond: "isBilling", 23 | target: ".Billing", 24 | }, 25 | ], 26 | }, 27 | states: { 28 | Cart: { 29 | tags: "pause", 30 | on: { 31 | "Decrement Product": { 32 | actions: "decrementProduct", 33 | target: "Cart", 34 | internal: false, 35 | }, 36 | "Increment Product": { 37 | actions: "incrementProduct", 38 | target: "Cart", 39 | internal: false, 40 | }, 41 | Checkout: { 42 | cond: "hasItems", 43 | target: "Load Shipping States", 44 | }, 45 | }, 46 | }, 47 | Shipping: { 48 | tags: "pause", 49 | on: { 50 | "Back to Cart": { 51 | target: "Cart", 52 | }, 53 | Next: { 54 | actions: "saveShipping", 55 | target: "Billing", 56 | }, 57 | }, 58 | }, 59 | "Order Success": { 60 | tags: "pause", 61 | type: "final", 62 | }, 63 | "Load Shipping States": { 64 | invoke: { 65 | src: "loadShippingStates", 66 | }, 67 | on: { 68 | "Shipping States Loaded": { 69 | actions: "saveShippingStates", 70 | target: "Shipping", 71 | }, 72 | }, 73 | }, 74 | "Placing Order": { 75 | invoke: { 76 | src: "placeOrder", 77 | }, 78 | on: { 79 | "Order Placed": { 80 | target: "Order Success", 81 | }, 82 | }, 83 | }, 84 | Billing: { 85 | tags: "pause", 86 | on: { 87 | "Place Order": { 88 | actions: "saveBilling", 89 | target: "Placing Order", 90 | }, 91 | Back: { 92 | target: "Shipping", 93 | }, 94 | }, 95 | }, 96 | }, 97 | }, 98 | { 99 | actions: { 100 | decrementProduct: assign({ 101 | cart: ({ cart }, event) => ({ 102 | ...cart, 103 | [event.product]: (cart[event.product] ?? 1) - 1, 104 | }), 105 | }), 106 | incrementProduct: assign({ 107 | cart: ({ cart }, event) => ({ 108 | ...cart, 109 | [event.product]: (cart[event.product] ?? 0) + 1, 110 | }), 111 | }), 112 | saveBilling: assign({ 113 | billing: (_context, event) => ({ 114 | name: event.name, 115 | state: event.state, 116 | }), 117 | }), 118 | saveShipping: assign({ 119 | billing: (context, event) => ({ 120 | name: context.billing?.name ?? event.name, 121 | state: context.billing?.state ?? event.state, 122 | }), 123 | shipping: (_context, event) => ({ 124 | name: event.name, 125 | state: event.state, 126 | }), 127 | }), 128 | saveShippingStates: assign({ 129 | shippingStates: (_context, event) => event.shippingStates, 130 | }), 131 | }, 132 | guards: { 133 | isCart: (_context, event) => event.destination === "Cart", 134 | isShipping: (_context, event) => event.destination === "Shipping", 135 | isBilling: (_context, event) => event.destination === "Billing", 136 | hasItems: ({ cart }) => 137 | Object.values(cart).some((quantity) => quantity > 0), 138 | }, 139 | services: { 140 | loadShippingStates: 141 | ({ cart }) => 142 | async (send) => { 143 | const products = Object.keys(cart).filter( 144 | (product) => cart[product] > 0, 145 | ); 146 | const shippingStates = await db.fetchShippingStates(products); 147 | send({ type: "Shipping States Loaded", shippingStates }); 148 | }, 149 | placeOrder: 150 | ({ cart, billing, shipping }) => 151 | async (send) => { 152 | await db.createOrder(cart, billing, shipping); 153 | send("Order Placed"); 154 | }, 155 | }, 156 | }, 157 | ); 158 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "backend-xstate-machines", 4 | "description": "", 5 | "license": "", 6 | "scripts": { 7 | "build": "npm run build:app:css && npm run build:remix", 8 | "build:remix": "remix build", 9 | "build:app:css": "tailwindcss -m -i ./styles/app.css -o app/styles/app.css", 10 | "dev": "concurrently \"npm run dev:app:css\" \"remix dev\"", 11 | "dev:app:css": "tailwindcss -w -i ./styles/app.css -o app/styles/app.css --watch", 12 | "postinstall": "remix setup node", 13 | "start": "remix-serve build" 14 | }, 15 | "dependencies": { 16 | "@remix-run/react": "^1.3.5", 17 | "@remix-run/serve": "^1.3.5", 18 | "react": "^18.0.0", 19 | "react-dom": "^18.0.0", 20 | "remix": "^1.3.5", 21 | "xstate": "^4.31.0" 22 | }, 23 | "devDependencies": { 24 | "@remix-run/dev": "^1.3.5", 25 | "@tailwindcss/typography": "^0.5.2", 26 | "@types/node": "^17.0.23", 27 | "npm-run-all": "^4.1.5", 28 | "tailwindcss": "^3.0.23" 29 | }, 30 | "engines": { 31 | "node": ">=14" 32 | }, 33 | "sideEffects": false 34 | } 35 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikras/remix-conf-2022/8770779e092890a01bc151f0e69ab8140cd2238d/public/favicon.ico -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@remix-run/dev/config').AppConfig} 3 | */ 4 | module.exports = { 5 | appDirectory: "app", 6 | assetsBuildDirectory: "public/build", 7 | publicPath: "/build/", 8 | serverBuildDirectory: "build", 9 | devServerPort: 8002, 10 | ignoredRouteFiles: [".*"] 11 | }; 12 | -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /slides/complexEvent.jsx: -------------------------------------------------------------------------------- 1 | import { Form } from "@remix-run/react"; 2 | 3 |
4 | 5 | 8 |
; 9 | -------------------------------------------------------------------------------- /slides/simpleEvent.jsx: -------------------------------------------------------------------------------- 1 | import { Form } from "@remix-run/react"; 2 | 3 | export function NextButton() { 4 | return ( 5 |
6 | 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /styles/app.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ["./app/**/*.{js,jsx}"], 3 | theme: { 4 | extend: {}, 5 | }, 6 | plugins: [require("@tailwindcss/typography")], 7 | darkMode: "class", 8 | }; 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "remix.env.d.ts", 4 | "**/*.ts", 5 | "**/*.tsx", 6 | "app/asyncInterpret.js", 7 | "app/storeMachine.js", 8 | "app/routes/index.jsx", 9 | "app/routes/$state/index.jsx", 10 | "app/entry.client.jsx", 11 | "app/entry.server.jsx", 12 | "app/root.jsx" 13 | ], 14 | "compilerOptions": { 15 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 16 | "isolatedModules": true, 17 | "esModuleInterop": true, 18 | "jsx": "react-jsx", 19 | "moduleResolution": "node", 20 | "resolveJsonModule": true, 21 | "target": "ES2019", 22 | "strict": true, 23 | "baseUrl": ".", 24 | "paths": { 25 | "~/*": ["./app/*"] 26 | }, 27 | 28 | // Remix takes care of building everything in `remix build`. 29 | "noEmit": true 30 | } 31 | } 32 | --------------------------------------------------------------------------------