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.
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.
Now let’s cover why Zag might be the state machine solution for you:
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.
Now that we covered Zag and state machines, let’s see how we can use them in our project.
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
.
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.
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.
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, })
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.
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.
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.
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.
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.
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 see exactly what the user did that led to an error.
LogRocket records console logs, page load times, stack traces, slow network requests/responses with headers + bodies, browser metadata, and custom logs. Understanding the impact of your JavaScript code will never be easier!
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowCompare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.