こんにちは、Webエンジニアチームの@sat0yuです。 これまで弊社のイベント報告系記事しか書いてこなかったのですが、今日はすこし技術寄りの話をします。
現在私達のチームでは、生徒向け学習サービス(日本ではスタディサプリ、 海外ではQuipper Videoの名称で提供)のリニューアルプロジェクトを担当しています。 リニューアルバージョンのリリースは以下のとおり計画されており、すでに合格特訓コースのユーザには提供を開始しています。
本投稿では、リニューアルプロジェクトの中で生まれた、小さいけれども非常に便利な状態遷移管理コンポーネントの紹介をします。
TL; DR
- 心地よいUXを表現するために5つの状態遷移を考える必要がある
- 状態遷移を簡単に実装するために
UiStackTemplate
コンポーネントを導入した - 現在では全コンポーネントの10%+で
UiStackTemplate
が使われている
スタディサプリ・Quipper Videoの両ブランドでは、そのプログラムコードの大部分を共有しながら開発されているため、エンジニアだけでも国内外あわせて10+名程が開発に携わっています。
今日の話は、いまから半年前、本リニューアルプロジェクトにはまだ3〜4人のエンジニアしかいなかった頃に戻ります。 ユーザに本サービスを心地よく使ってもらうと同時に、国内外のエンジニアが簡単に参加できるプロジェクトにしていく必要がありました。
心地よく勉強してもらいたい
すこし前の記事になりますが、Scott Hurffという方がUiStackという考え方を提案しています。(POSTDに日本語訳がありました)
同じ表示のままいつまでも待たされたり、エラーなのか表示要素が空(カラ)なのかが不明瞭だったりといった不親切なUIを卒業して、ユーザフレンドリで心地の良いUIを提供しようという考え方です。
記事中では下記の5つの状態を想定してデザイン・実装することが便利であると説明されています。
(図は元記事より引用)
- Blank state
- 表示要素が空の状態
- 多くの場合でユーザに対して何らかのアクションが要求される
- ユーザにアクションを起こさせる表示が必要
- Loading state
- 表示に必要な情報を読み込んでいる状態
- ユーザストレス軽減のためにスケルトンスクリーンによるアプローチが有効
- Partial state
- Ideal stateに至る過程にある状態
- e.g. 未完成のプロフィール画面
- Error state
- 何らかのエラーによって以降の表示が不可能に陥った状態
- 「何が起こった」よりも「どうすればよいか」を伝える必要
- Ideal state
- ユーザに体験してもらいたい理想状態
- すべての必要な情報が揃い、エラーも存在していない
アプリケーションの状態遷移に関して、リニューアル以前の(厳密には今現在も)本サービスはじつのところ様々な問題を抱えていました。
- 表示されない宿題リスト
- 消えないローディングインディケータ
- たった一つのデータ不整合でサービスが利用不可能に陥る
例えば、合格特訓コースでは毎週月曜日に生徒ごとにカスタマイズされた宿題(今週の課題)を配信しています。 仮にそのユーザが「表示されない宿題リスト」に遭遇してしまった場合には、『今週はやることがない』と誤解を招きかねない重要な問題に発展します。 しかしながら、ドメイン知識・スキル・要求など、背景の異なる大勢のエンジニアが各々独自に実装を加えるため、これらの問題を解消するのは極めて難しい課題となっていました。
今週の課題
表示されない宿題リスト
そこでリニューアルプロジェクトでは、(1)これらの問題を解消するとともに、問題の再発を防ぐために、(2)アプリケーションの状態遷移を考慮に入れた統一的なフレームワークが求められました。 また、スタディサプリ・Quipper Videoの両ブランドの上で、各国がサービスが提供されているため、(3)フレームワークはカスタマイズ性に優れている必要もありました。
心地よく開発してもらいたい
本プロダクトの主な技術スタックはReact + Redux + TypeScript + Workbox (Service Worker) が挙げられます。
Reactを触ったことのある&&カンの良い読者の方なら、ここまで読まれただだけでも「Higher-Order Component(HOC)かな?Providerパターンかな?」と想像されたかもしれません。
結論から先にお伝えすると、Providerパターンを採用して、汎用の状態遷移管理コンポーネント UiStackTemplate
を作成しました。
なお、本投稿内ではHOCとProviderパターンの詳述はしませんが、こちらの記事が非常に参考になりました。
import * as React from 'react'; import Spinner from 'SpinnerComponent'; import NetworkError from 'NetworkErrorComponent'; interface Props { isLoading?: boolean; isError?: boolean; isBlank?: boolean; LoadingComponent?: React.ReactChild; ErrorComponent?: React.ReactChild; BlankComponent?: React.ReactChild; children: React.ReactChild | React.ReactChild[]; } const UiStackTemplate: React.SFC<Props> = ({ isLoading, isError, isBlank, LoadingComponent, ErrorComponent, BlankComponent, children, }) => ( <> {isError ? ErrorComponent || <NetworkError /> : isLoading ? LoadingComponent || <Spinner /> : isBlank && !!BlankComponent ? BlankComponent : children} </> ); export default UiStackTemplate;
UiStackTemplate
はIdealステートに対応するコンポーネントをReact.Childrenとして受け取ります。
また、Blank, Error, Loadingの3状態についても、それぞれ所望のコンポーネントをPropsとして渡すことができます。
Propsで指定されなければそれぞれに用意された汎用コンポーネントを表示します。
非常に薄い実装ですが、これだけで複数の状態遷移を管理することができます。
UiStackTemplate
を利用する側のコードも簡潔にまとまっています。
// isLoading: boolean // error?: error // list: Item[] <UiStackTemplate isLoading={isLoading} isError={!!error)} isBlank={list.length == 0)} > <ul> {list.map(item => ( <li key={item.id}> {item.name} </li> ))} </ul> </UiStackTemplate>
なお、Partialステートの管理は本コンポーネントの機能として提供していません。 Partialステートではユーザに対してさらなるアクションの動機づけを行い、Idealステートに導くことが求められます。つまりPartialステートには個別性が要求されるということです。 したがってフレームワークのような統一的な解決策を提供することが難しいと考えました。
Providerパターン
先にも述べたとおり、弊社ではスキルセットの異なる大勢のエンジニアが本プロダクトの開発に携わります。 また、多くの(ほぼすべての)プロダクトにおいて詳述されたドキュメントが存在していないため、各エンジニアは必要に応じてgithub issueやPRを読み返して仕様を把握します。 このような状況のため、社内ライブラリとしてはできるだけ仕様が単純明快なものが好まれます。
Providerパターンで実装したコンポーネントは素朴なDOMを扱うような直感的な実装が可能です。 Reactに慣れていないエンジニアでもコードを読めば大体の挙動が理解できるという利点があります。
HOCも単体で利用するかぎりは一見問題ないように見えます。
ところが、複数のHOCが(withIntl
や withRouter
という具合に...)組み合わさりはじめると途端に複雑さが増します。
後々をメンテナンスを考えた場合には、Providerパターンが適していると判断しました。
type Props = ReturnType<typeof mapStateToProps> class SomeComponent extends React.Component<Props> { public render() { return (/* something */); } } const mapStateToProps = (state: RootState) => ({ courses: getCourses(state), }); // exportされたコンポーネントの型は…? export default compose( withIntl, withRouter, withAuthenticate, withUser, connect(mapStateToProps) )(SomeComponent)
また、感覚・抽象的な話になってしまい恐縮ですが、HOCによる実装の場合には、引数として渡すコンポーネントの『外側に機能を付加していく』イメージになります。 コンポーネントの内部状態の遷移を表現できるフレームワーク(実態はコンポーネントですが)を想定していたため、むしろ『内側に対して機能を提供する』実装アプローチのほうが相性が良いと感じます。
これに対してProviderパターンによる実装の場合には、コンポーネントの内部に処理を記述できるため、今回のケースに合っていると言えます。
ちなみに React17 React16.9で追加されるSuspenseに関してですが、 UiStackTemplate
を実装した時点では私自身がSuspenseの存在を認識していなかったために単純に考慮から漏れていました。
(まだ詳しい検討はできていませんが、本プロダクトではほぼ全てのHTTP通信をtypescript-fsa + reduxで制御しているため、素直な方法ではSuspenseの利点を活かした実装ができないと感じています)
後知恵になってしまいますが、 Providerパターンによる実装により UiStackTemplate
コンポーネント自体も、それを利用する側のコードも、非常に簡単に記述できており、結果的にはこの方針で良かったと感じています。
また、本投稿執筆時に関連ライブラリを調べてみたところ、類似の実装を公開されている方がいました。そちらではHOCによる実装パターンもライブラリとして提供されているようです。
心地よいUX/DXは提供できたか
実際に合格特訓コース受講生へ提供しているサービスの画面をお見せします。 リニューアル後の「今週の課題」はそれぞれ状態ごとに異なるViewを表示します。 この例では、Blank, Loadingの2状態で専用に用意したViewを利用していますが、Errorについては汎用の接続エラー用のコンポーネントを表示させています。
blank state
loading state
error state
Ideal state
つぎに UiStackTemplate
の利用状況を見ていきます。
現在、本プロダクトには500+のコンポーネントが存在しおり、それらの10%+において UiStackTemplate
が利用されているようです。
弊社内のエンジニアには十分に利用されていると言えます。
さらに、UiStackTemplate
を利用しているコンポーネントのうち67%が (Error|Blank|Loading)Component
を指定しており、カスタマイズ性も十分に活かされています。
# リポジトリ中の.tsxファイルを対象として検索 bash-3.2$ find src | grep -e .tsx | grep -v __tests__ | wc -l 502 bash-3.2$ find src | grep -e .tsx | grep -v __tests__ | xargs grep -l -e "UiStackTemplate" | wc -l 52 bash-3.2$ find src | grep -e .tsx | grep -v __tests__ | xargs grep -l -e "UiStackTemplate" | xargs grep -l -e ErrorComponent -e BlankComponent -e LoadingComponent | wc -l 35
まとめ
- 心地よいUXを表現するために5つの状態遷移を考える必要がある
- 状態遷移を簡単に実装するために
UiStackTemplate
コンポーネントを導入した - スキルセットの異なる大勢のエンジニアが触りやすいようにProviderパターンを採用した
- 来春のリニューアルリリースに備えて、Quipperでは新たな仲間を募集しています