├── .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 |
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 |
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 |
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 | ;
9 |
--------------------------------------------------------------------------------
/slides/simpleEvent.jsx:
--------------------------------------------------------------------------------
1 | import { Form } from "@remix-run/react";
2 |
3 | export function NextButton() {
4 | return (
5 |
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 |
--------------------------------------------------------------------------------