Xstate並列状態による増分ビューの構築


Twitterで私に従ってくださいNewsletter | もともと公開timdeschryver.dev .
Read the TLDR version on timdeschryver.dev
ましょうXState スピンはどのように我々はユーザーのためのより滑らかで滑らかな経験を達成するためにインクリメンタルビューを構築することができますを参照してください.
インクリメンタルビューは、ビューが複数のソースからデータを必要とするときに使用されるテクニックであり、ビューでは、これらのソースからのデータが解決するときに直接表示されます.インクリメンタルビューの別の用語は、プログレッシブビューです.
言い換えれば、私たちは、それが取得されるとすぐに、我々のユーザーにデータを示したいです、我々が見解を提出する前に、我々が要求のすべてを待つのを待ちません.いくつかの要求が他のものより賢明であるときに、このテクニックはユーザー経験を改良できる.その結果、アプリケーションは、高速かつ敏感な感じのユーザーが幸せです.
この例を簡単にするために、3つの別々のTodosを取得し、それらを単一のリストに表示します.
現実世界のシナリオでは、ビューを構築するために必要なデータを取得するために異なるサービス(ドメイン)を要求する必要があります.
たとえば、イベントが異なるサービスから取得されるカレンダーを考えます.
我々は、複数の方法でこれを構築することができますが、私は使用を好むparallel (or orthogonal) states .

並列状態の使用理由


並列状態機械の範囲内で、互いに独立して生きる複数の能動状態ノードがある.
複数の子を持つ一つの大きなノードと比較できます.
これは、子ノードが他の状態ノードを煩わすことなく単独で生きる利点がある.
この例では、各リクエストはリクエストのライフサイクルを表す独自の状態を持っていることを意味します.idle , loading , success , and error .
すべての状態ノードが1つのマシンに属するので、それらはすべて同じイベントに反応することができます.
私たちの例では、単一のfetch すべてのリクエストを起動するトリガーです.
単一の状態ノードはまた、ノード固有のイベント、例えば、応答を機械の文脈に割り当てる要求の成功イベントを有する.
並列状態機械は、異なる状態ノードを有する単一のコンテクストを共有する.
これにより、状態マシンからコンテキストを使用する「ビューモデル」を構築することが容易になります.
別のマシンを使用する場合は、手動で一緒に複数のマシンの状態をステッチする必要があります.
実際には、以下の状態グラフを与える.

上記のイメージで、はっきりと異なった州のノードを見ることができます.todoOne , todoTwo , and todoThree .
これらの状態ノードの各々はそれ自身の状態を持っていますtodoOne and todoTwo は成功状態、todoThree は読み込み状態にある.すべてのイベントは、グラフとどのように別の状態(別の状態から別の状態への移行)にもあります.

パラレルステートマシンの作り方


機械モデルへの小さな迂回


私が出会ったマシンを見てみる前に、まずその姿を見ましょうmachine's model API.次の手順では、マシンはこのモデルに基づいて作成され、モデル上の定義されたイベントは、マシンと通信するコンポーネントによって使用されます.
モデルを使用すると、コンテキストの構造、およびマシンに送信できるすべてのイベントを簡単に見ることができるので、より良い開発者の経験が得られます.
これらの利点の他に、モデルを送るか、イベントに反応している間、より良いタイプ支持を提供します.
博士によると、より多くのグッズが続きます!
あなたが上記の状態グラフを見て、下のモデルと比較するならば、あなたは一つの目瞬きの範囲内でモデルを認めるでしょう.
import { createModel } from 'xstate/lib/model';

export const appModel = createModel(
    {
        // comes from an external service
        todoOne: undefined as Todo | undefined,
        todoTwo: undefined as Todo | undefined,
        todoThree: undefined as Todo | undefined,

        // comes from the component
        onlyUncompleted: false,
    },
    {
        events: {
            // first group: events that all state nodes react to
            fetch: () => ({}),
            retry: () => ({}),
            focus: () => ({}),
            // second group: events where a single state node reacts to
            receivedTodoOne: (todo: Todo) => ({ todo }),
            receivedTodoTwo: (todo: Todo) => ({ todo }),
            receivedTodoThree: (todo: Todo) => ({ todo }),
            // third group: events that simply update the context
            toggleCompleted: () => ({}),
        },
    },
);
ご覧のように、モデルも含まれていますonlyUncompleted コンポーネントのボタンをクリックすることによって切り替えることができます.
プロパティは、完了したToDo項目をフィルタリングするために使用されます(後に表示されます).
このモデルの事象は3グループに分類できる.
を含む最初のグループfetch , retry , and focus イベントは、todosをフェッチするために使用されます.すべての異なる並列ノードは、これらのイベントに反応して、例えば、1つの状態から他の状態までの内部遷移を引き起こすidle to loading ノードがfetch イベント.
イベントの第2のグループは、単一の州の木に属する特定のイベントですreceivedOne , receivedTwo , and receivedThree . 最初のグループと同様に、これらのイベントはまた、1つの状態から他の状態への内部遷移を引き起こすloading to success を返します.
番目と最後のグループは、どの状態ツリーに属していないかのような遷移です.
これらのイベントは、マシンのコンテキストを更新するためにのみ使用されます.The toggleCompleted イベントはこの3番目のグループに属しますonlyUncompleted を返します.

パラレルマシン


モデルを解析することで、我々はマシンを作成することができます.
この例に適用できる重要な部分を通過しましょうappModel 我々が前に定義したモデル.
パラレルマシンを作成する最初のステップはtype に設定されているプロパティparallel .
この構成により、マシンは同時にアクティブである複数のサブノードを有する.
グラフで見たように、マシンは3つの孤立した状態ノードを含みます.todoOne , todoTwo , and todoThree .
それぞれのノードは、他のノードとほとんど同じである.例外は、異なるサービス(todoをフェッチする)を呼び出し、それが所有するコンテキストを更新することである.また、すべてのノードがマシン内の単一のコンテキストを共有することに注意してください.
export const appMachine = appModel.createMachine({
    id: 'app',
    type: 'parallel',
    context: appModel.initialContext,
    invoke: {
        src: 'checkForDocumentFocus',
    },
    states: {
        todoOne: {
            initial: 'idle',
            states: {
                idle: {
                    on: {
                        fetch: { target: 'loading' },
                        focus: { target: 'loading' },
                    },
                },
                loading: {
                    tags: ['loading'],
                    invoke: {
                        src: 'fetchOne',
                        onError: {
                            target: 'failure',
                        },
                    },
                    on: {
                        receivedTodoOne: {
                            target: 'success',
                            actions: appModel.assign({
                                todoOne: (_, event) => event.todo,
                            }),
                        },
                        fetch: {
                            target: 'loading',
                            actions: appModel.assign({
                                todoOne: () => undefined,
                            }),
                        },
                    },
                },
                success: {
                    on: {
                        fetch: {
                            target: 'loading',
                            actions: appModel.assign({
                                todoOne: () => undefined,
                            }),
                        },
                        focus: { target: 'loading' },
                    },
                },
                failure: {
                    on: {
                        retry: { target: 'loading' },
                    },
                },
            },
        },
        todoTwo: {
            initial: 'idle',
            states: {
                idle: {
                    on: {
                        fetch: { target: 'loading' },
                        focus: { target: 'loading' },
                    },
                },
                loading: {
                    tags: ['loading'],
                    invoke: {
                        src: 'fetchTwo',
                        onError: {
                            target: 'failure',
                        },
                    },
                    on: {
                        receivedTodoTwo: {
                            target: 'success',
                            actions: appModel.assign({
                                todoTwo: (_, event) => event.todo,
                            }),
                        },
                        fetch: {
                            target: 'loading',
                            actions: appModel.assign({
                                todoTwo: () => undefined,
                            }),
                        },
                    },
                },
                success: {
                    on: {
                        fetch: {
                            target: 'loading',
                            actions: appModel.assign({
                                todoTwo: () => undefined,
                            }),
                        },
                        focus: { target: 'loading' },
                    },
                },
                failure: {
                    on: {
                        retry: { target: 'loading' },
                    },
                },
            },
        },
        todoThree: {
            initial: 'idle',
            states: {
                idle: {
                    on: {
                        fetch: { target: 'loading' },
                        focus: { target: 'loading' },
                    },
                },
                loading: {
                    tags: ['loading'],
                    invoke: {
                        src: 'fetchThree',
                        onError: {
                            target: 'failure',
                        },
                    },
                    on: {
                        receivedTodoThree: {
                            target: 'success',
                            actions: appModel.assign({
                                todoThree: (_, event) => event.todo,
                            }),
                        },
                        fetch: {
                            target: 'loading',
                            actions: appModel.assign({
                                todoThree: () => undefined,
                            }),
                        },
                    },
                },
                success: {
                    on: {
                        fetch: {
                            target: 'loading',
                            actions: appModel.assign({
                                todoThree: () => undefined,
                            }),
                        },
                        focus: { target: 'loading' },
                    },
                },
                failure: {
                    on: {
                        retry: { target: 'loading' },
                    },
                },
            },
        },
    },
    on: {
        toggleCompleted: {
            actions: appModel.assign({
                onlyUncompleted: (context) => !context.onlyUncompleted,
            }),
        },
    },
});

状態ノードを徹底的に調べる


より大きな絵のより良い理解がある今、一つの州のノードに拡大しましょう.
ノードは一度に1つの状態になることができ、要求の状態を表しますidle , loading , success , or failure 状態.
ノードの状態に応じて、ノードがイベントを受信すると、別の状態に遷移することができます.
例えば、ノードはidle へのloading しかし、それはidle へのfailure 状態.グラフは、それが簡単に行動と意図について通信することができますノード間の可能な遷移を示しています.
これが重い持ち上げの大部分をするので、ロード・ノードをより詳しく見ましょう.
ノードの残りは単純遷移ノードである.
経由でinvoke プロパティの読み込み状態fetchOne サービスがアクティブ状態になったときにサービスを行う.
サービスは外部サービスからデータを取得し、受け取ったデータを返す責任があります.
ステートマシンは、サービスの実装の詳細について知っておく必要はありません.
あなたがマシンの流れを開発している間、あなたは要点に集中することができて、後で詳細について心配することができます.
これは状態機械を単純にして、消費者(マシンも再利用可能にする)とフレームワークから切り離されます.
サービスのインプリメンテーションは州のマシンの消費者によって提供されます.コンポーネントでは、サービスが実際のサービスになりますが、サービスはテストでスタブできます.
私はあなたにも3つの異なる参照してくださいモデルreceivedTodo イベント.これらの出来事は onDone transition , しかし、私はこれらについて明示的で、モデルにそれらを加えるのを好みます.この練習は、イベントの完全な制御を持っているので、テストされたすべてのイベント、およびまた、簡単にテストを保持します.
サービスが成功した場合、receivedTodoOne イベントに追加されたToDoはコンテキストに割り当てられ、success 状態.
最後ではなく重要ではないloading タグは、コンポーネントを簡単にローディングインジケータを表示するために使用されてloading がアクティブなノードです.別の方法として、状態ノードの1つがloading 例えば、state.matches('todoOne.loading') || state.matches('todoTwo.loading') || state.matches('todoThree.loading') . 私はタグを使用して簡単かつ簡単に将来的に拡張することがわかります.
{
    "todoOne": {
        "initial": "idle",
        "states": {
            "idle": {
                "on": {
                    "fetch": { "target": "loading" },
                    "focus": { "target": "loading" }
                }
            },
            "loading": {
                "tags": ["loading"],
                "invoke": {
                    "src": "fetchOne",
                    "onError": {
                        "target": "failure"
                    }
                },
                "on": {
                    "receivedTodoOne": {
                        "target": "success",
                        "actions": appModel.assign({
                            "todoOne": (_, event) => event.todo
                        })
                    },
                    "fetch": {
                        "target": "loading",
                        "actions": appModel.assign({
                            "todoOne": () => undefined
                        })
                    }
                }
            },
            "success": {
                "on": {
                    "fetch": {
                        "target": "loading",
                        "actions": appModel.assign({
                            "todoOne": () => undefined
                        })
                    },
                    "focus": { "target": "loading" }
                }
            },
            "failure": {
                "on": {
                    "retry": { "target": "loading" }
                }
            }
        }
    }
}

機械の消費


マシンは現在、コンポーネントによって消費される準備が整いました.
コンポーネントはすべてのサービスの実装を提供します.
それに加えてstate$ 状態の変更を購読し、テンプレートで使用されるビューモデルを構築します.
使用によってthe model , イベントは機械に送られる.
@Component({
    template: `
        <button (click)="fetch()">Fetch</button>
        <ng-container *ngIf="state$ | async as state">
            <div *ngIf="state.loading">Loading...</div>
            <div *ngIf="!state.loading">
                <button (click)="toggleClicked()">Toggle completed</button>
            </div>
            <pre>{{ state.todos | json }}</pre>
        </ng-container>
    `,
})
export class AppComponent {
    machine = appMachine.withConfig({
        // in a real application, these services would be @Injectable services
        services: {
            fetchOne: () => {
                return this.http.get<Todo>('https://jsonplaceholder.typicode.com/todos/1').pipe(
                    delay(1000),
                    map((todo) => appModel.events.receivedTodoOne(todo)),
                );
            },
            fetchTwo: () => {
                return this.http.get<Todo>('https://jsonplaceholder.typicode.com/todos/2').pipe(
                    delay(2000),
                    map((todo) => appModel.events.receivedTodoTwo(todo)),
                );
            },
            fetchThree: () => {
                return this.http.get<Todo>('https://jsonplaceholder.typicode.com/todos/4').pipe(
                    delay(4000),
                    map((todo) => appModel.events.receivedTodoThree(todo)),
                );
            },
            checkForDocumentFocus: () => (sendBack) => {
                const listener = () => {
                    sendBack(appModel.events.focus());
                };

                window.addEventListener('focus', listener);

                return () => {
                    window.removeEventListener('focus', listener);
                };
            },
        },
    });

    service = interpret(this.machine, { devTools: true }).start();

    state$ = from(this.service).pipe(
        filter((state) => state.changed === true),
        map((state) => {
            // build a view model from the state
            const componentState = {
                todos: [state.context.todoOne, state.context.todoTwo, state.context.todoThree]
                    .filter((todo) => todo && (state.context.onlyUncompleted ? !todo.completed : true))
                    .map((todo) => ({
                        title: todo!.completed ? `${todo!.title} (completed)` : todo!.title,
                    })),
                loading: state.hasTag('loading'),
            };
            return componentState;
        }),
    );

    constructor(private http: HttpClient) {}

    fetch() {
        this.service.send(appModel.events.fetch());
    }

    toggleClicked() {
        this.service.send(appModel.events.toggleCompleted());
    }
}
ほとんどのロジックはステートマシンに住んでいるので、2つのことに責任があるリーンコンポーネントで終わります.
  • 機械との通信
  • マシンの現在の状態を表示する
  • アプリケーション


    私たちは理由と方法について議論しました、しかし、我々はこれがどのようにユーザーインターフェースに翻訳するかについて見ませんでした.
    簡単な例については、このようにしてtodo項目を取得していくことを意味します.
    また、すべてのリクエストが完了するまで表示される読み込みインジケータがあります.
    現実世界のシナリオでは、同じテクニックをよりスムーズにユーザーの経験を達成するために適用することができます.
    たとえば、複数のイベントを(別のソースから来て)カレンダーにロードする.

    コードをフィドルする場合は、StackBlitz 下👇.
    Twitterで私に従ってくださいNewsletter | もともと公開timdeschryver.dev .