Skip to content

Instantly share code, notes, and snippets.

@redbar0n
Last active April 20, 2024 13:59
Show Gist options
  • 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

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