npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

florence-state-machine

v0.2.2

Published

<!-- Improved compatibility of back to top link: See: https://github.com/othneildrew/Best-README-Template/pull/73 -->

Downloads

1

Readme

About The Project

This library was designed to be a sweet spot between sophisticated, and sometimes even overwhelming solutions such as XState and a often too simplistic ones such as React's useReducer.

Florence state machine is not a global state manager, but a lightweight tool to handle complex UI logic in a type-safe and declarative style.

Installation

  • npm
npm i florence-state-machine
  • yarn
yarn add florence-state-machine
  • pnpm
pnpm add florence-state-machine

Basic Usage

Let's say that we want to implement a login screen, where user can login with an username.

Events

We start with defining all events (or "actions") that can happen in our "system":

export type Event =
  | { type: "inputChange"; payload: string }
  | { type: "loginRequest" }
  | { type: "logout" }
  | { type: "loginSuccess" }
  | { type: "loginError"; payload: { message: string } };

This union type contains information about everything that can happen at some point. The first three events come from the user and the last two will come from the auth server. However, it doesn't matter from where the events come to our state machine, so we don't include any information about it.

States

Next, we'll define all possible states in which our system can be in. When defining a state machine for UI this step is oftentimes surprisingly easy, since all states usually differ from each other visually, so it's not abstract. In our case:

export type State =
  | { name: "idle" }
  | { name: "loading" }
  | { name: "error"; message: string }
  | { name: "success" };

We are missing the info about the state of the current value of the user input though. We could put it into the idle state, however that would introduce at least two problems:

  1. We would probably have to put it into idle, loading and error states and handle passing it between them, so that the input doesn't reset its value between state changes.
  2. We would change state on every keystroke and that's not really semantically intuitive, since the state is editing email form (or idle) the whole time. But something changes, so what is it? The answer is context.

Context

In florence-state-machine context is a mechanism that allows us to share some mutable data between all states:

export type Context = {
  username: string;
};

Effects

Effects are async functions that return an event. Their signature is () => Promise<Event>. They are used to describe any async operations that can happen in our system. In our case we will need to make a request to the auth server, so we will define an effect for that:

export const loginEffect = async (username: string): Promise<Event> => {
  try {
    await login(username);
    return { type: "loginSuccess" };
  } catch (error) {
    return { type: "loginError", payload: { message: error.message } };
  }
};

where login is some function that makes a request to the auth server.

Transitions

Now, having defined all of the little pieces above, let's define how they all relate to each other. We'll do that by defining a "spicy" version of a reducer function. A regular reducer (eg. used by useReducer hook or in Redux) is a function that takes a state and an event and returns a new state. Its signature is (state: State, event: Event) => State. A reducer in florence-state-machine is slightly more powerful, its signature is

(
  state: State & { ctx: Context },
  event: Event
) =>
  | State
  | (State & { ctx: Context })
  | [State, Effect<Event>]
  | [State & { ctx: Context }, Effect<Event>];

It takes a state (but with context!), an event and returns either

  • a new state (this case is the same as in a regular reducer)
  • a new state with updated context
  • A tuple with a new state and a "declaration" of an effect that should be executed.
  • The same as above, but with updated context.

Let's write a reducer for our login screen:

import type { Reducer } from "florence-state-machine";

export const reducer: Reducer<State, Event, Context> = (state, event) => {
  switch (state.name) {
    case "idle": {
      switch (event.type) {
        case "inputChange":
          return {
            name: "idle",
            ctx: {
              username: event.payload,
            },
          };
        case "loginRequest":
          return [
            {
              name: "loading",
            },
            () => requestLogin(state.ctx.username),
          ];
        default:
          return state;
      }
    }
    case "loading": {
      switch (event.type) {
        case "loginSuccess":
          return {
            name: "success",
            ctx: {
              username: "",
            },
          };
        case "loginError":
          return {
            name: "error",
            message: event.payload.message,
          };
        default:
          return state;
      }
    }
    case "error": {
      switch (event.type) {
        case "inputChange": {
          return {
            name: "idle",
            ctx: {
              username: event.payload,
            },
          };
        }
        default:
          return state;
      }
    }
    case "success": {
      switch (event.type) {
        case "logout":
          return { name: "idle" };
        default:
          return state;
      }
    }
    default:
      return state;
  }
};

Three things to notice here:

First, by typing the reducer with a Reducer type from florence-state-machine we get type-safety and a nice autocomplete throughout writing this function.

Second, in loginRequest we use our special syntax of returning a tuple. Its first element is a state that the machine should transition immediately to, and the second element is an effect that should be executed.

Third, and most importantly, notice how first thing we do is switch on the state and then for each state we switch on the event. This is one of the biggest mind-shifts when coming from Redux, where a state is usually a single object instead of an union, but it's the key of keeping the logic of your system readable and less error-prone.

Usage with ts-pattern

Writing the reducer using switch statements is not bad as we get type safety and autocomplete, but it is a lot of boilerplate, especially because of the need of having multiple default: return state statements and duplicated logic when some event needs to be handled in the same way in multiple cases.

That is why we recommend using ts-pattern library to write reducers. It allows us to write the same reducer in a much more concise way that is also easier to read and maintain:

const reducerWithTsPattern: Reducer<State, Event, Context> = (state, event) =>
  // with ts-pattern we can match simultaneously on state.name and event.type!
  match<[State["name"], Event], ReturnType<Reducer<State, Event, Context>>>([
    state.name,
    event,
  ])
    .with(
      // in both idle and error state, we want to handle inputChange in the same way
      [P.union("idle", "error"), { type: "inputChange", payload: P.select() }],
      (username) => ({
        name: "idle",
        ctx: {
          username: username,
        },
      })
    )
    // only in idle state we want to handle loginRequest
    .with(["idle", { type: "loginRequest" }], () => [
      {
        name: "loading",
      },
      () => requestLogin(state.ctx.username),
    ])
    // below, two possible events that can happen in loading state
    .with(["loading", { type: "loginSuccess" }], () => ({
      name: "success",
      ctx: {
        username: "",
      },
    }))
    .with(
      ["loading", { type: "loginError", payload: P.select() }],
      (error) => ({
        name: "error",
        message: error.message,
      })
    )
    // nice and simple case for logout event -> only possible in success state
    .with(["success", { type: "logout" }], () => ({ name: "idle" }))
    // a single, global "default" case for all other combinations of states and events that we don't care about!
    .otherwise(() => state);

Using the machine

First of all, notice how we described our whole system in this nice, readable way without even using this library! That was one of the design goals of florence-state-machine: You don't have to learn any new syntax to use it, it's just TypeScript.

So what exactly does this library do? You can think of it as an execution engine for your events. In case of simple events it just updates the state based on your reducer, however in case of effects it will execute them and, if the state didn't change in the meantime, it will send the outputted event to the machine.

Now, let's use it in a React component:

import { useMachine } from "florence-state-machine";

function LoginPage() {
  const { state, send, matches } = useMachine(reducer, { name: "idle" });

  if (state.name === "success") {
    return (
      <div>
        <h1>Login Success</h1>
        <button onClick={() => send({ type: "logout" })}>logout</button>
      </div>
    );
  }

  return (
    <div>
      <h1>Login page</h1>
      <div
        style={{
          display: "flex",
          flexDirection: "column",
          maxWidth: "320px",
          gap: "16px",
        }}
      >
        <input
          type="text"
          disabled={state.name === "loading"}
          onChange={(e) =>
            send({ type: "inputChange", payload: e.target.value })
          }
        />
        <button
          disabled={state.name === "error" || state.name === "loading"}
          onClick={() => send({ type: "loginRequest" })}
        >
          {matches({
            idle: () => "login",
            error: () => "login",
            loading: () => "loading...",
          })}
        </button>
        {matches({
          error: ({ message }) => <p style={{ color: "red" }}>{message}</p>,
        })}
      </div>
    </div>
  );
}

These are the basics of florence-state-machine. You can find more examples in the examples directory.

Contributing

If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". Don't forget to give the project a star! Thanks again!

  1. Fork the Project
  2. Create your Feature Branch (git checkout -b feature/AmazingFeature)
  3. Commit your Changes (git commit -m 'Add some AmazingFeature')
  4. Push to the Branch (git push origin feature/AmazingFeature)
  5. Open a Pull Request