Skip to content
/ driver Public

🏁 A typescript package for declarative finite states and commonly derived values - zero dependencies, fully typed, & framework agnostic

License

Notifications You must be signed in to change notification settings

switz/driver

Repository files navigation

🏁 driver

Build npm (scoped) npm bundle size (scoped)

driver is a tiny typescript utility for organizing external data into finite states and deriving common values.

Jump to sample code or the docs. Get help & support in the Discord.

✨ Features

  • Tiny with zero dependencies (<500B gzipped + minified)
  • Framework agnostic (works with react, svelte, vue, node, deno, bun, cloudflare workers, etc.)
  • Fully typed
  • Declarative API
  • Readable source code (~60 lines including comments)

📦 Installation

$ npm i @switz/driver

🍬 Sample Code

This example is React, but driver is library agnostic.

import driver from '@switz/driver';

const CheckoutButton = ({ items, isLoading, checkout }) => {
  const shoppingCart = driver({
    // the first truthy state is the active state
    states: {
      isLoading,
      isCartEmpty: items.length === 0,
      isCartValid: true, // fallback/default
    },
    derived: {
      // isDisabled resolves to a boolean if the state matches
      isDisabled: ['isLoading', 'isCartEmpty'],
      // intent resolves to the value of the active state (a string here)
      popover: {
        isCartEmpty: 'Your cart is empty, please add items',
      },
      intent: {
        isLoading: 'none',
        isCartEmpty: 'error',
        isCartValid: 'success',
      }
    },
  });

  return (
    <Popover content={shoppingCart.popover}>
      <Button
        disabled={shoppingCart.isDisabled}
        intent={shoppingCart.intent}
        onClick={checkout}
      >
        Checkout
      </Button>
    </Popover>
  );
}

And we can represent our logic and ui as a truth table:

isDisabled intent popover
isLoading true none
isCartEmpty true error "Your cart..."
isCartValid false success

👩‍🏭 Basic Introduction

Each driver works by defining finite states. Only one state can be active at any given time. The first state to resolve to true is active.

Let's look at some examples. I'm going to use React, but you don't have to.

We define the possible states in the states object. The first state value to be true is the active state (these are akin to if/else statements).

import driver from '@switz/driver';

const CheckoutButton = ({ cartData }) => {
  const button = driver({
    states: {
      isEmpty: cartData.items.length === 0,
      canCheckout: cartData.items.length > 0,
    },
    derived: {
      // if the active state matches any strings in the array, `isDisabled` returns true
      isDisabled: ['isEmpty'],
    },
  });

  return (
    <Button icon="checkout" disabled={button.isDisabled} onClick={onClick}>
      Checkout
    </Button>
  );
}

Since driver gives us some guardrails to our stateful logic, they can be reflected as state tables:

States isDisabled
isEmpty true
canCheckout false

Here we have two possible states: isEmpty or canCheckout and one derived value from each state: isDisabled.

Now you're probably thinking – this is over-engineering! We only have two states, why not just do this:

const CheckoutButton = ({ cartItems }) => {
  const isEmpty = cartItems.length === 0;

  return (
    <Button icon="checkout" disabled={isEmpty} onClick={onClick}>
      Checkout
    </Button>
  );
}

And in many ways you'd be right. But as your logic and code grows, you'll very quickly end up going from a single boolean flag to a mishmash of many. What happens when we add a third, or fourth state, and more derived values? What happens when we nest states? You can quickly go from 2 possible states to perhaps 12, 24, or many many more even in the simplest of components.

Here's a more complex example with 4 states and 3 derived values. Can you see how giving our state some rigidity could reduce logic bugs?

const CheckoutButton = ({ cartItems, isLoading, checkout }) => {
  const cartValidation = validation(cartItems);
  const shoppingCart = driver({
    states: {
      isLoading,
      isCartEmpty: cartItems.length === 0,
      isCartInvalid: !!cartValidation.isError,
      isCartValid: true, // fallback/default
    },
    derived: {
      popoverText: {
        // unspecified states (isLoading, isCartValid here) default to undefined
        isCartEmpty: 'Your shopping cart is empty, add items to checkout',
        isCartInvalid: 'Your shopping cart has errors: ' + cartValidation.errorText,
      },
      buttonVariant: {
        isLoading: 'info',
        isCartEmpty: 'info',
        isCartInvalid: 'error',
        isCartValid: 'primary',
      },
      // onClick will be undefined except `ifCartValid` is true
      // <button onClick handlers accept undefined so that's okay!
      onClick: {
        isCartValid: checkout,
      }
    },
  });

  return (
    <Popover content={shoppingCart.popoverText} disabled={!shoppingCart.popoverText}>
      <Button icon="checkout" intent={shoppingCart.buttonVariant} disabled={!shoppingCart.onClick} onClick={shoppingCart.onClick}>
        Checkout
      </Button>
    </Popover>
  );
}

What does this state table look like?

States popoverText buttonVariant onClick
isLoading info
isCartEmpty "Your shopping cart is empty..." info
isCartInvalid "Your shopping cart has errors..." error
isCartValid primary () => checkout

Putting it in table form displays the rigidity of the logic that we're designing.

🖼️ Background

After working with state machines, I realized the benefits of giving your state rigidity. I noticed that I was tracking UI states via a plethora of boolean values, often intermixing const/let declarations with inline ternary logic. This is often inevitable when working with stateful UI libraries like react.

Even though state machines are very useful, I also realized that my UI state is largely derived from boolean logic (via API data or React state) and not from a state machine I want to build and manually transition myself. So let's take out the machine part and just reflect common stateful values.

For example, a particular button component may have several states, but will always need to know:

  1. is the button disabled/does it have an onClick handler?
  2. what is the button text?
  3. what is the button's style/variant/intent, depending on if its valid or not?

and other common values like

  1. what is the popover/warning text if the button is disabled?

By segmenting our UIs into explicit states, we can design and extend our UIs in a more pragmatic and extensible way. Logic is easier to reason about, organize, and test – and we can extend that logic without manipulating inline ternary expressions or fighting long lists of complex boolean logic.

Maybe you have written (or had to modify), code that looks like this:

const CheckoutButton = ({ cartItems, isLoading }) => {
  const cartValidation = validation(cartItems);

  let popoverText = 'Your shopping cart is empty, add items to checkout';
  let buttonVariant = 'info';
  let isDisabled = true;

  if (cartValidation.isError) {
    popoverText = 'Your shopping cart has errors: ' + cartValidation.errorText;
    buttonVariant = 'error';
  }
  else if (cartValidation.hasItems) {
    popoverText = null;
    isDisabled = false;
    buttonVariant = 'primary';
  }

  return (
    <Popover content={popoverText} disabled={!popoverText}>
      <Button icon="checkout" intent={buttonVariant} disabled={isLoading || isDisabled} onClick={checkout}>
        Checkout
      </Button>
    </Popover>
  );
}

Touching this code is a mess, keeping track of the state tree is hard, and interleaving state values, boolean logic, and so on is cumbersome. You could write this a million different ways.

Not to mention the implicit initial state that the default values imply the cart is empty. This state is essentially hidden to anyone reading the code. You could write this better – but you could also write it even worse. By using driver, your states are much more clearly delineated.

Other examples:

Every driver contains a single active state. The first key in states to be true is the active state.

const DownloadButton = ({ match }) => {
  const demoButton = driver({
    states: {
      isNotRecorded: !!match.config.dontRecord,
      isUploading: !match.demo_uploaded,
      isUploaded: !!match.demo_uploaded,
    },
    derived: {
      isDisabled: ['isNotRecorded', 'isUploading'],
      // could also write this as:
      // isDisabled: (states) => states.isNotRecorded || states.isUploading,
      text: {
        isNotRecorded: 'Demo Disabled',
        isUploading: 'Demo Uploading...',
        isUploaded: 'Download Demo',
      },
    },
  });

  return (
    <Button icon="download" disabled={!!demoButton.isDisabled}>
      {demoButton.text}
    </Button>
  );
}

The derived data is pulled from the state keys. You can pass a function (and return any value), an array to mark boolean derived flags, or you can pass an object with the state keys, and whatever the current state key is will return that value.

isDisabled is true if any of the specified state keys are active, whereas text returns whichever string corresponds directly to the currently active state value.

Now instead of tossing ternary statements and if else and tracking messy declarations, all of your ui state can be derived through a simpler and concise state-machine inspired pattern.

The goal here is not to have zero logic inside of your actual view, but to make it easier and more maintainable to design and build your view logic in some more complex situations.

👾 Docs

The driver function takes an object parameter with two keys: states and derived.

driver({
  states: {
    state1: false,
    state2: true,
  },
  derived: {
    text: {
      state1: 'State 1!',
      state2: 'State 2!',
    }
  }
})

states is an object whose keys are the potential state values. Passing dynamic boolean values into these keys dictates which state key is currently active. The first key with a truthy value is the active state.

derived is an object whose keys derive their values from what the current state key is. There are three interfaces for the derived object.

States

driver({
  states: {
    isNotRecorded: match.config.dontRecord,
    isUploading: !match.demo_uploaded,
    isUploaded: match.demo_uploaded,
  },
});

Derived

Function

You can return any value you'd like out of the function using the state keys

driver({
  states: {
    isNotRecorded: match.config.dontRecord,
    isUploading: !match.demo_uploaded,
    isUploaded: match.demo_uploaded,
  },
+  derived: {
+    isDisabled: (states) => states.isNotRecorded || states.isUploading,
+  }
})

or you can access generated enums for more flexible logic

driver({
  states: {
    isNotRecorded: match.config.dontRecord,
    isUploading: !match.demo_uploaded,
    isUploaded: match.demo_uploaded,
  },
  derived: {
+   isDisabled: (_, enums, activeEnum) => (activeEnum ?? 0) <= enums.isUploading,
  }
})

This declares that any state key above isUploaded means the button is disabled (in this case, isNotRecorded and isUploading). This is useful for when you have delinated states and you want to more dynamically define where those lines are.

Array

By using an array, you can specify a boolean if any item in the array matches the current state:

driver({
  states: {
    isNotRecorded: match.config.dontRecord,
    isUploading: !match.demo_uploaded,
    isUploaded: match.demo_uploaded,
  },
  derived: {
+   isDisabled: ['isNotRecorded', 'isUploading'],
  }
})

This returns true if the active state is: isNotRecorded or isUploading.

This is the same as writing: (states) => states.isNotRecorded || states.isUploading in the function API above.

Object Lookup

If you want to have an independent value per active state, an object map is the easiest way. Each state key returns its value if it is the active state. For Example:

driver({
  states: {
    isNotRecorded: match.config.dontRecord,
    isUploading: !match.demo_uploaded,
    isUploaded: match.demo_uploaded,
  },
  derived: {
+   text: {
+     isNotRecorded: 'Demo Disabled',
+     isUploading: 'Demo Uploading...',
+     isUploaded: 'Download Demo',
+   },
  }
})

If the current state is isNotRecorded then the text key will return 'Demo Disabled'.

isUploading will return 'Demo Uploading...', and isUploaded will return 'Download Demo'.

Svelte Example

This is a button with unique text that stops working at 10 clicks. Just prepend the driver call with $: to mark it as reactive.

<script>
    import driver from "@switz/driver";
    let count = 0;

    function handleClick() {
      count += 1;
    }

    // use $ to mark our driver as reactive
    $: buttonInfo = driver({
      states: {
        IS_ZERO: count === 0,
        IS_TEN: count >= 10,
        IS_MORE: count >= 0
      },
      derived: {
        text: {
          IS_ZERO: "Click me to get started",
          IS_MORE: `Clicked ${count} ${count === 1 ? "time" : "times"}`,
          IS_TEN: "DONE!"
        },
        isDisabled: ["IS_TEN"]
      }
    });
</script>

<button on:click={handleClick} disabled={buttonInfo.isDisabled}>
  {buttonInfo.text}
</button>

Key Ordering Consistency

My big concern here was abusing the ordering of object key ordering. Since the order of your `states object matters, I was worried that javascript may not respect key ordering.

According to: https://stackoverflow.com/questions/5525795/does-javascript-guarantee-object-property-order/38218582#38218582

Property order in normal Objects is a complex subject in JavaScript.

While in ES5 explicitly no order has been specified, ES2015 defined an order in certain cases, and successive changes to the specification since have increasingly defined the order (even, as of ES2020, the for-in loop's order).

This results in the following order (in certain cases):

Object {
 0: 0,
 1: "1",
 2: "2",
 b: "b",
 a: "a",
 m: function() {},
 Symbol(): "sym"
}

The order for "own" (non-inherited) properties is:

Positive integer-like keys in ascending order String keys in insertion order Symbols in insertion order

https://tc39.es/ecma262/#sec-ordinaryownpropertykeys

Due to this, we force you to define your states keys as strings and only strings. This should prevent breaking the ordering of your state keys in modern javascript environments.

If you feel this is wrong, please open an issue and show me how we can improve it.

Help and Support

Join the Discord for help: https://discord.gg/dAKQQEDg9W

Warning: this is naive and changing

This is still pretty early, the API surface may change. Code you write with this pattern may end up being less efficient than before, with the hope that it reduces your logic bugs. This code is not lazy, so you may end up evaluating far more than you need for a given component. In my experience, you should not reach for a driver immediately, but as you see it fitting in, use it where it is handy. The leafier the component (meaning further down the tree, closer to the bottom), the more useful I've found it.

Typescript

This library is fully typed end-to-end. That said, this is the first time I've typed a library of this kind and it could definitely be improved. If you run into an issue, please raise it or submit a PR.

Local Development

To install dependencies:

bun install

To test:

npm run test # we test the typescript types on top of basic unit tests

About

🏁 A typescript package for declarative finite states and commonly derived values - zero dependencies, fully typed, & framework agnostic

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published