[React] Class ComponentとFunctional ComponentからHooksを呼び出しDRY原則を取り戻す
1. はじめに
Reactを用いたフロントエンド開発は、HooksがサポートされたReact v16.8が一つの分水嶺です。v16.8より前の時代、状態を持ったComponentは全てClass Componentとして実装され、共通ロジックはHOCパターンによりカプセル化する手法が公式に推奨されていました。
v16.8以降、Hooksの登場によりClass ComponentとHOCのユースケースはほぼ無くなりました。その結果、v16.8より前のバージョンから開発されてきたReactプロジェクトは、Class ComponentとFunctional Componentが共存するコードベースを抱えることになりました。
HooksはComponent間で共通しているロジックを抽出しカプセル化する際のファーストチョイスです。しかしHooksはClass Componentから直接呼び出すことが出来ないため、Class ComponentとFunctional Componentで同じ目的のコードを別々に実装する非DRYなケースがあります。
本来であればClass ComponentをFunctional Componentに書き換えられれば良いですが、Class Componentのメンテナビリティ、開発工数、テストの不在などの理由により、Class Componentをそのまま使わなければならないシーンがあります。
本記事では、そのようなシーンで利用できるアプローチとして、Class ComponentとFunctional Componentから共通のHooksを呼び出す方法を紹介します。これにより、2種類のComponentが共存するプロジェクトにおいて、DRY原則を取り戻すことが期待できます。なおサンプルコードはTypeScriptで書き型付けも行います。
2. 完成サンプル
下図の通り、共通のロジックを単一のHooksとして実装し、Class ComponentとFunctional Componentから呼び出せる状態にします。なおClass ComponentからHooksは直接呼べないため、HOCパターンで実装します。
- HOCパターンについては公式ドキュメントが分かりやすいです。
サンプルとしてインクリメント可能なカウンターを用います。完成形は下の動作例のようになります。Class ComponentとFunctional Componentから、カウンター機能を実現するためのHooksを呼び出しています。
GitHubのレポジトリに完成サンプルを置いてあります。
3. カスタムHooksの用意
各Componentから利用するカスタムHooksを用意します。ここでは単純なカウンターのサンプルですが、HTTPリクエストなど再利用性を担保したいロジックは全て同じように書けます。
import { useState } from "react";
// Hooksが返すObjectの型をICounterとして定義
export interface ICounter {
count: number;
onIncrement: () => void;
}
const useCounter = (): ICounter => {
const [count, setCount] = useState<number>(0);
const onIncrement = () => setCount(count + 1);
return {
count,
onIncrement
};
};
export default useCounter;
4. HOCの用意
Class ComponentからHooksをコールできるようにするため、HOC関数を用意します。引数で渡されたComponentに対し、前章で定義したHooksのcount
とonIncrement
を新たなpropsとして注入しています。
import React from "react";
import useCounter, { ICounter } from "../hooks/useCounter";
// Componentに注入するpropsの型定義
// 個別の型はカスタムHooksのICounter型からコピーしている
export interface IInjectedCounter {
count: ICounter["count"];
onIncrement: ICounter["onIncrement"];
}
// Tは<ICounterProps & 注入対象Componentのpropsの型>という型になる
const withCounter = <T extends unknown>(
// Wrappeeは注入対象Component
Wrappee: React.ComponentType<T>
) => {
// orgPropsは注入対象Componentが元々持っているprops
// TからIInjectedCounterを消去(Omit)し、orgPropsの型だけを抽出する
return (orgProps: Omit<T, keyof IInjectedCounter>) => {
const { count, onIncrement } = useCounter();
return (
<Wrappee count={count} onIncrement={onIncrement} {...(orgProps as T)} />
);
};
};
export default withCounter;
<T extends unknown>
は本来<T>
と書きたい所ですが、JSX記法でないことを明示するためにextends unknown
を付与しています。
5. Class Componentの実装
HOC関数であるwithCounterにComponentを渡すことで、Component内でIInjectedCounterのObject(count
, onIncrement
)が使えるようになります。
import React from "react";
import withCounter, { IInjectedCounter } from "../hocs/withCounter";
// 注入されるpropsの型IInjectedCounterをGenericsとして設定する
class ClassCounter extends React.Component<IInjectedCounter> {
render() {
return (
<dl>
<dt>Class Component</dt>
<dd>
{this.props.count}
<button onClick={this.props.onIncrement}>+</button>
</dd>
</dl>
);
}
}
export default withCounter(ClassCounter);
6. Functional Componentの実装
Functional ComponentからはHooksを直接importするだけで利用可能です。
import React from "react";
import useCounter from "../hooks/useCounter";
const FunctionalCounter: React.VFC = () => {
const { count, onIncrement } = useCounter();
return (
<dl>
<dt>Functional Component</dt>
<dd>
{count}
<button onClick={onIncrement}>+</button>
</dd>
</dl>
);
};
export default FunctionalCounter;
7. おわりに
共通のカスタムHooksをClass ComponentとFunctional Componentから利用するコードサンプルを紹介しました。
Reactに限らずフロントエンド開発は技術トレンドが高速に変わるため、ある程度の歴史があるコードベースにはレガシーコードが入り込みがちです。長期的にはリファクタリングが最良の選択であっても、現実のプロジェクトでは開発コストや工数の制約により、レガシーコードと共存する必要性が往々にしてあります。
本記事で取り上げたのは小手先のテクニックですが、限られた制約の中で保守性の高いコードを書くことは、現実のプロジェクトと向き合う上で大切にしたい開発姿勢だなと改めて思います。つらいけど。
ほか、フロントエンドに関する記事を書いています。
Twitter:@aki202
Discussion