はじめに
2ヶ月前くらいにReactの勉強がてら以下のようなアプリケーションを作りました。
なんのことのない外部APIを叩くだけのよくあるアプリケーションです。
(一部クリップボードにカードをコピーして貼り付けるという作業をしています...)
ただこんなよくあるアプリですが、当時はまだReactを勉強し始めて1ヶ月くらいだったこともあり、実装内容を見返してみると変だなと思う箇所が結構見つかりました。
そこで「これはReactを学び直すいい機会なのでは?」と思い、改めてReactの設計思想やコンポーネント設計について学び直し、それをもとにリファクタリングしてみました。
これが思いのほか非常に良い勉強になったため、「学んだことをちゃんとメモしておこう」と思い、記事を執筆することにしました。
本記事が少しでもみなさんの参考になれば嬉しいです。
ぜひ最後までご覧ください。
対象読者
- Reactでアプリケーション開発をしたい人
- Reactに触ったことはあるけど、設計思想や考え方についてイマイチわからない人
- Reactに興味ある人
※注意点
私自身まだ未経験のエンジニアであり、規模の大きなプロジェクトや実際にReactを扱っている現場に関わった経験がありません。
そのため現場の人から見ると一部おかしな記述などがあるかもしれません。
そういった場合はぜひコメント欄などで指摘していただけると幸いです。
よろしくお願いします🙇♂️
目次
章 | タイトル | サブタイトル | 備考 |
---|---|---|---|
はじめに | |||
対象読者 | |||
※注意点 | |||
目次 | |||
1 | Reactを支える2つの思想 | 本記事のメイン部分です。 Reactというライブラリを扱っていく上で重要な「2つの思想」について詳しく見ていきます。 |
|
1-1. コンポーネント指向 | |||
1-2. 宣言的UI | |||
2 | 見直しの方針 | 実際にコードを見ながら「Reactらしいコードを書くにはどうすればよいのか?」について考えていきます。 | |
3 | コンポーネントの役割の明文化 | 「コンポーネント指向」という考え方を中心に設計の見直しを行っていきます。 | |
4 | 宣言性の向上 | 「宣言的」というキーワードを中心にコンポーネントを設計し直していきます。 | |
5 | 最後に | ||
6 | 参考文献 |
1. Reactを支える2つの思想
コーディング内容や設計の見直しを行っていく前に、まずは「React」というライブラリを支える2つの思想について振り返っていきましょう。
アプリケーションの基盤となるライブラリは、その背景にある思想をしっかりと理解した上で使いこなしていくことが重要です。
今回の見直し作業も当然この「Reactの思想」に当てはめながら行っていくことを前提としています。
さてReactを支える思想についてですが、大きく以下の2つに分かれます。
1-1. コンポーネント指向
まず1つ目が「コンポーネント指向」です。
Reactはコンポーネント指向のUIライブラリとして広く知られています。
そもそも「コンポーネント指向」とは、「アプリケーション開発において、分割した部品(コンポーネント)を組み合わせて開発する考え方」のことを言います。
イメージ的には、LEGOのように様々なパーツを組み合わせて大きなものを作っていくのに近いです。
例えば、以下はReact公式ドキュメントの例ですが、この例ではページを5つのコンポーネント単位に分けてUIを構築しています。
5 種類のコンポーネントがこのアプリの中にあることが見て取れます。それぞれの解説の中で、データを表すものについては太字にしました。図中の番号は以下の番号と対応しています。
- FilterableProductTable(オレンジ色): このサンプル全体を含む
- SearchBar(青色): すべてのユーザ入力を受け付ける
- ProductTable(緑色): ユーザ入力に基づくデータの集合を表示・フィルタする
- ProductCategoryRow(水色): カテゴリを見出しとして表示する
- ProductRow(赤色): 各商品を 1 行で表示する
上記のドキュメントの例では、最終的に以下のような階層構造にコンポーネントを並べてページを組み立てていきます。
- FilterableProductTable
- SearchBar
- ProductTable
- ProductCategoryRow
- ProductRow
コンポーネント指向の大きな特徴として、このように「各コンポーネントが親子関係を持つことができる」という点が挙げられます。
(上記の例でも、1番上のFilterableProductTableを「親コンポーネント」とし、その配下にSearchBarとProductTableという「子コンポーネント」を含めています。)
Reactではコンポーネントは基本的に関数として扱われますので(後で詳しく説明します)、関数の中で別の関数を呼び出していくというイメージになります。
これにより大きなUIを複数の要素に分けて構築することが可能になり、実装者は見通しよくアプリケーション開発を行うことができます。
さて、このコンポーネント指向ですが、実は「分割統治法」と「単一責任の原則」という考え方がベースにあります。
イメージとしてはベースに「分割統治法」という抽象的な考え方があって、それを実現するためには「単一責任原則」というルールが存在し、それらの具体的な設計案として「コンポーネント指向」がある...というイメージです。
ひとつひとつ説明していきます。
分割統治法
分割統治法(ぶんかつとうちほう、英: divide-and-conquer method)は、そのままでは解決できない大きな問題を小さな問題に分割し、その全てを解決することで、最終的に最初の問題全体を解決する、という問題解決の手法である。
※引用元 Wikipedia: 分割統治法
分割統治法とは、大きな問題を小さく分割して解決していく手法です。(そのまま)
いわゆる「困難は分割せよ」です。
ソフトウェア開発では、大きなソフトウェアを一気に作るのではなくできるだけ分割して、影響の範囲を限定し一つ一つ完成させていくという意味で用いられます。
例えば「アプリを開発する」といった大きな課題に対して、「要件定義→設計→実装→テスト」など小さな課題に分割していくことも分割統治法の一種になります。
Reactの場合、UIをコンポーネント単位に分割して実装するというのがまさに「分割統治法」の考え方を踏襲しています。
単一責任の原則
単一責任の原則 (たんいつせきにんのげんそく、英: single-responsibility principle) は、プログラミングに関する原則であり、モジュール、クラスまたは関数は、単一の機能について責任を持ち、その機能をカプセル化するべきであるという原則である。モジュール、クラスまたは関数が提供するサービスは、その責任と一致している必要がある。
※引用元 Wikipedia: 単一責任原則
次に「単一責任の原則」ですが、これは関数やクラスなどをカプセル化しその責務を一つにするという考え方です。
責務を限定することで各責務を単一の疎結合の状態にすることができ、互いに影響を及ぼし合わなくすることができます。
これにより「再利用性の向上」「可読性の向上」「疎結合によるバグの混入↓」というメリットを享受できます。
Reactも当然、各コンポーネントは「単一責任の原則」に従うことが理想とされており、それはドキュメントにもしっかり書かれています。
しかし、どうやって単一のコンポーネントに括るべき範囲を見つけられるのでしょうか。ちょうど、新しい関数やオブジェクトをいつ作るのかを決めるときと、同じ手法が使えます。このような手法のひとつに、単一責任の原則 (single responsibility principle) があり、これはすなわち、ひとつのコンポーネントは理想的にはひとつのことだけをするべきだということです。 将来、コンポーネントが肥大化してしまった場合には、小さなコンポーネントに分割するべきです。
とはいうものの、これだけだとReactの文脈における「単一責任の原則」がいまいちわかりづらい気がします。
Reactの場合、コンポーネントにどんな役割が与えられているのでしょうか?
ということで、ここからは実際のコードを見ながら「コンポーネントの果たす役割」について具体的に見ていくことにしましょう。
Reactにおけるコンポーネントの責務
先に結論からいうと、Reactでは1MVCアーキテクチャのように「技術的役割」によって2関心の分離を行うのではなく、「機能単位」での関心の分離を行っています。
つまり、Reactのコンポーネントに与えられた役割というのは「ある特定の機能を実現させるために必要なものすべて」なのです。
【補足:もう少しわかりやすく】
簡単に言うと画面描画に必要なスタイリングとロジックがすべてコンポーネント内に閉じ込められているということです。
画面表示に必要なデータをとってくるのも、ユーザーが発生させたイベントをどう処理するのか?もすべてコンポーネントが行います。
言葉だけではイメージしづらいと思いますので、ここからは「技術的役割で分離した場合(MVC)」と「機能単位で分離した場合(React)」のTODOアプリの実装例を比較しながら、
- 「機能単位での分離」とはどういうことなのか?
- 「MVC」では何が辛いのか?それを解消するためにReactはどうしているのか?
について具体的に見ていくことにしましょう。
(以下のようなタスクを追加していくだけの簡単な機能を持ったTODOアプリを想定しています。)
それぞれの実装のコア部分
先にMVCで分離した実装例を載せておきます。
(本題と関係のない実装に関しては一部省略させていただいています。)
<!DOCTYPE html>
<html lang="ja">
<head>
<!-- 省略 -->
</head>
<body>
<h1>TODOリスト</h1>
<form id="task-send-form">
<input id="task-input" />
<button>登録する</button>
</form>
<ul id="todos"></ul>
<script src="index.js"></script>
</body>
</html>
// モデルの定義
class TodoListModel {
constructor() {
this.idCounter = 0;
this.todos = new Map();
}
// task を todo として todoList に追加する.
addTodo(task) {
this.idCounter += 1;
this.todos.set(this.idCounter, {
id: this.idCounter,
task,
});
return this.idCounter;
}
// 指定したidのtodoを取得
getTodo(id) {
return this.todos.get(id);
}
}
const todoList = new TodoListModel();
// ビューの定義
class View {
// todoをUIに追加する
addTodo(todo) {
const todosEl = document.getElementById("todos");
const todoEl = this._createTodoElement(todo);
todosEl.appendChild(todoEl);
}
// input form をリセットする
resetTodo() {
// 省略
}
// todoを受け取り、TODO要素を作る
_createTodoElement(todo) {
// 省略
}
}
const view = new View();
// コントローラーの定義
class Controller {
setup() {
this.handleSubmitForm();
}
// タスク送信時にTODO追加とUI反映をする
handleSubmitForm() {
const formEl = document.getElementById("task-send-form");
// タスク送信時に実行される関数を定義
formEl.addEventListener("submit", (e) => {
e.preventDefault();
// input要素からタスクを取得
const input = document.getElementById("task-input");
const task = input.value;
if (!task.length > 0) {
// タスクに何も含まれてない場合の処理
}
// モデルにタスクを追加し、UIに反映させる
const addedTodoId = todoList.addTodo(task);
const todo = todoList.getTodo(addedTodoId);
view.addTodo(todo);
view.resetTodo();
});
}
}
const formController = new Controller();
formController.setup();
MVCの実装の全体像としては以下の図のようになります。
ここで大切なのが、ControllerがModelとViewの橋渡しを行っているということです。
つまりControllerがユーザーが発火させたイベントを処理し、その結果をModelとViewにそれぞれ反映させているのです。
// ユーザーがタスクを送信した時に実行される関数を定義
formEl.addEventListener("submit", (e) => {
// 省略
// ModelとViewを更新。橋渡し的役割を果たしている
// Modelの更新
const addedTodoId = todoList.addTodo(task);
const todo = todoList.getTodo(addedTodoId);
// Viewの更新
view.addTodo(todo);
view.resetTodo();
}
またViewの実装内容も大切です。
Viewではデータを受け取って表示するための要素をDOMを操作しながらHTMLに追加していきます。
この際に、基本的には「オブジェクトのメソッドやプロパティをいじりながら愚直に処理を1つずつ記述している」という点を押さえておいてください。
class View {
// todoをUIに追加する。処理をひとつずつ記述していく必要があり、大変
addTodo(todo) {
// 要素情報を取得
const todosEl = document.getElementById("todos");
// データを受け取り、要素を新規作成
const todoEl = this._createTodoElement(todo);
// 要素を追加
todosEl.appendChild(todoEl);
}
// todoを受け取り、TODO要素を作る
_createTodoElement(todo) {
const { id, task } = todo;
// 要素を新規作成
const todoEl = document.createElement("li");
// idを付与
todoEl.id = `todo-${id}`;
// テキストの要素をTODO要素に挿入
const textEl = document.createElement("p");
textEl.textContent = task;
todoEl.appendChild(textEl);
return todoEl;
}
}
まとめると、MVCの実装で大切なのは
- View部分では、オブジェクトをいじりながらDOM操作を愚直に実行する必要がある
- ModelとViewの同期はControllerに行わせ、実装側が同期処理を記述する必要がある
ということです。
これを踏まえた上で、ReactでこのTODOアプリを実装した場合を考えていきます。
実装例は以下のようになります。(useStateについてはあとで詳しく説明しますので雰囲気だけ掴んでいただければ大丈夫です。)
import { useState } from "react";
const App = () => {
// アプリ内で扱う「状態」を定義
const [todos, setTodos] = useState([]);
const [idCounter, setIdCounter] = useState(0);
// タスク送信時に実行される関数を定義
const handleSubmit = (e) => {
e.preventDefault();
const inputText = e.target["task"].value;
const nextid = idCounter + 1;
// 「状態」を更新
setIdCounter(nextid);
setTodos([...todos, { id: nextid, task: inputText, checked: false }]);
e.target["task"].value = "";
};
// UIへどのように出力するかを記述
return (
<div className="App">
<h1>TODOリスト</h1>
<form onSubmit={handleSubmit}>
<input name="task" />
<button>登録</button>
</form>
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.task}</li>
))}
</ul>
</div>
);
};
export default App;
まず1番わかりやすい違いとしてはReactの場合HTMLファイルが存在せず、JSファイル内にHTMLっぽいものを記述している点でしょう。
これは「JSX」と呼ばれる記法で、簡単に言うとJavaScriptでHTMLのような記述をできるようにしたものになります。
(上の例だとreturnで返している部分がJSXです。初めて見る人は違和感がすごいと思います)
【補足:JSXについて】
JSXは「JavaScript Syntax Extension」の略称です。
その名の通り「JavaScriptの構文を拡張したもの」となります。
もう少し簡単に言うと、「JavaScriptに標準で含まれていない便利な構文を後付けで使えるようにしたもの」というイメージです。
(構文拡張として有名なものでは他にTypeScriptなどが挙げられます。)
そしてこのJSXは構文拡張であるが故に単体では実行できず、標準のJavaScriptに一度コンパイルされてから実行されることになります。
以下は「Babel」というコンパイラでJSXをコンパイルした結果を表しています。
JSXはさらにここから「React要素」と呼ばれるJSオブジェクトに変換され、仮想DOMとしてメモリ上に保持されます。
もうひとつ特徴的なのが、関数内でstateと呼ばれるものを定義していることでしょう。
これはアプリケーションの「状態」を表すもので、これこそがReactの「核」となります。
(あとで詳しく説明します。)
つまり、React側の実装の肝は
- HTMLファイルが存在せず、JSXと呼ばれる構文で画面描写を表現している
- アプリ内で「状態」と呼ばれるものを定義し、基本的にはその「状態」を扱っていく
ということになります。
ここまでの内容をまとめると、以下の表のようになります。
両者の実装で大切な部分を少し言い換えて比較してみました。
いくつか見慣れないキーワードがあると思いますが、このあとひとつずつ説明していきます。
MVC | JSX |
---|---|
UI(View)の表現方法として、オブジェクトを書き換えながらDOMを操作するという「変化の過程」を書いていく必要がある=「命令的UI」 | UIの表現方法としては、JSXを用いて最終的に出力すべき「結果」のみを書けばよい=「宣言的UI」 |
データ(Model)とUI(View)の同期をコントローラ内で手動で行う必要がある | stateを更新するとUIの更新はライブラリ側で自動で行ってくれる。UI = f(state) |
違い①:UIの表現法の違い
まずUIの表現法の違いについて見ていきましょう。
MVCではViewの以下のコードでUIへの描写を行っていました。
class View {
// todoをUIに追加する。処理をひとつずつ記述していく必要があり、大変
addTodo(todo) {
// 要素情報を取得
const todosEl = document.getElementById("todos");
// データを受け取り、要素を新規作成
const todoEl = this._createTodoElement(todo);
// 要素を追加
todosEl.appendChild(todoEl);
}
// todoを受け取り、TODO要素を作る
_createTodoElement(todo) {
const { id, task } = todo;
// 要素を新規作成
const todoEl = document.createElement("li");
// idを付与
todoEl.id = `todo-${id}`;
// テキストの要素をTODO要素に挿入
const textEl = document.createElement("p");
textEl.textContent = task;
todoEl.appendChild(textEl);
return todoEl;
}
}
こんな風にMVCでは要素情報の入ったオブジェクトを直接操作して画面描写を行っていきます。
これは言い換えるとUIを表現するには、「オブジェクトの変化の過程」をひとつずつ記述していく必要があるということです。
今回の例ではユーザーがタスクを追加したら、それに該当する要素(TODO要素)を作ってそれにテキストを挿入して(textEl)オブジェクトへ追加する(todoEl.appendChild)といったことをいちいち記述していく必要があります。
さらにこれの辛い点としては、各要素にひとつずつidを振り分けていく必要がある(todoEl.id=...)ことも挙げられるでしょう。
タスクを追加していくという単純なTODOアプリひとつとっても、このようにMVCで実装した場合なかなかに面倒くさいです。
(このようにDOMを直接操作してUIを構築する方法を後述の「宣言的UI」と比較して、「命令的UI」と呼んだりします)
ではReactの場合はどのようにUIを表現しているのでしょうか?
上記のような命令的UIは、要は「ユーザーの操作に対応したオブジェクトの状態変化」をいちいち記述していく必要があるから、考慮すべきことが増えて大変なのでした。
この問題を解決するためReactのような宣言的UIは「ユーザーの操作に関わらず一度画面を全て消してから新たに再構築する」というアプローチをとります。
つまり要素オブジェクトの変化を一度リセットして、画面に描写する際は1から画面に必要な要素オブジェクトを再構築して描画し直しているのです。
こうすることでユーザーがたとえどんな操作をしたとしても以前の状態を覚えておく必要がなくなり、「ゼロから」の場合のみ考慮して実装すれば良くなります。
先述したidを振り分けていく作業も必要なくなり、実装量も抑えられそうです。
つまり、ReactでUIを表現したい場合、最終的に出力したい「結果」のみを書けば良いことになります。
そしてこの結果の出力に用いられるのが「JSX」で、TODOアプリでは以下の部分にあたります。
(このJSXは先程も言いましたが、JavaScriptのオブジェクトに最終的には変換されます。)
const App = () => {
// 省略
return (
<div className="App">
<h1>TODOリスト</h1>
<form onSubmit={/** フォーム送信時に実行される関数 */}>
<input name="task" />
<button>登録</button>
</form>
<ul>
{/** UI構築に必要なデータの表示処理 */}
{todos.map((todo) => (
<li key={todo.id}>{todo.task}</li>
))}
</ul>
</div>
);
};
見ての通り、「オブジェクトを書き換えながらDOMを操作する」という作業は一切していないです。
(todosは後述のstateと呼ばれるもの)
さらに、結果の記述方法として「関数」を用いていることがわかるでしょう。
この関数の戻り値(JSX)が、「最終的に画面に描写したい結果部分」に該当します。
違い②:データの扱い方の違い
次にデータの扱いについて見ていきましょう
まず前提としてクライアントサイドでの動的な動きをベースにしたアプリケーションの場合、当然ユーザー操作に紐づくデータを管理する必要がでてきます。
TODOアプリケーションの場合は、「フォームに入力するタスク」がこのデータに該当し、MVCではこのデータをModel層に切り出して管理していました。
class TodoListModel {
constructor() {
this.idCounter = 0;
this.todos = new Map();
}
/**
* task を todo として todoList に追加する.
* @param {string} task
* @returns 追加された todo の id
*/
addTodo(task) {
this.idCounter += 1;
this.todos.set(this.idCounter, {
id: this.idCounter,
task,
});
return this.idCounter;
}
/**
* 指定したidのTODOを取得
* @param {number} id TODOのid
* @returns TODO
*/
getTodo(id) {
return this.todos.get(id);
}
}
そして、ユーザーの操作に応じてControllerがこのModelのデータを書き換え、Viewも更新しているのでした。
つまり、MVCではデータとUIの同期を手動で行う必要があります。
これは実装量的に見てもかなり大変ですし、また万が一どちらかの更新を忘れてしまうとMとVが乖離してしまって大変なことになります。
(TODOアプリだとタスクを送信したのに画面に表示されていない...といったことが起こり得る😱)
一方、Reactの場合はどのようにユーザー操作に紐づくデータを管理し、画面に反映させているのでしょう??
Reactではこのようなユーザー操作に紐づくデータを含め「アプリケーションに関する状態」というものを予め定義し、その「状態」をstateというものを用いて管理していきます。
そしてこのstateが更新されればあとは自動でReact側がUIへの反映を行ってくれるという仕組みになっています。
これが巷でよく言われる「UI = f(state)」
というものです。
例のTODOアプリの場合、以下のようにtodosという「ユーザーが入力したタスクに関するデータ」をstateとして定義し、それを更新すればあとは勝手にReact側が(JSXを参考に)画面を更新してくれるという挙動をとります。
const App = () => {
// stateの定義
const [todos, setTodos] = useState([]);
const [idCounter, setIdCounter] = useState(0);
// ユーザー操作にしたがって、stateを更新する関数
const handleSubmit = (e) => {
e.preventDefault();
const inputText = e.target["task"].value;
const nextid = idCounter + 1;
setIdCounter(nextid);
setTodos([...todos, { id: nextid, task: inputText, checked: false }]);
e.target["task"].value = "";
};
return (
{/** JSX */}
);
};
つまりReactはユーザーがアプリケーションを使うということを「状態(state)」を操作することであるとみなし、UIというのはただ「現在の状態」が「JSX」という映写装置によって画面に映し出される結果に過ぎないという考え方をしているのです。
先ほどの「UIの表現法」と合わせて考えると、JSXという「画面に最終的に出力させたい結果(=UIの雛形)」に「stateと呼ばれるアプリケーションの状態」を流し込むことでUIを表しているのです。
UIはただの「映写結果」。JSXという映写装置によって「現在の状態」が画面に映し出され、ユーザーはそれを見ているに過ぎない。
Reactにとっては、「状態」こそがアプリケーションのコアなのです。
(しつこいようですが)UIが動いているように見えても、それはただ背後にあるstateが変化した結果としてUIが毎回ゼロから(JSXをベースに)再構築されているに過ぎないという考え方をしているのです。
以上の内容をまとめると宣言的UIとはおおよそ以下のような挙動をとることになります。
図中の実線部分は実装者側が書くべき部分で、点線部分がライブラリ側が担当してくれる部分です。
(fというのは「コンポーネントがJSXを返す関数であること」に由来しています。)
これを見れば分かる通り「DOMを直接操作しUIに反映させる」という面倒な作業は実装者側では基本的に行いません。
そのような面倒くさい作業はすべてライブラリ側で自動でやってくれるのです。
その代わり
- 「状態」(state)がユーザーが操作した際にどのように変化するのか?
- その状態をUIにどう映写するのか?(JSX)
という2つの部分だけはちゃんと実装する必要があります。
こうすることでどんなに大きく複雑なアプリを作るにしても、コードを書く人は基本的には「状態」にのみ目を向ければ良くなり、MVCのようにあっちこっち目線を移動させることがなくなります。
これがReactのような「宣言的UIライブラリ」を用いる最大のメリットです
さてここまでの話を一旦まとめましょう。
長々と話してきましたが、今考えていることとしては「技術的役割(MVC)と機能的役割(コンポーネント)の違いってなんだろう?」「Reactにおけるコンポーネントの役割ってなんなんだろう?」ということでした。
TODOアプリの実装例とこれまでの話をまとめるとReactと(Reactにおける)コンポーネントには以下のような特徴があることがわかります。
Reactのコンポーネントというのは、このように「UIへの描写内容(映写装置)」と「アプリケーションの状態と状態に関する処理」をすべて扱う必要があります。
先ほどのTODOアプリの例だと、JSXというスタイリング部分に加えてユーザーがタスク追加ボタンを押した際に「状態がどのように変化するのか?」というロジック部分をコンポーネント内に記述していく必要があります。
こうして考えていくとReactのコンポーネントの役割が「ある特定の機能を実現させるために必要なものすべて」であることを理解できるのではないでしょうか?
1-2. 宣言的UI
ここまで長々と「コンポーネント指向」について話してきましたが、Reactを支える思想として大切なものはもうひとつあります。
それが「宣言的UI」です。
ただこの「宣言的UI」の基本的な部分については先程の「コンポーネントが果たす責務」のところでほぼ説明し終わっています。
念の為先ほどの内容を簡単に振り返っておきます。
まず従来のクライアントアプリ開発でUIを表現するためには、オブジェクトの書き換えというような「変化の過程」を直接記述する必要があるのでした。
そして、こういったUI構築法は手続き的にDOM操作を記述していくため「命令的UI」と呼ばれたりします。
一方、Reactではそうした「変化の過程」を記述するのではなく「結果」のみを記述すればよいのでした。
具体的には、「ユーザーが操作しようがしまいがそれまで画面がもっていた状態をすべてリセットし、新たにゼロから画面を再構築する」というアプローチをとります。
つまり、先に実現したい結果を「宣言」しておくのです。
これがいわゆる「宣言的UI」と呼ばれるものです。
結果さえ先に書いておけば、ユーザー操作により画面上(正確には「アプリの状態」)に何かしらの変化があった場合もReact側がよしなにそれを反映してくれるのでした。
以上が、宣言的UIのざっくりした説明です。
仮想DOM
さてこの宣言的UIですが、ひとつ問題点があります。
それは「画面上のどんな小さな変化に対しても画面のすべてを毎回再構築してしまう」という点です。
例えば先程のTODOアプリにチェックボックスをつけて、タスクを完了済みにできる機能をつけたとしましょう。
画面上にひとつのタスクしかない場合はまだ良いものの、タスクの量が増えてきてタスクが完了済みになるたびに画面上のすべての要素を再構築してしまったらインタラクティブ性がかなり悪くなりそうです。
つまり、多機能になればなるほど細かいサイクルで画面を変化させる必要がでてきてしまい、パフォーマンスが悪くなってしまうのです。
こうした問題を防ぐため、Reactでは「仮想DOM」と呼ばれる方法を採用しています。
これはざっくりいうと、内部的にはUIの内容を全部構築しながらも画面に描写する際は前回の表示との差分だけを検出してリアルDOMに反映させていくという方法になります。
「差分だけを更新する」という部分においては先述の「命令的UI」と同等ですが、Reactの場合この「差分を検出して更新する」という処理を裏で自動でやってくれているのが大きく違っています。
【補足:仮想DOMの仕組みについて詳しく知りたい方へ】
仮想DOMからどうやってReal DOMへ反映させるか?など知りたい方は以下の記事が参考になるかと思います。
(若干古い記事も存在していますが😅)
自分もちゃんと理解してるわけではないので、いつか勉強できたらいいな〜とひそかに思っている分野でもあります。
さてここまでで「宣言的UI」と「Reacがそれをどのようにうまく行っているのか」について見てきました。
ただReactの場合この「宣言的」について実はかなり考慮しており、それ故Reactの思想を理解するためには「宣言的」についてより深く理解しておく必要があります。
そのときに重要になってくる概念が「関数型プログラミング」と呼ばれるものです。
関数型プログラミング
3関数型プログラミングとはざっくりいうと「手続き型の処理を(なるべく)関数に隠蔽して、やりたいことに集中できるようにするプログラミング手法」を指します。
例えば、「特定の配列の要素に格納されている値を2倍した配列を求める」という機能を実現する場合を考えましょう。
従来のいわゆる「手続き型」と呼ばれるプログラミング手法では、以下のような実装内容になります
let nums = [1, 2, 3];
let doubleNums = [];
for (let i = 0; i < nums.length; i++) {
const double = nums[i] * 2;
doubleNums.push(double);
}
一方、「関数型」で実装した場合、以下のようになります
let nums = [1, 2, 3];
let doubleNums = nums.map(num => num * 2);
手続き型が、「変数を宣言する命令」「ループするための命令」「ループ内で各要素を2倍する命令」「その値を新たな配列にpushする命令」...というように1つ1つの命令を上から順に並べていくのに対して、関数型ではそうした手続き的処理が関数内に隠蔽されていることがわかります。
(ここではループ処理がmapメソッドに隠蔽されている)
つまり、開発者は本来実装したい内容である「各要素を2倍する」という処理にのみ注力すればよくなります。
このように関数型プログラミングというのは、「手続き的な処理」を関数の力を借りてできる限り隠蔽して、開発者が本来実装したい内容に集中できるようにする手法になります。
そして、こうした手続き的処理が減れば減るほど「宣言性」が増していくこともコードを見れば理解できるでしょう。
【補足】関数型と手続き型は混在する
先程mapメソッドは「ループ処理に関する手続き的処理を隠蔽している」と言いました。
ではそのmapメソッドの中身はどうなっているのでしょう?
実はこのmapメソッドの中身は「手続き型」で書かれています。
map(callback) {
let new_arry = new Array();
for (let i = 0; i < this.length; i++) {
const value = this[i];
new_arry.push(callback(value, i, this));
}
return new_arry;
}
つまり、関数型プログラミングというのは、あくまで「利用する」側から見た場合に「関数」として使えるだけであって、関数を「実装する」側から見たら「手続き型の処理」も混在してくるわけです。
そのため関数型プログラミングとは正確にいうと「利用する側から見て」手続き的な処理を減らしていこうというプログラミング手法になります。
Reactと関数型プログラミング
Reactにおいてこの「関数型プログラミング」の思想が最も反映されているのがReact16.8から追加された「フックAPI」です。
ということでここからはReactの「宣言性」に対するこだわりをフックAPIから見ていくことにしましょう。
useState Hook
まず触れておかなければならないのが、「クラスコンポーネント」の存在です。
フックAPI導入以前のReactでは関数コンポーネントでstateを管理することができず、基本的にはクラスコンポーネントを利用してアプリ開発を行っていました。
しかし、フックAPIの登場で関数コンポーネント内でstateを扱えるようになると事態は一変します。
フックはそれまでクラスコンポーネントのみでしかできなかったことを実現させただけでなく「宣言性の向上」や「ロジックの分離」といったことも可能にし、クラスコンポーネントの時代よりも優れた設計でコンポーネントを実装できるようにしました。
これによりクラスコンポーネントは(一部の例外を除いて)ほとんど使われなくなったそうです。
ではここからはクラスコンポーネントと関数コンポーネントを比較しながらフックAPIを用いることによる「宣言性の向上」について見ていくことにします。
以下のようなボタンを押すとカウントアップするアプリケーションを例に考えていきます。
このカウントアップアプリをクラスコンポーネントで作った場合、以下のようになります。
import { Component } from "react";
// コンポーネントを定義
class Counter1 extends Component {
constructor(props) {
super(props);
// stateを定義
this.state = { count: 0 };
}
// クラスコンポーネントではrenderメソッド内にJSXを記述していく
render() {
const { count } = this.state;
return (
<>
<button
onClick={() => this.setState((state) => ({ count: state.count + 1 }))}
>
カウントアップボタン
</button>
<span>:{count}</span>
</>
);
}
}
export default Counter1;
上記を見ると、クラスコンポーネントではstateの初期化をコンストラクタ(コンポーネントインスタンスが作成されたときに実行される関数)内で行っていることがわかります。
constructor(props) {
super(props);
this.state = { count: 0 };
}
これは「コンポーネント作成時に特定の値でstateを初期化する」ということをクラスのメソッドを用いて「命令的に」表現していることになりますね。
(特定のタイミングで特定の処理を実行するということを愚直にconstructorを用いて書いている)
一方、関数コンポーネントで作った場合は、以下のようなコードになります。
import React from "react";
import { useState } from "react";
const Counter2 = () => {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount((prev) => prev + 1)}>
カウントアップボタン
</button>
<span>:{count}</span>
</>
);
};
export default Counter2;
関数コンポーネントの場合はuseStateというフックを呼び出し、stateを定義しています。
そしてこのuseStateの引数に初期値(initialState)をあたえれば、あとは関数内でよしなにstateの初期化処理が行われるようになっています。
const [count, setCount] = useState(0);
つまり、実装側から見たら「stateの初期化処理」というのはuseState内に隠蔽されていることになるのです。
(特定のタイミングで特定の処理を実行させる..ということを愚直に書く必要がなくなる)
これは関数型プログラミングを活用することにより宣言性が増している最も簡単な例でしょう。
useEffect Hook
次に以下のような1秒ごとにカウントアップするアプリケーションを考えてみましょう。
ここで重要になってくるのが「副作用」(Side Effect)と呼ばれる概念です
そもそもプログラミングにおいて「作用」というのは式、関数を評価し値を得る際の何らかの効果を指します。式、関数を評価し値を得ることを期待する効果が「主たる作用」です。
Reactのコンポーネントにおいては「JSXの構築」が「主たる作用」にあたります。
一方、副作用とはそれ以外の状態(グローバル変数など)を変化させる作用のことを指します。
Reactにおいては、JSXの構築に「直接」関係のない処理が「副作用」にあたります。
(例えば、コンソールへのログ出力、サーバーとの通信などが「副作用」の代表例です。)
これを踏まえた上で、上記のタイマーアプリをクラスコンポーネントと関数コンポーネントで作成した場合を比較してみましょう。
import { Component } from "react";
class Timer1 extends Component {
constructor(props) {
super(props);
this.state = { time: 0 };
}
componentDidMount() {
this.timer = setInterval(() => {
this.setState((state) => ({ time: state.time + 1 }));
}, 1000);
}
componentWillUnmount() {
clearInterval(this.timer);
}
render() {
const { time } = this.state;
return (
<h3>
<time>{time}</time>
<span>秒経過</span>
</h3>
);
}
}
export default Timer1;
import React, { useEffect, useState } from "react";
const Timer2 = () => {
const [time, setTime] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setTime((prev) => prev + 1);
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
return (
<h3>
<time>{time}</time>
<span>秒経過</span>
</h3>
);
};
export default Timer2;
両者ともsetIntervalを呼び出し「1秒ごとにsetTimeを繰り返し実行するタイマー」を登録しています。
この「タイマー登録」というのは当然「JSXを構築する」という主作用とは直接的には無関係の処理になりますので「副作用」に該当します。
そしてこの副作用に関する処理を記述する際には、クラスコンポーネントではrenderメソッド以外のメソッドを追加する必要があります。
それが「componentDidMount」と「componentWillUnmount」です。
これらのメソッドについて説明するため前に、まずはコンポーネントのライフサイクル(生成〜消滅までにたどる過程)について触れておきます。
一般的にコンポーネントのライフサイクルには3つの段階があるとされています。
1つ目が「Mounting」と呼ばれる段階。
これは、初回のrender()が呼び出され、画面に反映されるまでの期間のことを指します。
2つ目が「Updating」と呼ばれる段階。
これは、何らかのきっかけで再レンダリングが走り、それが画面に反映されるまでの期間を指します。
最後の「Unmounting」がコンポーネントが不要となり破棄するための期間のことを指します。
※引用元 https://projects.wojtekmaj.pl/react-lifecycle-methods-diagram/
そして、各段階でそれぞれ特別なメソッドが用意されており、実装者はそのタイミングで任意の処理を実行させることが可能になります。
Mounting期間に対応するのが「componentDidMount()」、Updating期間が「componentDidUpdate()」、最後のUnmountingが「componentWillUnmount()」となります。
これらを踏まえた上で上記のクラスコンポーネントの例をもう一度見てみましょう。
一般的に副作用処理というのはクリーンアップ(副作用処理の前後で影響を消す)前提の処理となります。
例えばマウント時に何かしらの副作用を発生させた場合、アンマウント時(もしくはコンポーネントが非表示になった時)にはその副作用による影響を消す必要があります。
(なので実は「useEffect内で一度しか実行させない処理を記述する」といった書き方は基本的に非推奨。React側でもこれは警告してます。)
クラスコンポーネントでは初回マウント時にタイマーを登録し、アンマウント時にそのタイマーを解除するというコードを書いて副作用を記述しています。
componentDidMount() {
this.timer = setInterval(() => {
this.setState((state) => ({ time: state.time + 1 }));
}, 1000);
}
componentWillUnmount() {
clearInterval(this.timer);
}
こうすることで、コンポーネントに紐付いた副作用処理を安全に扱っているのです。
一方、関数コンポーネントにおいて副作用処理はどのように記述されるのでしょうか?
関数コンポーネントでは以下の部分で副作用処理を実装しています
useEffect(() => {
const timer = setInterval(() => {
setTime((prev) => prev + 1);
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
このように、関数コンポーネントで副作用を宣言する場合はuseEffectフックを利用します。
詳しい説明は省きますが、useEffectは引数にコールバック関数を渡すことでコンポーネントのマウント時に実行される関数を定義することができます。
クラスコンポーネントにおけるcomponentDidMountですね。
上記ではsetIntervalを呼び出し、timerを登録しています。
そして、そのコールバック関数が返す関数(クリーンアップ関数)がコンポーネントがアンマウントする時に実行されます。
クラスコンポーネントでいうところのcomponentWillUnmountです。
上記ではclearIntervalを呼び出しています。
さてそれぞれのコードを見比べると、どちらが宣言的かは明確でしょう。
クラスコンポーネントの場合、「マウントされたらタイマーを登録する」「アンマウントされたらタイマーを解除する」というように「変化の過程」をメソッドを用意して愚直に記述していくため、手続き的な処理が多くなり見通しが悪くなっています。
一方、フックを用いた場合はどうでしょう。
こちらはまずコールバック関数内で「あるべき状態」(ここでは「タイマーが登録されている状態」)に移行することを明示し、その後始末のコードをクリーンアップ関数内に書いているだけです。
若干手続き的(クリーンアップ処理を記述する必要があること)な気もしなくもないですが、クラスコンポーネントのときに「変化の過程」を具体的に記述していたのと比べれば宣言性が増しているのは一目瞭然でしょう。
つまりuseEffectフックではコールバック関数内のコードがいつ実行されるか?を明示する必要がなくなり、Reactによってそういった手続き的処理が隠蔽されているのです。
【注意点:useEffectの依存配列】
厳密にはuseEffectの第2引数にあたる依存配列に「コールバック関数がいつ実行されるのか?」を定義する必要があります。
ただこれを含めたとしても、クラスコンポーネントと比べて関数コンポーネントの方が宣言性が増していることはなんとなく理解できることでしょう。
2. 見直しの方針
さてここまででReactを支える2つの思想について見てきました。
(めちゃくちゃ長くなってすいません😅)
これらを踏まえた上で、ここからは実際に(自分が作った)アプリケーションを題材にして「よりReactらしいコード」を書くためにはどうすればよいのか?を考えていきましょう。
「Reactの思想」に従い、以下の2つの視点を中心に作業していきたいと思います。
(本当はデータフェッチや再レンダリング防止あたりも考慮したいのですが、今回は割愛させていただきます🙇♂️)
- コンポーネントの役割の明文化
- 宣言性の向上
3. コンポーネントの役割の明文化
まずはReactの思想の1つ目「コンポーネント指向」を中心に見ていきます。
ここでは以下の「ユーザー入力に従って漫画を検索しそれを一覧表示する」という機能の実装を例に考えていきます。
現在のプロジェクトのsrcディレクトリ配下、そしてコンポーネント設計は以下のようになっています。
src/
├ api/
│ └ comic.js ... APIを用いて漫画の一覧情報を取得する
├ components/
│ ├ ComicSearch.jsx
│ ├ ComicLists.jsx
│ └ (その他いろいろなコンポーネント)
└ (その他いろいろなディレクトリやファイル)
ディレクトリ構成
まず注目したいのが「ディレクトリ構成」です。
Reactでは、例えばRuby on Railsのような明確なルールが存在しておらず、ディレクトリ構成も基本的には開発者が自由にやりくりできます。
React はファイルをどのようにフォルダ分けするかについての意見を持っていません。
とはいえ、ある程度Reactの思想に則ったディレクトリ構成を意識していくことも重要です。
例えば今回のようなアプリケーションの場合、このようにsrc配下に「components」や「api」というディレクトリを作成し「コードの形態」だけで分類していくディレクトリ構成を採用しています。
src/
├ api/
│ └ comic.js ... APIを用いて漫画の一覧情報を取得する
├ components/
│ ├ ComicSearch.jsx ... 漫画一覧情報と検索窓を表示するコンポーネント
│ ├ ComicLists.jsx ... 漫画一覧情報を表示するコンポーネント
│ └ (その他いろいろなコンポーネント)
└ (その他いろいろなディレクトリやファイル)
これは(今回のように)ある程度機能が限定されたアプリケーションを開発していくことを想定した場合には、各ファイルが何をしているのかが直感的に理解できるので特段問題ないでしょう。
一方でもう少し大規模な開発(例えばユーザー認証や投稿機能が要件として加わった場合)を想定した場合はどうでしょう。
もしcomponentsディレクトリに脳死でコンポーネントを追加していくと、各コンポーネントがどの機能を実現させるために必要なのかぱっと見で理解するのが難しくなっていくのは明白でしょう。
ということでそういった問題を回避しコンポーネントの責務を明確にするため、まずは「ユーザー入力に従って漫画を検索しそれを一覧表示する」という機能に関連するコンポーネントを別ディレクトリに切り出すことにしましょう。
今回は「bulletproof-react」というReactアプリケーションのアーキテクチャの一例として公開されているリポジトリを参考に、featuresディレクトリに切り出してみました。
【補足:bulletproof-reactについて】
bulletproof-reactについて詳しく知りたい方は以下の2つの記事を参考にしてください。
今回採用したfeaturesディレクトリですが、こちらには「アプリケーションが抱えている各機能に関するディレクトリ」が並びます。
例えばusers、auth、commentsなどです。
つまり、ある特定の機能に関するコンポーネントやロジックはすべてこのfeaturesディレクトリの機能ディレクトリの中に格納してしまおう...というイメージです。
こうすることでもしその機能が必要なくなったときにすぐ消せますし、また特定ディレクトリ内に機能が限定されているため関連するファイルも見つけやすくなります。
featuresに切り出したあとのディレクトリ構成は以下のようになります。
src/
├ components/
│ └ (その他いろいろなコンポーネント)
+ ├ features/
+ │ ├ comics/ ... 漫画関連の機能
+ │ │ ├ api/
+ │ │ │ └ comic.js ... APIを用いて漫画の一覧情報を取得する
+ │ │ ├ ComicSearch.jsx
+ │ │ ├ ComicLists.jsx
+ │ │ └ index.js ... comics配下すべてのエクスポートファイルを再エクスポート
+ │ ├ users/ ... ユーザー関連の機能(あくまで一例)
+ │ └ posts/ ... 投稿関連の機能(あくまで一例)
└ (その他いろいろなディレクトリやファイル)
先程までとは違って、少し見通しがよくなったのではないでしょうか?
コンポーネントの実装内容
さて、お次はコンポーネントの中身について見ていきましょう。
「キーワードをもとに漫画を検索し、一覧表示する」という機能は基本的には以下のComicSearchコンポーネントにより実現されています。
(わかりやすくするため実際のアプリケーションから一部機能を減らしたり、本題と関係のないコードを一部省略させていただいています。)
import { useRef, useState } from 'react';
import comicApi from './api/comic'; // APIから漫画のデータを取得するオブジェクト
import ComicLists from './ComicLists'; // 取得した漫画のデータを表示するコンポーネント
const ComicSearch = () => {
const inputRef = useRef(); // フォームを参照するrefオブジェクト
const [comics, setComics] = useState([]); // 取得した漫画データを格納するstate
// ユーザーがキーワードを入力して検索をかけた際に行われる関数
const submitHandler = e => {
e.preventDefault();
if (inputRef.current.value) {
// 入力されたキーワードを用いてAPIにリクエストを投げて、漫画データを取得する処理
comicApi
.getAll(`keyword=${encodeURIComponent(inputRef.current.value)}`)
.then(({ items }) => {
setComics(items);
})
.catch(e => { /** 漫画データの取得に失敗した際の処理 */ })
} else {
// 入力欄が空欄だった場合の処理
}
};
// コンポーネントの中身
return (
<Container maxW="container.lg">
{/** 検索フォーム */}
<Center>
<form onSubmit={submitHandler}>
<InputGroup>
<InputLeftElement pointerEvents="none" children={<SearchIcon />} />
<Input
placeholder="検索したい漫画を入力"
variant="outline"
size="md"
maxW="md"
ref={inputRef}
borderRadius="99999999"
bg="white"
/>
</InputGroup>
</form>
</Center>
{/** 漫画を一覧表示 */}
<ComicLists {...{ comics }} />
</Container>
);
};
export default ComicSearch;
コンポーネントを「機能によって分割されたスタイルとロジックをカプセル化したもの」と考えた場合、このままでも特に問題はないでしょう。
しかし、実際にプロジェクトを継続的に運用する場合はもう少し責務を分割して「見た目だけを担当するコンポーネント」と「その見た目にロジックを追加するコンポーネント」に分けることが一般的です。
このようにロジックとスタイルをしっかり分けて実装することで「関心の分離」を図るデザインパターンを
「Container/Presentationalパターン」
と呼びます。
Reactの文脈では、ロジックを責務とするContainer Componentと、スタイルを責務とするPresentational Componentに分けて実装することを指します。
こうすることで、以下の3つのメリットが得られるとされています。
- コンポーネントの責務がより明確になり、要件に応じてどこを修正すればよいのかはっきりする
- Presentational Componentが再利用しやすくなる
- 各コンポーネントで何をテストすればよいのか明確になるため、テストが容易になる
(特にPresentationalは純粋関数であるためストアへのアクセスなどがいらずテストがめちゃ簡単)
今回の場合ComicSearchコンポーネントは「検索フォーム」と「全体のレイアウト」いうスタイル部分を含んでいます。
const ComicSearch = () => {
// stateの定義やAPIからデータを取得する部分は省略
// コンポーネントの中身
return (
{/** 全体のレイアウト */}
<Container maxW="container.lg">
{/** 検索フォーム */}
<Center>
<form onSubmit={submitHandler}>
<InputGroup>
<InputLeftElement pointerEvents="none" children={<SearchIcon />} />
<Input
placeholder="検索したい漫画を入力"
variant="outline"
size="md"
maxW="md"
ref={inputRef}
borderRadius="99999999"
bg="white"
/>
</InputGroup>
</form>
</Center>
{/** 漫画を一覧表示 */}
<ComicLists {...{ comics }} />
</Container>
);
};
export default ComicSearch;
そのため、この「フォーム部分」と「実装済みのComicListsコンポーネント」をPresentational Componentとして切り出し、ComicSearch ComponentをContainer Componentとして定義することにしましょう。
さらに全体のレイアウト(Containerで囲っている部分)も、再利用性を高めるために別ファイルとして切り出しておきます。
イメージとしては以下のようになります。
(赤が「Container」青が「Presentational」)
ディレクトリ構成は以下の通りになります。
src/
├ components/ ... アプリケーション全体で使用できる共通コンポーネント
+ │ ├ Layout/
+ │ │ └ ContentLayout.jsx ... 全体のレイアウト
│ └ (その他いろいろなディレクトリやファイル)
├ features/
│ ├ comics/ ... 漫画関連の機能
│ │ ├ api/
│ │ │ └ comic.js ... APIを用いて漫画の一覧情報を取得する
+ │ │ ├ components/
+ │ │ │ ├ ComicSearchContainer.jsx ... Container Component
+ │ │ │ ├ ComicLists.jsx ... Presentational Component
+ │ │ │ └ SearchInput.jsx ... Presentational Component
│ │ └ index.js ... comics配下すべてのエクスポートファイルを再エクスポート
│ ├ users/ ... ユーザー関連の機能(あくまで一例)
│ └ posts/ ... 投稿関連の機能(あくまで一例)
└ (その他いろいろなディレクトリやファイル)
まずcomicsディレクトリ配下にcomponentsディレクトリを作成し、ここに先程作成したContainer ComponentとPresentational Componentを配置しました。
これはこの機能でのみ限定的に用いられるコンポーネントであることを強調したかったため作成しました。
このあたりも「bulletproof-react」を参考にしています。
(ただ検索フォームに関しては横断的に用いられることが容易に想像できるため、src直下のcomponentsディレクトリに配置するのもありかも)
次に、src直下のcomponentsディレクトリですが、ここにはレイアウトに関するファイルを配置しています。
なぜfeaturesディレクトリに置かなかったのか?というと、このフォーム+コンテンツ一覧を含むレイアウトは今後横断的に用いる可能性があるからです。
例えばユーザー投稿機能を設けて、ユーザーが好きな漫画の名言などをアプリ内に投稿できるようにしたい場合、その一覧情報を表示する際に似たようなレイアウトの構成を組むことになるでしょう。
次にコンポーネントの中身は以下のようになります
import { useRef, useState } from 'react';
import comicApi from '../api/comic';
import ComicLists from './ComicLists';
import SearchInput from './SearchInput';
const ComicSearchContainer = () => {
const inputRef = useRef(); // フォームを参照するオブジェクト
const [comics, setComics] = useState([]); // 取得した漫画データを格納するstate
// ユーザーがキーワードを入力して検索をかけた際に行われる関数
const submitHandler = e => {
...
};
return (
<>
{/** 検索フォーム */}
<SearchInput {...{ submitHandler, inputRef }} />
{/** 漫画を一覧表示 */}
<ComicLists {...{ comics }} />
</>
);
};
export default ComicSearchContainer;
const SearchInput = ({ submitHandler, inputRef }) => {
return (
<Center>
<form onSubmit={submitHandler}>
<InputGroup>
<InputLeftElement pointerEvents="none" children={<SearchIcon />} />
<Input
placeholder="検索したい漫画を入力"
variant="outline"
size="md"
maxW="md"
ref={inputRef}
borderRadius="99999999"
bg="white"
/>
</InputGroup>
</form>
</Center>
);
};
export default SearchInput;
{/** 全体のレイアウト構成 */}
const ContentLayout = ({ children }) => {
return (
<>
<Container maxW="container.lg" py={10}>
{children}
</Container>
</>
);
};
export default ContentLayout;
ここで大切なのは、以下の2点です。
-
Presentational ComponentはUIに関心を持ち、Propsで受け取ったデータをどのように表示するかの役割のみを果たす。
- 原則状態を持たず、データの受け取り元がPropsに限定されている。
-
Container Componentはアプリケーションのロジックに関心を持ち、APIや状態管理ライブラリから取得したデータをそれぞれのPresentational Componentに渡す役割のみを果たす。
- それらのデータを扱うための状態をもつことも可能。
実際のコードを見ればわかりますが、ComicSearchContainerでは状態を定義し必要なデータを受け渡す役割のみを果たす一方で、SearchInputはpropsを受け取ってレンダリングすることしか行っていません。
const ComicSearchContainer = () => {
// 状態を定義
const inputRef = useRef();
const [comics, setComics] = useState([]);
// 省略
return (
<>
{/** 検索フォームに必要なデータを渡す */}
<SearchInput {...{ submitHandler, inputRef }} />
{/** 漫画一覧表示のコンポーネントに必要なデータを渡す */}
<ComicLists {...{ comics }} />
</>
);
};
export default ComicSearchContainer;
こうすることで、SearchInputは純粋関数(決まった引数に対して決まった値を返す関数)として機能し、再利用性が高まるとともにテストも非常に行いやすくなっています。
【補足:「Reactの流儀」のすすめ】
実はこのContainer/PresentationalデザインパターンはReact公式ドキュメントのほうで直接的な言及はされていませんが、それっぽいことはちゃんと書かれています。
それがこの「Reactの流儀」というページです
この「Reactの流儀」では以下の5つの手順に沿ってコンポーネント設計をしていきます。
- UI をコンポーネントの階層構造に落とし込む
- Reactで静的なバージョンを作成する
- UI 状態を表現する必要かつ十分な state を決定する
- state をどこに配置するべきなのかを明確にする
- 逆方向のデータフローを追加する(stateのリフトアップ)
勘の良い方ならお気づきかもしれませんが、この手順に沿ってコンポーネントを設計していくと自動的にContainerコンポーネントとPresentationalコンポーネントに分かれることになります
具体的には1と2でpropsを受け取ってJSXを返すだけの「Presentationalコンポーネント」を作成し、4と5で必要なstateを定義した「Containerコンポーネント」を作成します。
このように公式ドキュメントにも原則としてコンポーネントを設計する際は「Container/Presentationalデザインパターン」に従うよう(暗黙的に)推奨しているくらいなので、この設計思想がいかに重要か理解できると思います。
4. 宣言性の向上
最後に「宣言性の向上」という観点から上記のアプリケーションの見直しを行っていきましょう。
少しおさらいになりますが、「宣言性の向上」とは「手続き的な処理をなるべく関数に隠蔽して開発者が本来実装したい内容に集中できるようにすること」でした。
では今回のアプリケーションで手続き的処理を行っている部分はどこでしょうか?
まずPresentational層やLayout部分はロジックが含まれないただの関数ですので、これ以上隠蔽する内容がありません。
つまり、見直すべきはContainer層のロジックを扱っている部分(APIからデータを取得したり、フォームを参照するrefオブジェクトを生成している部分)になります。
const ComicSearchContainer = () => {
const inputRef = useRef(); // フォームを参照するオブジェクト
const [comics, setComics] = useState([]); // 取得した漫画データを格納するstate
// APIからデータを取得する関数
const submitHandler = e => {
e.preventDefault();
if (inputRef.current.value) {
// 入力されたキーワードを用いてAPIにリクエストを投げて、漫画データを取得する処理
comicApi
.getAll(`keyword=${encodeURIComponent(inputRef.current.value)}`)
.then(({ items }) => {
setFetchComics(items);
})
.catch(e => { /** 漫画データの取得に失敗した際の処理 */ })
} else {
// 入力欄が空欄だった場合の処理
}
};
return (
{/** Presentationalコンポーネントの表示 */}
);
};
export default ComicSearchContainer;
これらを隠蔽するため、Reactでは「カスタムフック」と呼ばれるものがよく使われます。
カスタムフック
まずは簡単な例を見てみましょう。
import React, { useEffect, useState } from "react";
const Timer2 = () => {
const [time, setTime] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setTime((prev) => prev + 1);
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
return (
<h3>
<time>{time}</time>
<span>秒経過</span>
</h3>
);
};
export default Timer2;
これは、先ほどuseEffect hookを説明する際に使用したタイマーアプリです。
簡単に振り返っておくと1秒ごとに1ずつ値がインクリメントされ、その値を画面に表示するアプリケーションでした。
さて、これをカスタムフックを用いてリファクタリングしてみましょう。
import { useTimer } from "./hooks/useTimer";
const Timer2 = () => {
const time = useTimer();
return (
<h3>
<time>{time}</time>
<span>秒経過</span>
</h3>
);
};
export default Timer2;
import { useEffect, useState } from "react";
export const useTimer = () => {
const [time, setTime] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setTime((prev) => prev + 1);
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
return time;
};
ロジック部分が見事にuseTimerの中に隠蔽されました。
カスタムフックとはこのように、「コンポーネント内に含まれるロジックを再利用可能な形に分割する」ための手法になります。
上記の例では、useTimerに「1秒ごとにstateを1ずつカウントアップさせる」というロジックが隠蔽されています。
こうすることでTimerコンポーネント側で余計なロジックを記述する必要がなくなり、使用する側からすればuseTimerを宣言しておくだけで「1秒ごとに1ずつ変化していくstate」が得られることになります。
こんな芸当はクラスコンポーネント時代にはなかなかできないことでした。
この辺もフックAPIの優れている部分ですね。
【補足:カスタムフックを作る際の約束事】
カスタムフックを作る際は、上記のように関数の名前の先頭に「use」をつけることが一般的です。
これは絶対に守らないといけないわけではありませんが、ある関数が内部でフックを呼んでいるかどうかを簡単に判別できるようReactの公式側が推奨している方法でもあります。
またカスタムフックの名前をuse○○○以外は許可しないというルールを適用できるESLintのプラグインも存在しているので、ESLintに怒られないためにもカスタムフックを利用する際はなるべくuseをつけるようにしましょう。
では、このカスタムフックを利用して先ほどのアプリケーションのロジック部分を切り出してみましょう
まずディレクトリ構成は以下のようになります。
src/
├ components/ ... アプリケーション全体で使用できる共通コンポーネント
│ ├ Layout/
│ │ └ MainLayout.jsx
│ └ (その他いろいろなディレクトリやファイル)
├ features/
│ ├ comics/ ... 漫画関連の機能
│ │ ├ api/
│ │ │ └ comic.js ... APIを用いて漫画の一覧情報を取得する
│ │ ├ components/
│ │ │ ├ ComicSearchContainer.jsx ... Container Component
│ │ │ ├ ComicLists.jsx ... Presentational Component
│ │ │ └ SearchInput.jsx ... Presentational Component
+ │ │ ├ hooks/ ... カスタムフック
+ │ │ │ └ useComics.js ... APIから漫画の一覧情報を取得し、データを返すフック
│ │ └ index.js ... comics配下すべてのエクスポートファイルを再エクスポート
│ ├ users/ ... ユーザー関連の機能(あくまで一例)
│ └ posts/ ... 投稿関連の機能(あくまで一例)
+ ├ hooks/ ... アプリケーション全体で使用できる共通のカスタムフック
└ (その他いろいろなディレクトリやファイル)
こちらのカスタムフックも基本的には漫画関連でしか使用しないフックなので、機能ディレクトリ内に閉じました。
(もちろん、汎用的に用いられるようなフックとして使いたい場合はfeatures配下ではなく、src直下のhooksディレクトリなどに格納すれば良いでしょう。)
ではファイルの中身を見てみましょう。
import { useComics } from '../hooks/useComics';
import ComicLists from './ComicLists';
import SearchInput from './SearchInput';
const ComicSearchContainer = () => {
// カスタムフックを用いて必要なデータとイベントハンドラを取得
const {inputRef, comics, submitHandler} = useComics();
return (
<>
{/** 検索フォーム */}
<SearchInput {...{ submitHandler, inputRef }} />
{/** 漫画を一覧表示 */}
<ComicLists {...{ comics }} />
</>
);
};
export default ComicSearchContainer;
import { useRef, useState } from 'react';
import comicApi from '../api/comic';
export const useComics = () => {
const inputRef = useRef();
const [comics, setComics] = useState([]);
// APIからデータを取得する関数
const submitHandler = e => {
e.preventDefault();
if (inputRef.current.value) {
// 入力されたキーワードを用いてAPIにリクエストを投げて、漫画データを取得する処理
comicApi
.getAll(`keyword=${encodeURIComponent(inputRef.current.value)}`)
.then(({ items }) => {
setComics(items);
})
.catch(e => { /** 漫画データの取得に失敗した際の処理 */ })
} else {
// 入力欄が空欄だった場合の処理
}
};
return {
inputRef,
comics,
submitHandler,
};
};
Containerコンポーネントの中身が非常にスッキリしたことがわかるでしょう。
Container側ではuseComicsというフックを使うだけで必要なデータや関数が取得できていて、「コンポーネント内では『あるべき状態』(ここではAPIから必要なデータを取得している状態)を宣言しておくだけ」という非常に「Reactらしい」書き方をしていることがわかるでしょう。
またこうすることでロジックの使いまわしも可能になり、漫画関連の機能を追加したい際に簡単にAPIからのデータフェッチ部分を再利用できるようになります。
【補足:カスタムフックとContainer Component】
実は今回紹介した「Container/Presentationalパターン」はhooksの導入でロジック部分の分離が容易になったことで、最近ではそこまでこだわる必要はないと言われています。
例えば上記のアプリの例だとComicSearchContainerを省略して、Presentationalで直接カスタムフックを呼び出して利用すれば十分見通しのよいコンポーネントとなるでしょう。
ただ依然としてContainerとPresentationalに分けるメリットは充分大きいと自分は思います。
そもそもContainer/Presentationalの本質は「UIとロジック部分を疎結合にすることで、再利用可能なコンポーネントとして切り出す」ということでした。
そのためカスタムフックを用いてロジックを分離できるようになったといえ、Presentationalでそれを呼び出してしまうと結局Presentationalがロジックに依存することになってしまい再利用性が低下してしまいます。
(例えばユーザーがお気に入り登録した漫画の一覧情報を表示させたいという場合を考えた場合、Presentationalを使いまわすことができなくなってしまう)
このようなことを考えると、「ロジックとUIの橋渡しを行う」という役割をもたせればContainerもまだまだ活躍の余地があると個人的には思っています。
5. 最後に
いかがでしたでしょうか。
本記事では「Reactを支える2つの思想」、そしてそれらを踏まえた上で「Reactらしいコードを書くとはどういうことなのか?」について実際のアプリケーションを例に考えていきました。
ただ正直なところ「各コンポーネントにどういう責務をもたせるのか?」「コンポーネントをどう組み合わせてUIをデザインしていくのか?」という部分に関してはこれといった決まりがあるわけではないため、現場や個人の考え方次第でかなり意見がバラバラな気がしてます。
(その証拠に「React コンポーネント設計」とかでググればめちゃくちゃ情報がでてきます😅)
ただコンポーネントの設計手法というのは結局のところ「Reactの思想に則ってアプリ開発をするにはどうすればよいのか?」ということを追求して生み出された手法であっていくらいろいろな設計手法があろうが、根っこにあるのは「コンポーネント指向」「宣言的UI」という2つの思想なのです。
そのため、今回はあえてAtomic Design等の具体的なコンポーネント設計手法云々の話ではなく「Reactの思想」にフォーカスを当てて、設計を行っていくという流れにしてみました。
本記事がみなさんの参考になれば幸いです。
最後までご覧いただきありがとうございました🙇♂️
6. 参考文献
ここからは本記事を書くにあたって参考にor引用させていただいた資料を紹介していきます。
①記事内で紹介・引用させたいただいたもの
②記事を書く際に参考にさせていただいた資料
書籍・動画
技術記事
- 苦しんで覚える React
- そのファイル、本当に hooks/・utils/ に入れるんですか?React プロジェクトを蝕む「見かけ駆動パッケージング」
- フロントエンドのコンポーネント設計に立ち向かう
- Container/Presentationalパターン再入門
- STORES 予約 のReactで踏み抜いたアンチパターンと現在