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に作ってもらいました。