参考: https://medium.com/@jrwebdev/react-higher-order-component-patterns-in-typescript-42278f7590fb
まだ HOC と TypeScript の良い落としどころが見えてないが、何となく理解しつつある現状。 「TypeScript こわい」から卒業したのと、良記事があったので簡単にまとめておきます。
悪い。 => 型を設定することが難しい。
しかしやっていきたいので、any
を使わずに整理していこう。
整理すると下記2つのパターンが観測できる。
Enhancers
: 追加の機能/Props でコンポーネントをラップするパターンInjectors
: コンポーネントに Props を注入するパターン
※ displayName
, static メソッドの巻き上げについては今回は除外
- https://reactjs.org/docs/higher-order-components.html#convention-wrap-the-display-name-for-easy-debugging
- https://reactjs.org/docs/higher-order-components.html#static-methods-must-be-copied-over
もっとも簡単な HOCs パターンであるエンハンサー。
下記例は loading
prop をコンポーネントに与えて、値の true/false でスピナー表示/コンポーネントまま表示を切り替え。
const withLoading = Component =>
class WithLoading extends React.Component {
render() {
const { loading, ...props } = this.props;
return loading ? <LoadingSpinner /> : <Component {...props} />;
}
};
TypeScript で型付にすると
interface WithLoadingProps {
loading: boolean;
}
const withLoading = <P extends object>(Component: React.ComponentType<P>) =>
class WithLoading extends React.Component<P & WithLoadingProps> {
render() {
const { loading, ...props } = this.props as WithLoadingProps;
return loading ? <LoadingSpinner /> : <Component {...props} />;
}
};
interface WithLoadingProps {
loading: boolean;
}
ラップされたコンポーネントに与えられるインタフェース。
<P extends object>(Component: React.ComponentType<P>)
個人的にピンと来ていなかったジェネリック。ここでの P
は HOC に渡されるコンポーネントの Props。
また React.ComponentType<P>
は React.StatelessComponent<P> | React.ClassComponent<P>
のエイリアスであることも覚えておこう(参考)。
つまり、HOC に渡されるコンポーネント = (ステートレスなコンポーネント | クラスコンポーネント)
になるということを意味する。
class WithLoading extends React.Component<P & WithLoadingProps>
HOC から返却されるコンポーネントの定義。
このコンポーネントはジェネリックのP(rops)
と HOC自身の Props である WithLoadingProps
を持つ。
const { loading, ...props } = this.props as WithLoadingProps;
HOC の Props から loading
を抜き出して他の Props を分割代入する。
as WithLoadingProps
のように型キャストを明示しているのがわかる。
(TypeScript 自体が抱えている分割代入やスプレッド構文の問題、未だマージされないPR)
型キャストは一般的にはバッドプラクティスだが必ずしも悪ではない。
型キャスをしているおかげで残りの Props を指定できるという意味では今の所のワークアラウンド。
return loading ? <LoadingSpinner /> : <Component {...props} />;
最終的に loading
Props の値でスピナーかラップしたコンポーネントかで描画する。
実際にはこのタイプの HOC は SFC で書き換えることも可能。
const withLoading = <P extends object>(
Component: React.ComponentType<P>
): React.SFC<P & WithLoadingProps> => ({
loading,
...props
}: WithLoadingProps) =>
loading ? <LoadingSpinner /> : <Component {...props} />;
インジェクターはもっと一般的な形ではあるものの、型定義が難しい。
コンポーネントに Props を注入するだけではなく、多くの場合は注入された Props は
ラップされ返却された合成コンポーネントとなった場合にその型定義を含まない場合が多い。
※ react-redux の connect
は代表的なインジェクターの例
ここで扱うサンプルは下記。
import { Subtract } from 'utility-types';
export interface InjectedCounterProps {
value: number;
onIncrement(): void;
onDecrement(): void;
}
interface MakeCounterState {
value: number;
}
const makeCounter = <P extends InjectedCounterProps>(
Component: React.ComponentType<P>
) =>
class MakeCounter extends React.Component<
Subtract<P, InjectedCounterProps>,
MakeCounterState
> {
state: MakeCounterState = {
value: 0,
};
increment = () => {
this.setState(prevState => ({
value: prevState.value + 1,
}));
};
decrement = () => {
this.setState(prevState => ({
value: prevState.value - 1,
}));
};
render() {
return (
<Component
{...this.props}
value={this.state.value}
onIncrement={this.increment}
onDecrement={this.decrement}
/>
);
}
};
export interface InjectedCounterProps {
value: number;
onIncrement(): void;
onDecrement(): void;
}
コンポーネントに注入される Props のためにこのインタフェースが定義されてる。 実際には HOC がラップするコンポーネントによって使用されるためエクスポートされている。
利用された例が下記。
import makeCounter, { InjectedCounterProps } from './makeCounter';
interface CounterProps extends InjectedCounterProps {
style?: React.CSSProperties;
}
const Counter = (props: CounterProps) => (
<div style={props.style}>
<button onClick={props.onDecrement}> - </button>
{props.value}
<button onClick={props.onIncrement}> + </button>
</div>
);
export default makeCounter(Counter);
<P extends InjectedCounterProps>(Component: React.ComponentType<P>)
ここでもジェネリックを使う。 エンハンサーと違っているのはここで HOC に渡されたコンポーネントに注入する Props が含まれている点。
class MakeCounter extends React.Component<
Subtract<P, InjectedCounterProps>,
MakeCounterState
>
HOC から返却されるコンポーネントには渡されたコンポーネントの Props から
注入される Props を間引く必要があり、Subtract
を使用している(utility-types)。
※ Substract
は TS 2.8 からの Exclude
で記述されているとのこと
2つのパターンを組み合わせることで、コンポーネントにわたすことなく maxValue
, minValue
をインターセプトしつつHOCに渡すことが可能になる。
export interface InjectedCounterProps {
value: number;
onIncrement(): void;
onDecrement(): void;
}
interface MakeCounterProps {
minValue?: number;
maxValue?: number;
}
interface MakeCounterState {
value: number;
}
const makeCounter = <P extends InjectedCounterProps>(
Component: React.ComponentType<P>
) =>
class MakeCounter extends React.Component<
Subtract<P, InjectedCounterProps> & MakeCounterProps,
MakeCounterState
> {
state: MakeCounterState = {
value: 0,
};
increment = () => {
this.setState(prevState => ({
value:
prevState.value === this.props.maxValue
? prevState.value
: prevState.value + 1,
}));
};
decrement = () => {
this.setState(prevState => ({
value:
prevState.value === this.props.minValue
? prevState.value
: prevState.value - 1,
}));
};
render() {
const { minValue, maxValue, ...props } = this.props as MakeCounterProps;
return (
<Component
{...props}
value={this.state.value}
onIncrement={this.increment}
onDecrement={this.decrement}
/>
);
}
};
Subtract<P, InjectedCounterProps> & MakeCounterProps
コンポーネント自身の Props(P
)、HOC自身の Props(MakeCounterProps
)を合成したうえで
Subtract
で注入する Props(InjectedCounterProps
)は間引かれている。
取り立てて特筆すべき2パターンとの差異はないが、HOC が抱える一般的な問題も孕んでいる。
<MakeCounter maxValue={5} minValue={-5} />
問題1。
minValue
, maxValue
は HOC によってインターセプトされるが、コンポーネントは通らない。
※ コンポーネント自身から参照することはできない意
値に基づいて増減ボタンの活性・非活性、ユーザへのメッセージ掲示等を行いたい場合
HOCが返すコンポーネントにも値を注入するよう、HOC を改修をする必要がある。
export const makeCounter = <P extends InjectedCounterProps>(
Component: React.ComponentType<P & MakeCounterProps> // Props の足し込み
) =>
...
<Component
{...props}
minValue={minValue}
maxValue={maxValue}
value={this.state.value}
onIncrement={this.increment}
onDecrement={this.decrement}
/>
問題2。
HOC によって注入されている value
prop は一般的な名前なので
他の目的で再利用したい場合や複数の HOC から Props を注入する場合に
他の Props と命名がかぶる可能性がある。再利用性に応じて柔軟であるべき。
recompose という解決策もあるが
ほとんど型推論できないため、TypeScript との食い合わせは悪い。 現状としては型を明示的にした上で HOC を謹製で作っていくのがベターに思える。