Reactの関数コンポーネントで、hooksのViewModelを使う
概要
Reactにおいて、MVVMアーキテクチャで実装する時のViewModelの書き方について、hooksを使った一例を紹介します。
MVVMとは
簡単ですがMVVMについて概要を記載しておきます。詳細については割愛しますので他資料を参照ください。
MVVMパターンとは、アプリケーションのコードを、Model (モデル)、View (ビュー)、ViewModel (ビューモデル) に責務を分割するアーキテクチャを指します。
もとはMicrosoftが提唱したアーキテクチャですが、Androidの標準で組み込まれているアーキテクチャではMVVMが採用されていたりと、比較的に採用されているイメージです。
単方向データフロー
ViewModelを作るにあたっては、 単方向データフロー
と呼ばれる設計パターンに従うことが重要です。
ViewModelの責務は一般的に、「Viewからの入力を受け取り、状態をViewに対して通知する」ことです。
このようにデータの更新イベントについては常にView -> ViewModel
、データの状態については常にViewModel -> View
の方向で流れる単方向データフローとしてViewModelを実装することで状態の不整合を減らし、メンテナンス性を高めることができます。
Reactのような宣言的UIにおいて、このような単方向データフロー
に準拠することは相性が良いため、基本的にこれから紹介するコードもこれを意識して書きます。
実装方針
ここから本題です。 ここではViewModelをTypeScriptで実装します。 そのためのViewModelの型は以下の通りです。
type ViewModelFunc<State, Action, Argument extends object | void = void> = (args: Argument) => { state: State action: Action }
前述の単方向データフローに乗っ取り、基本ルールとして State
には状態となる値を、 Action
には値を更新するためのメソッドを定義します。
Argumentは、単方向データフローでは登場しませんでしたが、親コンポーネントから値をそのまま受け取りたい場合などに使用する想定で、指定しなくても良くなっています。
実装例1(カウンターのコンポーネント)
早速ですが、この型をつかって簡単な関数コンポーネントにViewModelを導入してみます。
ViewModelを使わない場合
export function CounterView() { const [counter, setCounter] = useState(0) return ( <div> <p>{counter}</p> <button onClick={() => setCounter((prevCount) => prevCount + 1)}>count up</button> </div> ) }
ViewModelを使う場合
この型を使って作成した簡単なカウンターコンポーネントは以下のようになります。
type State = { counter: number } type Action = { countUp: () => void } export const useViewModel: ViewModelFunc<State, Action> = () => { const [counter, setCounter] = useState(0) return { state: { counter, }, action: { countUp: () => setCounter((prevCount) => prevCount + 1), }, } } export function CounterView() { const { state: { counter }, action: { countUp }, } = useViewModel() return ( <div> <p>{counter}</p> <button onClick={() => countUp()}>count up</button> </div> ) }
実装例2(Todoリストのコンポーネント)
例1のコード量では、イマイチ実用性がわかりにくいかもしれないのでもう少し長いコードで試してみます。
以下はTODOリストを作成するための簡単なサンプルコードです。
ViewModelを使わない場合
const TodoList: React.FC = () => { const [todoList, setTodoList] = useState<Todo[]>([]) const [inputTodoText, setInputTodoText] = useState('') const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { setInputTodoText(e.target.value) } const handleAddTodo = () => { if (inputTodoText.trim()) { const newTodo: Todo = { id: Date.now(), task: inputTodoText.trim(), completed: false, } setTodoList([...todoList, newTodo]) setInputTodoText('') } } const handleCompleteTodo = (id: number) => { const updatedTodoList = todoList.map((todo) => (todo.id === id ? { ...todo, completed: !todo.completed } : todo)) setTodoList(updatedTodoList) } const handleDeleteTodo = (id: number) => { const updatedTodoList = todoList.filter((todo) => todo.id !== id) setTodoList(updatedTodoList) } return ( <div> <h2>Todo List</h2> <input type="text" value={inputTodoText} onChange={handleInputChange} /> <button onClick={handleAddTodo}>Add Todo</button> <ul> {todoList.map((todo) => ( <li key={todo.id}> <span style={{ textDecoration: todo.completed ? 'line-through' : 'none', }} onClick={() => handleCompleteTodo(todo.id)} > {todo.task} </span> <button onClick={() => handleDeleteTodo(todo.id)}>Delete</button> </li> ))} </ul> </div> ) }
ViewModelを使う場合
これらのコードをuseViewModelを使って整理してみます。
まずViewModelはこうなります。
type Todo = { id: number task: string completed: boolean } type State = { todoList: Todo[] inputTodoText: string } type Action = { changeInputTodoText: (text: string) => void addTodo: () => void deleteTodo: (id: number) => void completeTodo: (id: number) => void } const useViewModel: ViewModelFunc<State, Action> = () => { const [todoList, setTodoList] = useState<Todo[]>([]) const [inputTodoText, setInputTodoText] = useState('') const addTodo = () => { if (inputTodoText.trim()) { const newTodo: Todo = { id: Date.now(), task: inputTodoText.trim(), completed: false, } setTodoList([...todoList, newTodo]) setInputTodoText('') } } const completeTodo = (id: number) => { const updatedTodoList = todoList.map((todo) => (todo.id === id ? { ...todo, completed: !todo.completed } : todo)) setTodoList(updatedTodoList) } const deleteTodo = (id: number) => { const updatedTodoList = todoList.filter((todo) => todo.id !== id) setTodoList(updatedTodoList) } return { state: { todoList, inputTodoText, }, action: { changeInputTodoText: (text) => setInputTodoText(text), addTodo, completeTodo, deleteTodo, }, } }
次に関数コンポーネントはこうなります。
const TodoList: React.FC = () => { const { state: { todoList, inputTodoText }, action: { changeInputTodoText, addTodo, completeTodo, deleteTodo }, } = useViewModel() return ( <div> <h2>Todo List</h2> <input type="text" value={inputTodoText} onChange={(event) => changeInputTodoText(event.target.value)} /> <button onClick={addTodo}>Add Todo</button> <ul> {todoList.map((todo) => ( <li key={todo.id}> <span style={{ textDecoration: todo.completed ? 'line-through' : 'none', }} onClick={() => completeTodo(todo.id)} > {todo.task} </span> <button onClick={() => deleteTodo(todo.id)}>Delete</button> </li> ))} </ul> </div> ) }
メソッド名など少し変更しましたが、基本的には処理内容の変更はしていません。
何がStateで何がActionなのかが明示的になり、コード量も減ったことでViewの可読性が上がりました。
なぜhooksなのか?
Reactの関数コンポーネントにおいて、インスタンスでViewModelを表現する場合、useMemoやuseStateなどを使ってインスタンスを保持する形になると思います。
しかし、現状の多くのエコシステムは、hooksをメインに実装されているため、ViewModelをインスタンスとして管理するのは相性が悪いためviewModelもhooksにするというアプローチを取っています。
これならば、単純にhooksをuseViewModelに隠蔽しているだけなので、useStateがrecoilやreduxに変更されてもコンポーネントに影響を与えることはありません。
すべての画面にViewModelが必要か?
個人的にはすべての画面には、ViewModelは必要ない認識です。
例えば、XXXButton、XXXLabelといった純粋なコンポーネントに状態は不要なのでViewModelは使っていません。
現状の運用としては、XXXPageのようなドメイン知識を有しているコンポーネントや特別な状態を持つコンポーネントについては、状態管理が複雑化しやすいためViewModelによる分離が有効だと考えています。
Argumentはいつ使うのか?
Argumentは親コンポーネントから受け取った値を元にStateを初期化したり、引数を元に初期データを取得する場合などのために使用します。
ですが、なるべく依存関係をきれいにするのであれば使わないに越したことはないと思います。
おわりに
以上、関数コンポーネントでのViewModelについてhooksを使った方法の紹介でした。 ※ 余談ですが、この記事のサンプルコードはChatGPTに作ってもらいました。
「Hooked ハマるしかけ 使われつづけるサービスを生み出す[心理学]×[デザイン]の新ルール」を読んだ感想とメモ
Hooked ハマるしかけ 使われつづけるサービスを生み出す[心理学]×[デザイン]の新ルール
- 作者: Nir Eyal,Ryan Hoover
- 出版社/メーカー: 翔泳社
- 発売日: 2014/05/27
- メディア: Kindle版
- この商品を含むブログ (1件) を見る
感想
- あるプロダクトがその人にとってなくてはならないものになる(習慣化、依存性)という事象にのみフォーカスしているため視点がブレずに読める
- ユーザーを依存させることに対してのデメリットも書かれている点は共感できた
- 多くの事例と一緒に説明されているため理解しやすい
- 逆に事例や引用が長すぎて本質的な論点を見失うこともあった(洋書っぽさかも?)
- 各章の最後にチェックポイントがあり、自分のプロダクトをチェックすることができる
- 自分でプロダクトを考えるときのフィルターにすると良いかもしれない
フックモデルとは
- 人間の行動を習慣化させる為のプロセス。
- 「トリガー(きっかけ)」「アクション(行動)」「インベストメント(投資)」「リワード(報酬)」 という4つのプロセスサイクルを継続的に実施することでそのユーザーをそのプロダクトのリピーターにすることを目的とする。
各章のメモ
1.ハビットゾーン(習慣化された領域)
- プロダクトの習慣化がもたらすメリットは大きい
- 顧客生涯価値を上昇させる
- 習慣化は長期間の収益につながる
- 価格設定に自由度をもたらす
- ユーザーは習慣化されたプロダクトに対してお金を使うことが無頓着になる
- 無料ゲームのモデルでは、ユーザーが習慣化するまでは課金しない(フリーミアムモデルはこれにあたる)
- 急激な成長
- 習慣化されたプロダクトはユーザーが口コミでマーケティングしてくれる
- 競争力
- ビタミン剤から鎮痛剤へ
- 習慣化が「あったらいいもの」から「なくてはならないもの」に変える
2.トリガー(きっかけ)
外的トリガー
- まずはユーザーに対してアクションしユーザーの行動を変えていく必要がある(コール・トゥ・アクション)
- 外的トリガーなしでプロダクトがユーザーに受け入れられることはないが、ここに真の成功はない
- ① 有償トリガー
- 広告マーケティングなどの有料チャンネル。新規獲得のために行う
- ② 名声トリガー
- 名声自体にコストはかからないが、名声を得るためのPRやメディア露出には費用が掛かる
- 得た名声は長期的な維持は難しい
- ③ 口コミトリガー
- ネットからの紹介、家族、友人間で口コミで広がる
- 人は
自分が素敵だと思うものをお互いに教え合う
ことを好むため大きな成長につながることもある
- ④ 自己(Owned)トリガー
- スマホ画面のアイコンや、登録したメールアドレスの通知など
- 習慣化するまでにユーザーとつなげるためのトリガー
内的トリガー
- ユーザーの考えや感情、日課などがプロダクトとつながるとそれは内的トリガーとなる
- 内的トリガーはあくまで心の中にあり、見ること(観測すること)はできない
内的トリガーとプロダクトが結びつくことはプロダクトの成功を意味する
- 内的トリガーを得るためには
ユーザーの問題を解決する
ことが重要 - 内的トリガーとプロダクトを結びつけるには長い時間が必要
- プロダクトの何がユーザーを虜にさせるのかを分析する必要がある
3.アクション
- 行動は思考よりも簡単なものでなくてはならない
- 習慣は無意識、または限りなく意識が低い状態で行われる行動である
- 肉体的、精神的な努力が必要であればあるほど、行動は行いにくい
- [重要] プロダクト設計は、逆にいかにユーザーの努力を減らせるかを考えること
- 行動を起こすためには3つの要素がある
- 行動を起こすためのモチベーション
- 行動を可能とする能力
- 行動するためのトリガー
モチベーション
- モチベーションは行動したいという意思の強さ
- 苦しみを避け、快楽を追求する
- 恐怖を避け、希望を求める
- 社会からの拒絶を避け、受け入れられることを求める
能力
- 消費者がプロダクトやサービスをなぜ使うのかを理解する
- 次に消費者が目的を果たすまでに行わなければいけない作業をリストアップする
- 最後に最も単純な作業になるまでいらない作業を取り除く
- [重要] プロダクトは簡単であればあるほどいい
- 時間: どれだけ時間がかかるか
- お金: どれだけお金がかかるか
- 身体的な努力: どれだけ労力がかかるか
- ブレインサイクル: メンタル面でどれだけ努力と集中が必要か
- 社会的な逸脱: どれだけ社会的に受け入れられるか
- 非日常性: どれくらい日常の行動にあうか
トリガー
- ヒューリスティックと認識: 人は無意識下で経験則(ヒューリスティック)を意思決定に利用する
- 希少効果: 同じものでも希少だと感じることで行動に大きく影響する
- フレーミング: 客観的な品質に差がなくても、適切な環境下であれば価値が高まることがある
- 値段が高いワインほど美味しく感じる
- アンカー効果: 「セール」「値引き中」など表記があるだけで、意思決定に影響を与える
- エンダウト・プログレス効果: 人は目的に近づけば近づくほどモチベーションがあがる
- ポイントカードで予め数ポイント押してあるほうが完走率が高くなる
4.リワード(報酬)
- 問題解決に対して報酬を与え、過去に取った行動に対してモチベーションを強化する
予測不能性
- 人は想定パターンを外れた体験をした時に、強く興味を示す
- プロダクトは常に目新しさを維持すべき
トライブ(集団)の報酬
- コミュニティの中での地位獲得などによる報酬
- 自分の投稿による他ユーザーからのいいねなど
ハント(狩猟)の報酬
- 行動により、情報やお金を得ることによる報酬
- Twitterから情報を得る
- カジノでお金を得る
セルフ(自己)の報酬
- 自己の満足感
- 情報のコンプリート
- レベルアップ
- 全てのタスクを消化
報酬システムをデザインする上で大切なこと
- プロダクトが本質的な問題解決をしていなければ、報酬に意味はなく継続性はない
- ゲーミフィケーションを取り入れても使う価値がなければいずれ使われなくなる
- 自主性の維持
- システムからの高圧的な抑制はユーザーに苦痛を与える
- あくまでユーザー自身が主導権を握っているという感覚を保証しなければならない
5.インベスト(投資)
- 人は労力をかければ掛けるほど、それを高く評価し価値があるものと正当化していく
- ユーザーの習慣化に成功したプロダクトは競争優位を獲得する
労力に対する不合理な自己評価
- ユーザーは自分が労力を費やしたという事象に対して、他者よりも高い評価をする
- 例: IKEAは購入者に自ら家具を組み立てさせる
過去の行動との一貫性を求める
- 過去自分がした行動との一貫性を求める
- より大きな事象であっても、過去に自分が行動したことがあれば受入れやすくなる
認知的不協和音を避ける
- 自分に不快な現象に対して、認識を変化させ避けようとする
- 例: ビールは初めて飲む時は美味しくないが、周りが美味しそうに飲んでいる状況からしだいに慣れていく
フォロワー
- すでにTwitterで多くのフォロワーを獲得しているユーザーはその積み上げた投資を放棄することは難しい
スキル
- 一つのプロダクトを生産的に使えるようになるためには訓練が必要な場合がある
- Photoshopは、はじめは覚えるのが難しいツールだが慣れると仕事の効率性が向上する
- ツールの使い方になれるためには、チュートリアルを読んだり、ビデオを見たりといった投資が必要
- しかし、それらのスキルは他のツールで活用できないことが多い
- 結果として、製品やメーカーにロックされる
6.フックモデルをどのように活かせばよいか
- 人が夢中になるプロダクトをつくるということに責任をもつ
- 悪用してはいけない
操作マトリックスの四象現
- そのプロダクトに対して「ユーザーを惹き付けるべきか」という観点で判断のヒントとなる
[作り手が使わない] | [作り手が使う] | |
---|---|---|
[ユーザーの生活を著しく改善する] | Peddler(商人) | Facilitator(住人) |
[ユーザーの生活を改善しない] | Dealer(売人) | Entertainer(芸人) |
ファシリテーター(住人)
- 作者自身が使いかつユーザーの生活を改善するプロダクトは健全である
- 自分自身が「そのプロダクトを使いたいか」「ユーザーの生活の何を改善するか」を考える必要がある
- ユーザーが過度な使用をしたり、悪用をしていることへの倫理的抑制は、作者の義務である
- 習慣化するプロダクトを作るためにファシリテーターであることは必須であるといってもよい
ペドラー(商人)
- ユーザーが本当に欲しいものを作るために必要な共感力や洞察力が欠けている傾向がある
- ユーザーにとって価値のない作業を「バッジやポイントのようなインセンティブを用いてゲーム化する」ようなことがある
エンターテイナー(芸人)
- 自分たちが楽しむ為だけにプロダクトをつくっている
- エンターテイメントは芸術でありそれ自体に価値があるが、人々に継続的に使用してもらうには難易度が高い
- 常に新鮮味のある斬新なものを作り続けなくてはいけない
ディーラー(売人)
- 作者が生活の改善を考えることなく、自分自身も使わないプロダクトは搾取である
- おそらく金銭的な目的であることが多い
読書メモを初めてみた感想
- ほぼメモになってしまった..
- 世の中で本の感想などを書いている人は尊敬します
- 流し読みするより時間がかかるが理解は深まるかも