Skip to content

Instantly share code, notes, and snippets.

@redbar0n
Last active April 20, 2024 13:59
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save redbar0n/545e94cc4e0ca1dfc922459251fd7c5a to your computer and use it in GitHub Desktop.
Save redbar0n/545e94cc4e0ca1dfc922459251fd7c5a to your computer and use it in GitHub Desktop.
The MVC-widget - Through refactoring boilerplate out from an XState example

What if you could have vertically sliced MVC-widgets, that looked something like this?

Using React and XState.

//  A vertically sliced "MVC-widget"

//  VIEW:

export default function Users() {
  const [state, send] = useMachine(usersMachine);
  return (
    <div className="users">
      <button type="button" onClick={() => send("FETCH")}>
        Fetch users
      </button>
      {
        {
          success: state.context.data?.map((user) => (
            <div key={user.id}>{user.name}</div>
          )),
        }[state.value]
      }
    </div>
  );
}

// MODEL-CONTROLLER:

const usersMachine = Machine({
  id: "users",
  on: { FETCH: "loading" },
  states: {
    loading: fetchSome("users")
  }
});

The benefits would be:

  • Mental Model / Architectural Model: a clear mental model of how to construct a application (MVC-widgets communicating with each other as XState Actors).
  • Less boilerplate than vanilla XState: if the suggested boilerplate parts could be abstracted away, either by XState or a wrapping library.
  • Widget / single-file-component (SFC): the view and the (relevant parts of the) machine are located in the same file, so you can get an overview quickly, without having to jump too much back and forth.
  • Decoupling:
    • Model-View Separation: a clean/dumb view, that simply sends events and renders based on state.
    • Testability: business logic could be more easily testable separate from the view.
    • Less reliance on hooks (and their rules): the single useMachine hook being the only hook responsible for updating your view.
    • View library agnostic: in the off chance you want to change out your rendering library down the line.

General benefits which are opened up by using XState:

  • Routing...: though this example does not presuma anything about routing, by using XState for state management you have the option to let XState control your routing, through using xstate-router or xrouter...
  • Persistence: don't lose the app state when user clicks Refresh, and potentially sync app state across a user's multiple browser tabs. Or persist the state to the server for backup or to sync cross-platform. All possible since XState has excellent support for serializing the app state.
    • Ideally, (but with some caveats) always persist state to localStorage or sessionStorage immediately upon any state change (ideally even immediately after the user stops typing) (Never lose user input! is Rule 7 of The Golden Assumptions of Product Design). So that in case the user closes the tab or refreshes the page (reloading the SPA), then the state of the app can be immediately restored.

Let's see how we arrived here, through refactoring a basic XState example taken from: https://blog.openreplay.com/xstate-the-solution-to-all-your-app-state-problems

The original XState example:

Codesandbox original XState example: https://codesandbox.io/s/xstate-demo-ux4ts?file=/src/Users.jsx

Users.jsx

import { useMachine } from "@xstate/react";

import priceMachine from "./usersMachine";

export default function Users() {
  const [state, send] = useMachine(priceMachine, {
    services: {
      fetchUsers: () =>
        fetch("https://jsonplaceholder.typicode.com/users")
          .then((res) => res.json())
          .then((res) => Promise.resolve(res))
          .catch((err) =>
            Promise.reject({
              status: err.response.status,
              data: err.response.data
            })
          )
    }
  });

  const currentState = state.value;
  const users = currentState === "success" ? state.context.users : [];

  const handleButtonClick = () => {
    send("FETCH");
  };

  const stateUIMapping = {
    loading: <p>Loading</p>,
    success: users.map((user) => <div key={user.id}>{user.name}</div>),
    error: <p>An error occured</p>
  };

  const output = stateUIMapping[currentState];

  return (
    <div className="users">
      <button type="button" onClick={handleButtonClick}>
        Fetch users
      </button>

      {output}
    </div>
  );
}

usersMachine.js

import { Machine, assign } from "xstate";

const usersMachine = Machine({
  id: "users",
  initial: "idle",
  context: {
    users: null,
    error: null
  },
  states: {
    idle: {
      on: { FETCH: "loading" }
    },
    loading: {
      invoke: {
        src: "fetchUsers",
        onDone: {
          target: "success",
          actions: assign({
            users: (_, event) => event.data
          })
        },
        onError: {
          target: "error",
          actions: assign({
            error: (_, event) => event.error
          })
        }
      }
    },
    success: {
      on: { FETCH: "loading" }
    },
    error: {
      on: { FETCH: "loading" }
    }
  }
});

export default usersMachine;

MVC-widget - through refactoring boilerplate out from the XState example:

CodeSandbox refactored XState example as a terse MVC-widget: https://codesandbox.io/s/xstate-demo-forked-w1uc3t?file=/src/Users.jsx:214-250

Users.jsx - As a vertically sliced MVC-widget consistiing of: View + Model-Controller

This contains both the view and the interesting/relevant parts of the XState machine.

import { useMachine } from "@xstate/react";
import { Machine } from "xstate";
import {
  fetchSome,
  loadingAndErrorBoilerplate,
  idleAndSuccessAndErrorBoilerplate,
  initStateBoilerplate
} from "./boilerplate";

//  A vertically sliced "MVC-widget"

//  VIEW:

export default function Users() {
  const [state, send] = useMachine(usersMachine);
  return (
    <div className="users">
      <button type="button" onClick={() => send("FETCH")}>
        Fetch users
      </button>
      {
        {
          success: state.context.data?.map((user) => (
            <div key={user.id}>{user.name}</div>
          )),
          ...loadingAndErrorBoilerplate // boilerplate, ought not be needed here when nothing out of the ordinary/default is done...
        }[state.value] // the input here selects a key in the preceding object
      }
    </div>
  );
}

// MODEL-CONTROLLER:

const usersMachine = Machine({
  id: "users",
  ...initStateBoilerplate, // boilerplate, couldn't the initial state always, by convention, be the first one listed under `states` ? It seems to consistently be the case already...: https://xstate.js.org/docs/guides/hierarchical.html#api
  on: { FETCH: "loading" },
  states: {
    ...idleAndSuccessAndErrorBoilerplate, // boilerplate, could it not be initialized as empty objects by default? Specifying them ought not be needed here when nothing out of the ordinary/default is done...
    loading: fetchSome("users")
  }
});

boilerplate.js - What you should be able to ignore/forget.

The 'Various forms of boilerplate' here should ideally be provided by XState by default, or through a library that wraps XState. So you could be able to ignore/forget about it entirely. The 'Generic controller actions' represent helper functions for your own data handling/server fetching, which would be extracted out by you, based on what kind of fetching you are doing (since this basic fetch might not make sense if you are doing GraphQL). (If you are doing GraphQL, for example with Relay, then you could follow this example.)

import { assign } from "xstate";

// HELPERS - AKA. GENERIC CONTROLLER ACTIONS

export const fetchData = (data) =>
  fetch(`https://jsonplaceholder.typicode.com/${data}`)
    .then((res) => res.json())
    .then((res) => Promise.resolve(res))
    .catch((err) =>
      Promise.reject({
        status: err.response.status,
        data: err.response.data
      })
    );

// VARIOUS FORMS OF BOILERPLATE:

export const loadingAndErrorBoilerplate = {
  loading: <p>Loading</p>,
  error: <p>An error occured</p>
};

export const initStateBoilerplate = {
  initial: "idle"
};

export const idleAndSuccessAndErrorBoilerplate = {
  idle: {
    // on: { FETCH: "loading" } // not needed due to when not found the search will bubble up to same transition on main machine: https://stackoverflow.com/a/61410144/380607
  },
  success: {
    // on: { FETCH: "loading" }
  },
  error: {
    // on: { FETCH: "loading" }
  }
};

export const fetchSome = (data) => ({
  invoke: {
    src: () => fetchData(data),
    ...successAndErrorBoilerplate // boilerplate, ought not be needed here when nothing out of the ordinary/default is done...
  }
});

export const successAndErrorBoilerplate = {
  // onDone here ought to be called `actorDone`, see: https://github.com/statelyai/xstate/discussions/3574
  // boilerplate (no useful info for specific component):
  onDone: {
    target: "success",
    actions: assign({
      data: (_, event) => event.data
    })
  },
  // onError here ought to be called `actorError`
  // boilerplate (no useful info for specific component):
  onError: {
    target: "error",
    actions: assign({
      error: (_, event) => event.error
    })
  }
};

Common code:

The rest of the app shell, which is the same in both examples:

index.js

import Users from "./Users";

import "./styles.css";

export default function App() {
  return (
    <div className="App">
      <Users />
    </div>
  );
}

App.js

import { StrictMode } from "react";
import ReactDOM from "react-dom";

import App from "./App";

const rootElement = document.getElementById("root");
ReactDOM.render(
  <StrictMode>
    <App />
  </StrictMode>,
  rootElement
);

styles.css

.App {
  font-family: sans-serif;
  text-align: center;
}

package.json

{
  "name": "xstate-demo",
  "version": "1.0.0",
  "description": "",
  "keywords": [],
  "main": "src/index.js",
  "dependencies": {
    "@xstate/react": "1.3.3",
    "react": "17.0.2",
    "react-dom": "17.0.2",
    "react-scripts": "4.0.0",
    "xstate": "4.19.2"
  },
  "devDependencies": {
    "@babel/runtime": "7.13.8",
    "typescript": "4.1.3"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  },
  "browserslist": [
    ">0.2%",
    "not dead",
    "not ie <= 11",
    "not op_mini all"
  ]
}

Appendix

There's an opportunity to make the code in the component more chronological. Noting it here for future reference.

Users.jsx

Instead of:

      {
        {
          success: state.context.data?.map((user) => (
            <div key={user.id}>{user.name}</div>
          )),
          ...loadingAndErrorBoilerplate
        }[state.value]
      }

We could have the slightly more chronological / intuitive:

      {
        state.value === "success" ?
          state.context.data?.map((user) => (
              <div key={user.id}>{user.name}</div>
            ))
        : loadingOrError(state.value)
      }

boilerplate.js

export const loadingOrError = (stateValue) => {
  if (stateValue === "loading") return <p>Loading</p>;
  if (stateValue === "error") return <p>An error occured</p>;
};
@redbar0n
Copy link
Author

redbar0n commented Sep 8, 2022

@redbar0n
Copy link
Author

redbar0n commented Sep 10, 2022

What I envision, is an architecture where:

  1. User actions in the View triggers actions on corresponding XState machine.
  2. XState machine changes the route state.
  3. React will automatically re-render the View based on the new route state. See routing ideas on how to achieve that.

You don't need XState to directly drive the rendering in React (witth the likes of xstate-component-tree or xstate-first-react or similar). It suffices that XState changes the route state, which React picks up and uses to re-renders itself.

Rendering thus becomes a side-effect (like it always should have been...). Rendering ought to be the end-result, not the start of some other side-effects (I'm looking at you,useEffect).

@redbar0n
Copy link
Author

How would it work with animations? Well, the state machine of the component would have various animation states, like transitionOut, which it would go through, before finally modifying the route state.

@redbar0n
Copy link
Author

redbar0n commented Sep 28, 2022

"you don't actually need a separate isActive state on every single widget"

So where does the mouse state live? At the top, inside ui. Because you're only ever interacting with one widget at a time, you don't actually need a separate isActive state on every single widget, and no onMouseDown either. You only need a single, global activeWidget of type ID, and a single mouseButtons. At least without multi-touch. If a widget discovers a gesture is happening inside it, and no other widget is active, it makes itself the active one, until you're done. The global state tracks mouse focus, button state, key presses, the works, in one data structure. Widget IDs are generally derived from their position in the stack, in relation to their parent functions.

Every time the UI is re-rendered, any interaction since the last time is immediately interpreted and applied. With some imagination, you can make all the usual widgets work this way. You just have to think a bit differently about the problem, sharing state responsibly with every other actor in the entire UI.

https://acko.net/blog/model-view-catharsis/

This is where actor broadcasting could come in, just as it could broadcast to all UI widgets that would choose to render themselves or not based on the current route.

@redbar0n
Copy link
Author

redbar0n commented Nov 7, 2022

XState is allegedly not accessible outside of React (diagram), since it uses the React useMachine hook which instantiates a new machine:

There was a subtlety, and it might be fixed now, with XState, that the useMachine hook instantiated a machine on its own. So you can instantiate another version of the same machine and connect to it externally. But if you use useMachine you'll be using the instantiation of the machine that it creates. -- Jack Herrington, in response to my question on aforementioned youtube video

But xstate-tree seems to be accessible outside React...

@redbar0n
Copy link
Author

redbar0n commented Dec 6, 2022

Using XState with Immer seems like a go-to choice: mobxjs/mobx-state-tree#1149 (comment)

@redbar0n
Copy link
Author

redbar0n commented Dec 6, 2022

Now Baahu is really beautiful!

Blog post describing Baahu as a lovechild between React and XState: https://dev.to/tjkandala/baahu-the-state-machine-based-spa-framework-531i centered around the concern of treating UI as an afterthought.

I agree that rendering is what should be the side-effect, not data fetching!

Sadly, it replaces React, which makes is less ideal for cross-platform usages (React Native being React's biggest strength).

How to replicate something as clean as Baahu, in React?

@redbar0n
Copy link
Author

This is another MVC-widget approach:

Experimenting with a react MVC concept (don't do this) - 2.sept 2022

which doesn't use XState but plain JS to separate out a Model and a Controller, to achieve faster unit (component logic) testing, which is more ameanable with TDD. Without having to load React-DOM and the kitchen sink like React testing library does.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment