Iskander Samatov I’m a technical lead at HubSpot based in Dallas-Fort Worth Metropolitan Area. When I have time, I enjoy working on SaaS products and writing programming articles at https://isamatov.com.

How to make your design system DRY with Zag

6 min read 1742

Zag Logo Over Colorful Background

In this post, we will take a look at Zag, a JavaScript library that employs the state machine approach to represent common component state patterns.

Using Zag allows you to create a design system with declarative, DRY, and simple state management logic by outsourcing most of its complexity to the library.

Intro to Zag

There’s an abundance of out-of-the-box UI libraries out there. Yet we often can’t use them because of the unique design requirements we must follow.

And while our components’ design may be unique, the functionality often isn’t. When creating your component from the ground up, you’ll have to write your state logic, reinventing the wheel in the process.

Enter Zag, a library that takes care of the component state logic for you so you can focus on making your components look great and leave the inner workings of their state to the state machines.

Zag provides state machines for the most common UI components, such as Menu, Accordion, Dialog, etc. You can find a comprehensive list of all the available state machines in their docs.

If you’re building your design system from scratch or have a project with lots of components that contain overlapping, yet slightly different logic, using Zag can save you time and headache.

For example, the same state machine can be used for both vertical and horizontal menus. Doing so allows you to share common state logic between components, keeping your design system DRY.

Why use Zag?

Now let’s cover why Zag might be the state machine solution for you:

  • First of all, Zag is framework agnostic, so it works with React, Angular, Vue, or even vanilla JavaScript. Still, it does provide adapters for React, Solid, and Vue to make adoption easier if you happened to be using those frameworks
  • Zag is completely unopinionated about how you style your components. You can follow whatever processes and workflows you’re used to. This is in contrast to many other UI libraries that come with their styling solutions that you have to learn and adopt
  • You can introduce Zag to your project incrementally, adding state machines as you need them. That’s possible because each state machine is available as a separate NPM package
  • The library is built with accessibility in mind and handles accessibility concerns like keyboard interactions, focus management, and aria roles for you

What are state machines?

To better appreciate the benefits of Zag and properly use the library, we need to understand the concept of state machines.



A state machine, also called a finite state machine, is a mathematical model of computation. It’s an abstract machine that can be in one of a finite number of states. The machine is in only one state at a time and can change from one state to another when triggered by an input (called an event).

In the React community, the state machines have been popularized by XState. They are often used to represent the logic of common components with complex behavior.

State machines are a natural fit for UI components because they allow you to model the different states that a component can be in and the events that can trigger a state change.

Using out-of-the-box Zag state machines

Now that we covered Zag and state machines, let’s see how we can use them in our project.

Sample usage example

Let’s try using a state machine for menus, one of the most common UI components.

Here’s what the code for adding a state machine to a React component looks like:

import * as menu from "@zag-js/menu";
import { useMachine, useSetup } from "@zag-js/react";

export default function Menu({ onSelect }: { onSelect: (id: string) => void }) {
  const [state, send] = useMachine(
    menu.machine({ onSelect: (id) => onSelect(id) })
  );

  const ref = useSetup({ send, id: "1" });

  const api = menu.connect(state, send);

  return (
    <div ref={ref}>
      <button {...api.triggerProps}>
        Actions <span aria-hidden>▾</span>
      </button>
      <div {...api.positionerProps}>
        <ul {...api.contentProps}>
          <li {...api.getItemProps({ id: "records" })}>Records</li>
          <li {...api.getItemProps({ id: "duplicate" })}>Duplicate</li>
          <li {...api.getItemProps({ id: "settins" })}>Settings</li>
          <li {...api.getItemProps({ id: "export" })}>Export...</li>
        </ul>
      </div>
    </div>
  );
}

In the code above, we’re using the useMachine Hook to create a new instance of the state machine. The onSelect callback that we pass will be triggered when an item is selected and will receive its id.


More great articles from LogRocket:


Then, we call the useSetup hook with an object that contains the id of our menu and the send function from our state machine. useSetup ensures that the component works in different environments (iframes, Electron, etc.). The function returns a ref that we add to the root element of our component.

Note: id needs to be a unique identifier.

Finally, we call the menu.connect function with our state machine’s state and the send function. connect translates the machine’s state into JSX attributes and event handlers.

At this point, our state machine is ready to be used, and now we need to apply the JSX data stored in the api variable.

The api contains state logic for all of the inner components that make up a menu: trigger, positioner, content, and menu items. To apply the state machine logic to our HTML elements, we use the spread operator syntax.

And that’s all there’s to it. Now we have a functioning (although not the best looking) menu.

Actions, Records, Duplicate, Settings, Export

Styling

Zag is unopinionated about styling, and you have more control over how you want to style your components. You can use whatever CSS libraries you want or write your styles.

Each component usually has multiple parts that you can style separately. As we discussed before, the menu component has the following parts you can style: trigger, positioner, content, and menu items.

Zag automatically inserts the data-part attribute to your component’s parts that you can use to target them for styling.

For example, here’s what a simplified HTML output of our menu component would look like:

<!--HTML-->
<div>
  <button data-part="trigger" id="menu:1:trigger">
    Actions <span aria-hidden="true">▾</span>
  </button>
  <div data-part="positioner" id="menu:1:popper">
    <ul data-part="content" id="menu:1:content">
      <li data-part="item" id="records">
        Records
      </li>
      <li data-part="item" id="duplicate">
        Duplicate
      </li>
      <li data-part="item" id="settins">
        Settings
      </li>
      <li data-part="item" id="export">
        Export...
      </li>
    </ul>
  </div>
</div>

As you can see, each part has the data-part attribute that can be used in CSS selectors.

For example, if we want to change the color of our menu items, we can write the following CSS:

[data-part="item"] {
  color: blue;
}

When a component enters a certain state, Zag automatically adds an HTML attribute to represent the current state of the component using data-ATTRIBUTE_NAME, where ATTRIBUTE_NAME is an attribute that represents the current state in the state machine.

For example, if a menu item is in a disabled state, you can target it in your CSS styles using:

[data-part="item"][data-disabled] {
  /* styles go here */
}

And that covers the basics of styling Zag components.

Adding custom event handlers

Typically you would add your event handlers whenever creating an instance of your machine, as we saw with our earlier menu example:

const [state, send] = useMachine(
  menu.machine({ onSelect: (id) => onSelect(id) })
);

However, if you want to add custom event handlers to specific parts of the component, you can do that as well using the mergeProps utility function Zag provides:

const handleClick = () => {
    // do something here
}

const buttonProps = mergeProps(api.buttonProps, {
    onClick: handleClick,
})

Building your own state machine

Now and then, you might run into a case that’s not covered by state machines provided by Zag. In that case, you can build your own state machine from scratch using Zag’s createMachine function.

Defining your state machine model

Creating a state machine involves defining all the possible states and transitions for your component. For example, let’s say we want to create a machine that represents a simple on/off toggle button:

const machine = createMachine({
  // initial state
  initial: "active",
  // the finite states
  states: {
    active: {
      on: {
        CLICK: {
          // go to inactive
          target: "inactive"
        }
      }
    },
    inactive: {
      on: {
        CLICK: {
          // go to active
          target: "active"
        }
      }
    }
  }
});

As you can see, we first defined the initial state of our machine. Then, for each state, we specified which events should trigger a transition to another state.

In this case, we only have one event (CLICK) that can happen in both states and it will transition to the other state.

Creating connector function

Once we have our machine, we need to create a connector function that will take care of mapping the machine’s state to JSX props.

The connector function needs state and send arguments so it can access the machine’s current state and send events to the machine.

Here’s what the connector function for our on/off toggle button would look like:

function connect(state, send) {
  const active = state.matches("active");
  return {
    active,
    buttonProps: {
      type: "button",
      role: "switch",
      "aria-checked": active,
      onClick() {
        send("CLICK");
      }
    }
  };
}

Unfortunately, there’s no way to write one connect function that would work for all machines. You’ll have to create a connector function specific to each machine since the shape of the data in each machine’s state is different.

Connecting your components to your state machine

Finally, we need to instantiate our machine and use our connector to apply the machine’s state to our component:

import { useMachine } from "@zag-js/react";
import { machine, connect } from "./toggle";

function Toggle() {
  const [state, send] = useMachine(machine);
  const api = connect(state, send);

  return <button {...api.buttonProps}>{api.active ? "ON" : "OFF"}</button>;
}

And that’s all there is to it.

Conclusion

In conclusion, using state machines and Zag to build your design system has a lot of benefits. State machines make your code more declarative and predictable.

Zag provides out-of-the-box state machine solutions for common use cases, allowing you to concentrate on the design of your components while outsourcing the state management to the library.

: Debug JavaScript errors more easily by understanding the context

Debugging code is always a tedious task. But the more you understand your errors the easier it is to fix them.

LogRocket allows you to understand these errors in new and unique ways. Our frontend monitoring solution tracks user engagement with your JavaScript frontends to give you the ability to find out exactly what the user did that led to an error.

LogRocket records console logs, page load times, stacktraces, slow network requests/responses with headers + bodies, browser metadata, and custom logs. Understanding the impact of your JavaScript code will never be easier!

.
Iskander Samatov I’m a technical lead at HubSpot based in Dallas-Fort Worth Metropolitan Area. When I have time, I enjoy working on SaaS products and writing programming articles at https://isamatov.com.

Leave a Reply