class構文で書かれた処理をReactで使う(Class Componentの話ではない)
目次
こんにちは、@Himenonです。フロントエンド開発のコンテキストで最近あまり聞かなくなった class 構文の使い方について紹介します。ただし、React の Class Component の話ではなく、純粋な言語としての class です。
いつ class が使われるのか?
大抵の場合、サードパーティ製のライブラリを利用する際に利用されることが多いように感じます。ライブラリが UI ライブラリではない場合、ライブラリが持つべき責務を最小限にしたいと作者は考えるので、React や Vue などでラップされた状態で提供されるとは限りません。
例えば、通信に関するライブラリ(WebSocket の Wrapper や、GraphQL のコア)や、プラグインとして class 構文のみをサポートしている場合などが考えられます。
class で記述しなければいけない場面は、高確率でコントロールできないところから登場するので、どのように class 構文と結合するのか見ていきましょう。
※注意点: class での実装をもとに紹介しますが、React の外側に state 管理を切り出すと同じことが言えます。class かどうかは本来は重要なポイントではないことに注意してください。
TODO リストの State を class で管理する
React.useState を利用した TODO リストの実装
実装サンプル:https://codesandbox.io/s/quizzical-worker-nw2t8w?file=/src/PureTodoList.tsx
TODO リストを例に実装していきましょう。まずは、React.useState を利用した場合は次のように記述できます。
import * as React from "react";
export type TodoItem = {
id: string;
text: string;
};
export default function App() {
const [text, setText] = React.useState("");
const [todos, setTodos] = React.useState<TodoItem[]>([]);
const handleAddItem = () => {
setTodos((prev) => {
return prev.concat({
id: Math.random().toString(),
text,
});
});
};
return (
<div className="App">
<h1>TODO LIST</h1>
<input type="text" onChange={(event) => setText(event.target.value)} />
<button onClick={handleAddItem}>Add Item</button>
<ul>
{todos.map((item) => {
return (
<li key={item.id}>
<p>
<button
onClick={() => {
setTodos((prev) => {
return prev.filter((target) => target.id !== item.id);
});
}}
style={{ marginRight: "2em" }}
>
remove
</button>
{item.text}
</p>
</li>
);
})}
</ul>
</div>
);
}
state に todos を持ち、それを更新する実装になっています。
class 内で state を管理する TODO リストの実装
実装サンプル:https://codesandbox.io/s/quizzical-worker-nw2t8w?file=/src/ClassTodoList.tsx
次に、class 構文で TODO リストを作成してみます。まずは、React のコンテキストを持たない状態で TODO List を class 構文で記述しています。
export type TodoItem = {
id: string;
text: string;
};
export type ChangeCallback = (items: TodoItem[]) => void;
class TodoList {
private todos: TodoItem[] = [];
public addItem = (item: TodoItem) => {
this.todos.push(item);
};
public removeItem = (id: string) => {
this.todos = this.todos.filter((todo) => todo.id !== id);
};
}
export default TodoList;
ただ、この状態だと todos を取得する API がないので、それも実装すると次のようになります。
export type TodoItem = {
id: string;
text: string;
};
export type ChangeCallback = (items: TodoItem[]) => void;
class TodoList {
private todos: TodoItem[] = [];
private onChangeHandlers: ChangeCallback[] = [];
private emit = () => {
this.onChangeHandlers.forEach((callback) => {
callback(this.todos);
});
};
public addItem = (item: TodoItem) => {
this.todos.push(item);
this.emit();
};
public removeItem = (id: string) => {
this.todos = this.todos.filter((todo) => todo.id !== id);
this.emit();
};
public onChange = (callback: ChangeCallback) => {
this.onChangeHandlers.push(callback);
return () => {
this.onChangeHandlers = [];
};
};
}
emit method と onChange method は EventEmitter 風に記述しています。EventEmitter については”TypeScript で EventEmitter を原理を理解しながら実装する(初級編)”で紹介されているので、こちらの記事も御覧ください。
class と React の結合
class のインスタンスは React.useRef を用いて参照をコンポーネント内で管理させます。また、インスタンス内部で管理されている state を React と結合するためには、React の State にわたす必要があります。直接インスタンス内の参照を渡しても、React のコンポーネントを更新する dispatch が呼ばれないため、描画が更新されません。これを同期するために、インスタンス内の state の変化を、前述した onChange メソッドで Listen する必要があります。
これらを満たすように実装すると次のように記述することができます。
import * as React from "react";
import TodoList, { TodoItem } from "./TodoList";
export default function App() {
// インスタンスの参照を管理する
const todoList = React.useRef<TodoList>(new TodoList());
const [text, setText] = React.useState("");
const [todos, setTodos] = React.useState<TodoItem[]>([]);
// class内のstateとReact.useStateで持つstateを同期させる
React.useEffect(() => {
const cleanup = todoList.current.onChange((nextTodos) => {
setTodos(() => [...nextTodos]);
});
return () => {
// メモリリークを防ぐためにcleanupを忘れない
cleanup();
};
}, []);
const handleAddItem = () => {
todoList.current.addItem({
id: Math.random().toString(),
text,
});
};
return (
<div className="App">
<h1>TODO LIST</h1>
<input type="text" onChange={(event) => setText(event.target.value)} />
<button onClick={handleAddItem}>Add Item</button>
<ul>
{todos.map((item) => {
return (
<li key={item.id}>
<p>
<button
onClick={() => {
todoList.current.removeItem(item.id);
}}
style={{ marginRight: "2em" }}
>
remove
</button>
{item.text}
</p>
</li>
);
})}
</ul>
</div>
);
}
React.useState と class で state を管理する実装の比較
実装量(行数)
明らかな違いとして、class 構文で記述した場合のほうが冗長で、実装量が明らかに多いです。
テスタビリティ
state の管理をReact の外側に切り出したclass 実装ベースは、React に非依存なため、純粋な JavaScript としてテストすることができます。一方で、React.useState で実装された部分は、ビジネスロジックを直接テストすることができません。
どの処理を class 構文で記述するか?
ここまでで見てきたように、class で記述した実装は React に非依存なためテスタビリティが向上します。また、シングルトン化したインスタンスを利用することで参照を一意にすることができ、React Component のライフサイクルから処理を分離することが可能です。
まずは UI に依存しない処理を対象とすると良いでしょう。その上で、テストコードを書くことが重要で、多少の実装の複雑性を受け入れられるのであれば選択肢として浮上するでしょう。特に、通信に関する Wrapper は明らかに分離したほうがテストコードを書くコストが React に依存している場合と比較して低くなります。
まとめ
class 構文で記述された処理を React 上で扱う方法を紹介しました。最初に注意で述べたように、class で記述された処理に限った話ではなく、React の外側で処理を記述する場合に似たような実装をすることになると考えられます。シンプルな処理を React の外側に切り出すと結合コストが高くなるため、使い所を見極める必要がありますが、実装の選択肢として知っておくことで対応できる課題も増えるでしょう。