Skip to content

Instantly share code, notes, and snippets.

@tkdn
Created December 29, 2018 11:53
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save tkdn/da7934575708a3989bf1a1bc02e9fbed to your computer and use it in GitHub Desktop.
Save tkdn/da7934575708a3989bf1a1bc02e9fbed to your computer and use it in GitHub Desktop.
HOCs と TypeScript における型付けの肝

React HOCs on TypeScript

参考: https://medium.com/@jrwebdev/react-higher-order-component-patterns-in-typescript-42278f7590fb

まだ HOC と TypeScript の良い落としどころが見えてないが、何となく理解しつつある現状。 「TypeScript こわい」から卒業したのと、良記事があったので簡単にまとめておきます。

HOCs と TypeScript の相性

悪い。 => 型を設定することが難しい。 しかしやっていきたいので、any を使わずに整理していこう。

整理すると下記2つのパターンが観測できる。

  • Enhancers: 追加の機能/Props でコンポーネントをラップするパターン
  • Injectors: コンポーネントに Props を注入するパターン

displayName, static メソッドの巻き上げについては今回は除外


Enhancers

もっとも簡単な 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} />;

Injectors

インジェクターはもっと一般的な形ではあるものの、型定義が難しい。 コンポーネントに 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 で記述されているとのこと


Enhancers + Injectors

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 を謹製で作っていくのがベターに思える。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment