🆒

Zag.js が良いかもしれない

2024/09/04に公開

Zag.js を調べた背景

私は Chakra UI ユーザーなのですが、Chakra UI はスタイリング部分でランタイム CSS-in-JS(= emotion)を使用しているため、レンダリング時のパフォーマンス影響を考えると、どこかで移行するタイミングがありそうだと思っています。もちろん、Chakra UI がランタイム CSS-in-JS を捨ててくれれば移行は必要ありませんが、Chakra UI のロードマップを見たところ、明確にスケジューリングされていません(未来のどこかで移行はされそう)。そのため、移行先を探していたのですが、Chakra UI の作成者のアデバヨさんが作った Zag.js はとりあえず見とかないといけないよな、という感じで見始めました。

Zag.js が作られた背景

Chakra UI が抱えている技術的課題は先述の通り、ランタイム CSS-in-JS が内部的に使用していることですが、この課題の解消は容易じゃないようです。Chakra UI はかなり大きなライブラリで、スタイルシステム・デザイントークン・状態管理など様々な要素を含んでおり、さらにこれらは密結合しています。Chakra UI はランタイム CSS-in-JS からの移行をするにあたって、Chakra UI を要素毎にバラけさせて、それぞれをプロジェクトとして独立させています。

例えば、スタイルシステムの部分は Panda です。

https://panda-css.com/

(ヘッドレス)コンポーネントの部分は Ark UI です。

https://ark-ui.com/

今回のメインテーマである状態管理の部分は zag.js です。

https://zagjs.com/

各プロジェクトは、将来的には Chakra UI に取り込まれる予定のようで、おそらく、状態を保つコンポーネントは Ark UI+Panda CSS に置き換えられ、状態を保たないコンポーネントは Panda CSS によってスタイリングの部分は置き換えられるのだと思います。ランタイム CSS-in-JS に関する課題は、これで解消する予定なのだと思いますが、Chakra UI は他にも課題を抱えているそうで、例えば、各フレームワークの対応は大変だったようです。

We've tried this in the past, and it's not a viable option as it leads to burnout and inconsistent DX.
https://www.adebayosegun.com/blog/the-future-of-chakra-ui

アデバヨさんのブログにも超多変(意訳)と綴られています。React や Vue 以外にも Chakra UI は対応しているので、各フレームワークに対応するコンポーネントを実装する必要がありますし、また、フレーワムワークが違っても同じ仕様で動作することを保証する必要もあります。そこでコンポーネントとして共通部分である状態管理を独立させて、フレームワークに依存しない状態管理ライブラリとして Zag.js が作られたようです。

Zag.js 詳細

Zag is a framework agnostic toolkit for implementing complex, interactive, and accessible UI components in your design system and web applications
https://zagjs.com/overview/introduction

公式ドキュメントには、複雑でインタラクティブでアクセシビリティに配慮があるコンポーネントを実装するためのライブラリと記載されています。個人的にはアクセシビリティという要素は、あまり気にしていないので(各方面から怒られそう...)、コンポーネントの状態管理の部分にフォーカスしたライブラリ、という認識をしています。

コンポーネントの状態管理ってなんやねん、という感じですが、例えば、トグルを実装する場合に、active な状態と inactive な状態と2つの状態を管理する必要があります。これを React で実装すると以下のようになると思います。

import React, { useState } from 'react';

const Toggle = () => {
  const [isOn, setIsOn] = useState(false);

  const handleToggle = () => {
    setIsOn(!isOn);
  };

  return (
    <>
      <button
        onClick={handleToggle}
        className={`w-12 h-6 rounded-full p-1 transition-colors duration-300 ease-in-out ${
          isOn ? 'bg-blue-600' : 'bg-gray-300'
        }`}
      >
        <div
          className={`w-4 h-4 rounded-full bg-white transform transition-transform duration-300 ease-in-out ${
            isOn ? 'translate-x-6' : 'translate-x-0'
          }`}
        />
      </button>
      <span>{isOn ? 'ON' : 'OFF'}</span>
    </>
  );
};

export default Toggle;

useState で active な状態(=ON)と inactive(=OFF)な状態を管理し、状態はボタン要素の onClick イベントによって遷移します。これがコンポーネントの状態管理です。上記の実装は React のステート管理機能を使っているので、フレームワークに依存している実装です。Zag.js はこの部分をフレームワーク非依存で実装しています。

私が Zag.js を気に入っている理由

Zag.js は状態管理をする上で、状態のモデル化というアプローチをとっており、個人的にはかなりしっくり来ており、イカしてるなぁと思います(これが気に入っている理由)。Zag.js はコンポーネントを作成する上で以下を定義します。

  • 状態の数
  • 状態間の遷移の数

これらを定義したら、あとは各状態の初期状態や状態間の遷移時の具体的な実装を進めるのみになります。

Zag.js を使ったトグルの実装

トグルが持つ状態は以下になります。

  • 状態の数 : active か inactive かの2択
  • 状態間の遷移の数 : active から inactive、inactive から active への2つ

実装は以下のようになり、状態が2つ(active と inactive)、クリックイベントによって状態が遷移すること、そして、状態が active の場合は inactive に状態が遷移することなどが表現されています。

states: {
  active: {
    on: {
      CLICK: {
        target: "inactive",
        actions: ["setInactiveState"],
      },
    }
  },
  inactive: {
    on: {
      CLICK: {
        target: "active",
        actions: ["setActiveState"],
      },
    }
  },
},

状態の遷移でどのようにデータを更新するか、は別途で以下のように実装します。今回はトグルなので真偽値をひっくり返すだけです。

{
  actions: {
    setActiveState: (context) => {
      context.value = true;
    },
    setInactiveState: (context) => {
      context.value = false;
    }
  }
}

定義した状態を useMachine という関数に渡して html 要素のアクションと紐付けて、状態の変更をできるようにします。トグルなのでボタン要素の onClick イベント経由で状態の変更をできるようにしています。スタイリング部分は Tailwind CSS で実装しています。

export default function Page() {
  const [state, send] = useMachine(machine);

  return (
    <main>
      <button
        onClick={() => {
          send("CLICK");
        }}
        className={`
          px-4 py-2 rounded-full font-semibold text-sm
          transition-all duration-200 ease-in-out
          focus:outline-none focus:ring-2 focus:ring-offset-2
          ${state.context.value
            ? "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500"
            : "bg-gray-200 text-gray-700 hover:bg-gray-300 focus:ring-gray-400"
          }
        `}
      >
        {state.context.value ? "Active" : "Inactive"}
      </button>
    </main>
  );
}

実装したトグルの見た目は以下です。

(参考:トグル ON)

(参考:トグル OFF)

フレームワーク依存の箇所が少ない

Zag.js は react や vue などのフレームワークに依存する箇所をかなり限定しています。

状態管理のコアの部分はクラスによって実装されており、フレームワーク非依存です。
https://github.com/chakra-ui/zag/blob/656c66a65f7b79b042c87532d1d4556abbc1758c/packages/core/vanilla/src/machine.ts#L30

状態の保存はグローバルで実装されており、フレームワーク非依存です。
https://github.com/chakra-ui/zag/blob/c8aeca475b078806c2765659668d843037746ba6/packages/store/src/proxy.ts#L50

グローバルの状態を Proxy オブジェクトで監視しており、変更があったタイミングで再レンダリングするようにフックしており、ここでやっとフレームワーク依存部分が出てきます。
https://github.com/chakra-ui/zag/blob/3a53a1e97306a9fedf1706b95f8e38b03750c2f3/packages/frameworks/react/src/use-snapshot.ts#L43

実装ベースで追っても Zag.js にはフレームワーク依存の実装はかなり薄いです。

Ark UI も良い

Ark UI はヘッドレス UI で内部的に Zag.js を使用しています。

(参考:Ark UI)
https://ark-ui.com/

今回は Zag.js のことについて書いていますが、元々は Ark UI について記事を書くつもりで、色々とコンポーネントを Ark UI で実装していたりしました。ほとんどのユーザーは、Ark UI を通して、Zag.js を使用することになると思いますし、今のところ、私もそうです。興味本位でちょっくら Zag.js をのぞいたところ心に刺さったので、Zag.js について調べていた感じです。

まとめ

Zag.js のイカした実装を見ると、バグが少なそう、フレームワークの対応数はすぐに増えそう、などなど思いを馳せれる気がします。もう少し Zag.js(Ark UI)は触ってみようと思っています。以上です。

Discussion