ステートマシンによる予測可能で堅牢なUIコンポーネントの構築
Olivia Novak
Dev Intern · Leapcell

はじめに
進化し続けるフロントエンド開発の状況において、視覚的に魅力的であるだけでなく、予測可能で、堅牢で、保守しやすいユーザーインターフェースを構築することは、絶え間ない課題です。アプリケーションが複雑になるにつれて、個々のUIコンポーネント内のインタラクションも同様に複雑になります。一般的なドロップダウンメニューを考えてみてください。開いている、閉じている、フォーカスされている、フォーカスされていない、無効になっている、または読み込み状態にある可能性があります。これらの状態間の遷移、それらをトリガーするアクション、およびそれらが持つ可能性のある副作用を考慮してください。構造化されたアプローチなしに、この状態と遷移の複雑なダンスを管理することは、すぐに条件付きロジックの絡み合ったウェブにつながり、デバッグは悪夢となり、将来の拡張はギャンブルになります。まさにここで、ステートマシンとステートチャートの力が発揮されます。UIコンポーネントの動作を形式化することで、比類のない予測可能性と堅牢性を達成できます。この記事では、XStateやZag.jsのようなライブラリがこれらの概念を活用して、ドロップダウンやモーダルなどの複雑なUIコンポーネントを構築する開発者を支援し、混沌としたスパゲッティコードをエレガントでテスト可能な状態駆動ロジックに変える方法を掘り下げます。
ステートマシン駆動UIのコアコンセプト
実際に応用する前に、XStateとZag.jsの基盤となるコアコンセプトの基本的な理解を確立しましょう。
ステートマシンとステートチャート
ステートマシンは計算の数学的モデルです。これは、任意の時点で有限個の状態のうちちょうど1つにいることができる抽象マシンです。マシンは、イベントまたはアクションによってトリガーされると、ある状態から別の状態に変化できます。これは遷移と呼ばれます。
ステートチャートは、特に複雑なシステムを扱う際のステートマシンの限界に対処するために、ステートマシンの拡張です。ステートチャートの主な機能は次のとおりです。
- 階層(ネストされた状態): 状態は他の状態を含むことができ、より整理されたモジュラーな状態定義を可能にします。たとえば、ドロップダウンの「開」状態には、「アイテムにフォーカス」、「アイテムからフォーカス解除」のサブ状態が含まれる場合があります。
- 直交領域(並行状態): 状態は複数のサブ状態に同時にいることができ、動作の独立した側面を表します。単純なUIコンポーネントではあまり一般的ではありませんが、より高度なシナリオでは強力です。
- 履歴状態: 親状態に再入力する際に、最後のアクティブなサブ状態を記憶します。
- ガード: 遷移が発生するために満たされなければならない条件です。
- アクション/エフェクト: 状態に入るとき、または状態から出るとき、あるいは遷移が発生するときに実行される操作です。
XState
XStateは、ステートマシンとステートチャートを作成、解釈、実行するためのJavaScriptライブラリです。これは、非常に予測可能でテスト可能な方法で複雑な状態ロジックを定義するための堅牢で開発者に優しいAPIを提供します。XStateのコア哲学は、アプリケーションロジックを有限ステートマシンとして扱い、暗黙的な動作を明示することです。
Zag.js
Zag.jsは、XStateによって駆動されるステートマシンから完全に構築された、フレームワークに依存しないUIコンポーネントのコレクションです。これは「ヘッドレスUI」コンポーネントを提供します。つまり、すべてのインタラクションロジック、状態管理、およびアクセシビリティ属性を処理しますが、UI要素の実際のレンダリングは完全に開発者に任せます。これにより、任意のフロントエンドフレームワーク(React、Vue、Svelteなど)でのスタイリングと統合において最大限の柔軟性が得られます。Zag.jsは、一般的なUIパターンのための事前構築された堅牢なステートチャートのコレクションとして効果的に機能します。
XStateとZag.jsで予測可能なUIを構築する
UIコンポーネントにXStateまたはZag.jsを使用する本質は、コンポーネントのライフサイクルとインタラクションを正式なステートチャートとして定義することにあります。例を見てみましょう。
従来のUIコンポーネントロジックの問題点
単純なモーダルコンポーネントを考えてみましょう。その動作には以下が含まれる場合があります。
- 開いているか閉じているか。
- ボタンをクリックすると開く。
- Escapeキーを押すと閉じる。
- モーダルコンテンツの外側をクリックすると閉じる。
- 開いているときにモーダル内でフォーカスをトラップする。
- 閉じたときに、それを開いた要素にフォーカスを戻す。
- アニメーション状態を持つ可能性がある。
ステートマシンなしでは、これはしばしば次のように翻訳されます。
// 架空のReactコンポーネント(簡略化) function Modal() { const [isOpen, setIsOpen] = useState(false); const triggerRef = useRef(null); // モーダルを開いた要素を保存するため useEffect(() => { const handleKeyDown = (event) => { if (isOpen && event.key === 'Escape') { setIsOpen(false); } }; const handleClickOutside = (event) => { // クリックがモーダルコンテンツの外側かどうかを確認する複雑なロジック if (isOpen && !modalContentRef.current.contains(event.target)) { setIsOpen(false); } }; document.addEventListener('keydown', handleKeyDown); document.addEventListener('mousedown', handleClickOutside); // または click return () => { document.removeEventListener('keydown', handleKeyDown); document.removeEventListener('mousedown', handleClickOutside); }; }, [isOpen]); // フォーカス管理、ARIA属性など、さらに複雑になるでしょう。 // ...コンポーネントロジックとJSXの残りの部分 }
このアプローチは扱いにくくなります。useEffect
フックは肥大化し、さまざまなロジック部分が散在し、すべての可能な状態と遷移を理解するのは困難です。
XState:モーダル動作の形式化
XStateを使用してモーダルのステートチャートを定義してみましょう。
import { createMachine, assign } from 'xstate'; const modalMachine = createMachine({ id: 'modal', initial: 'closed', context: { // フォーカス復元のためにモーダルをトリガーした要素を格納します triggerElement: null, }, states: { closed: { on: { OPEN: { target: 'opening', actions: assign({ triggerElement: (context, event) => event.trigger, }), }, }, }, opening: { // アニメーション遅延または非同期操作をシミュレートします after: { 200: { target: 'open', actions: 'focusModalContent', // フォーカスをトラップするロジックを実行します }, }, on: { CLOSE: 'closing', // アニメーション中に直接closingに遷移できます }, }, open: { entry: 'trapFocus', // フォーカスをトラップすることを保証します on: { CLOSE: 'closing', ESCAPE: 'closing', CLICK_OUTSIDE: 'closing', }, }, closing: { entry: 'restoreFocus', // トリガーにフォーカスを戻します after: { 200: 'closed', // アニメーション遅延をシミュレートします }, }, }, }, { actions: { focusModalContent: (context) => { // モーダル内の最初のフォーカス可能な要素にフォーカスするロジック console.log('Focusing modal content'); }, trapFocus: () => { // フォーカスをトラップするハンドラーを設定するロジック console.log('Setting up focus trap'); }, restoreFocus: (context) => { // context.triggerElementにフォーカスを戻すロジック console.log('Restoring focus to:', context.triggerElement); context.triggerElement?.focus(); }, }, });
これをReactコンポーネントで利用する:
import React, { useRef, useEffect } from 'react'; import { useMachine } from '@xstate/react'; // またはお使いのフレームワークのXStateフック function MyModalComponent({ children }) { const [current, send] = useMachine(modalMachine); const modalRef = useRef(null); // モーダルコンテンツへの参照 const isOpen = current.matches('open') || current.matches('opening'); useEffect(() => { const handleKeyDown = (event) => { if (isOpen && event.key === 'Escape') { send('ESCAPE'); } }; const handleClickOutside = (event) => { if (isOpen && modalRef.current && !modalRef.current.contains(event.target)) { send('CLICK_OUTSIDE'); } }; document.addEventListener('keydown', handleKeyDown); document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('keydown', handleKeyDown); document.removeEventListener('mousedown', handleClickOutside); }; }, [isOpen, send]); const handleOpen = (event) => send({ type: 'OPEN', trigger: event.currentTarget }); const handleClose = () => send('CLOSE'); return ( <div> <button onClick={handleOpen}>Open Modal</button> {isOpen && ( <div role="dialog" aria-modal="true" aria-labelledby="modal-title" className="modal-overlay" > <div ref={modalRef} className="modal-content"> <h2 id="modal-title">Modal Title</h2> {children} <button onClick={handleClose}>Close</button> </div> </div> )} </div> ); }
このアプローチは、すべてのモーダルロジックをmodalMachine
内に集中させます。コンポーネントは薄いレンダリングレイヤーとなり、状態に反応し、イベントを送信します。
- 予測可能性: 可能性のあるすべての状態と遷移は明示的に定義されています。隠されたインタラクションはありません。
- 堅牢性: 設計により、不可能な状態(例:すでに閉じているモーダルを閉じる)は防止されます。
- テスト容易性: ステートマシンはUIフレームワークから独立してテストでき、単体テストを非常に効果的にします。
- 保守性: 動作の変更は、1か所、つまりステートチャートの定義で行われます。
Zag.js:一般的なパターンのためのヘッドレスコンポーネント
XStateではゼロからステートマシンを構築できますが、Zag.jsは一般的なUIパターンのための、事前構築された本稼働対応のステートマシンを提供します。これにより、柔軟性を犠牲にすることなく、開発が大幅に加速されます。
Zag.jsを使用したドロップダウンメニューの例でこれを説明しましょう。ドロップダウンには次のような状態があります。
open
/closed
focused.trigger
/focused.item
(およびフォーカスされているアイテム)disabled
Zag.jsは、一般的なUIコンポーネントのためのuseMachine
フック(XStateのものと同様)を公開しており、state
とsend
、およびapi
プロパティを提供します。 api
オブジェクトには、HTML要素にスプレッドして、ARIA属性、イベントリスナー、フォーカス管理を自動的に処理するために必要なすべてのプロパティが含まれています。
// Reactまたは同等のフレームワークコンポーネント内 import { useMachine } from '@zag-js/react'; import * as dropdown from '@zag-js/dropdown'; import { useId } from 'react'; // 一意のID用 function MyDropdown() { const [state, send] = useMachine(dropdown.machine({ id: useId() })); const api = dropdown.connect(state, send); return ( <div {...api.getRootProps()}> <button {...api.getTriggerProps()}> Actions <span aria-hidden>▼</span> </button> {api.isOpen && ( <ul {...api.getContentProps()}> <li {...api.getItemProps({ value: 'edit' })}>Edit</li> <li {...api.getItemProps({ value: 'duplicate' })}>Duplicate</li> <li {...api.getItemProps({ value: 'archive' })}>Archive</li> <li {...api.getItemProps({ value: 'delete' })}>Delete</li> </ul> )} </div > ); }
Zag.jsはこの単純なドロップダウンで、すぐに追加できる機能を提供します。
- ARIA属性:
role
、aria-haspopup
、aria-expanded
、aria-controls
、aria-labelledby
、aria-activedescendant
はすべて管理されます。 - キーボードナビゲーション: アイテムをナビゲートするための矢印キー、
Home
、End
;閉じるためのEscape
;選択するためのEnter
/Space
。 - フォーカス管理: 自動フォーカストラップと復元。
- クリックアウトサイド: 外側をクリックしたときの閉じる処理を扱います。
開発者の責任は、JSXをレンダリングし、api
プロパティを適用することだけです。すべての複雑なインタラクションロジックは、Zag.jsの基盤となるステートマシンによって処理されます。これにより、ボイラープレートコードとバグの可能性が大幅に削減され、開発者は最小限の労力で、非常にアクセシブルで堅牢なコンポーネントを構築できます。
結論
従来の命令型アプローチで複雑なUIコンポーネントを構築すると、すぐに管理不能なコードベースにつながる可能性があります。ステートマシンとステートチャート(XStateとZag.jsによって促進される)を採用することで、フロントエンド開発者は予測可能性、堅牢性、保守性を最優先事項にできます。XStateはカスタムステートフルロジックを設計するための強力なツールキットを提供し、Zag.jsは一般的なコンポーネントのバトルテスト済みのヘッドレスUI解釈を提供して、アクセシビリティとインタラクションの複雑さを抽象化します。これらのツールを採用することで、インタラクティブなUIの開発は、一連のアドホックな条件付きステートメントから、明確に定義された、テスト可能で、信頼性の高いシステムへと変革され、複雑なUIコンポーネントの構築と保守が楽しくなります。