Class Componentライクに書けるFunction Component

# 動機

Reactに導入されたHooks APIは色々と便利です。
ところが、FC自体はTypeScriptのクラス機能や型付けとあまり相性が良くないように思います。[^1]
そこで、Class Componentのときに出来た機能をちょっと移植しつつ、FCとして使えるようにするクラスを自作してみました。

## コード

```tsx

import * as React from 'react';

/** 第2引数で指定したプロパティ名を省略可能にします */
type Optional<Original, Names> = { [P in Extract<keyof Original, Names>]?: Original[P] } & Pick<Original, Exclude<keyof Original, Names>>;
/** defaultPropsの戻り値を省略可能にしたPropsを作ります */
type RenderProps<Original, Component extends { defaultProps: { [P in keyof Original]?: Original[P] }; }> = React.PropsWithChildren<Optional<Original, keyof Component['defaultProps']>>;

export abstract class FunctionComponent<Props = {}, State = {}> {
  private _props!: React.PropsWithChildren<Props>;
  private _state!: State;
  private _dispatchers!: { [P in keyof State]: React.Dispatch<State[P]>; };

  /** defaultPropsのチェックで使うので、publicでないといけない */
  public get props() {
    return this._props;
  }

  protected get state() {
    return this._state;
  }

  protected get dispatchers() {
    return this._dispatchers;
  }

  /**
   * 利用側で使用するJSX要素を生成し返します。
   * この部分がFunction Componentです。
   */
  public readonly toJSX = (props: RenderProps<Props, this>) => {
    this._props = props as any;
    this.initializeState();
    return this.render();
  }

  /**
   * Class ComponentやFunction Component標準のdefaultPropsと同じように実装をしてください。
   * この戻り値のプロパティ名は省略可能扱いになります。
   *
   * 戻り値をPartial<Props>にすると、全てのプロパティが省略可能になってしまい旨味がないので、指定していません。
   */
  public get defaultProps() {
    return {};
  }

  /**
   * ここで指定された値がstateの初期値になります。
   * stateを使用する場合、必ず全ての初期値を返すよう実装してください。
   */
  public get defaultState(): State {
    return {} as State;
  }

  /** このメソッドを旧来のrenderと同じように実装してください。 */
  protected abstract render(): ReturnType<React.FC>;

  /** Class ComponentのsetState相当のことをします。 */
  protected setState<T extends { [P in keyof State]?: State[P] }>(newState: T) {
    (Object.keys(newState) as any as (keyof State)[]).forEach(k => this.dispatchers[k](newState[k] as any));
  }

  private initializeState() {
    const state = this.state || this.defaultState;
    this._state = {} as any;
    this._dispatchers = {} as any;
    Object.keys(state).forEach(k => [this._state[k], this._dispatchers[k]] = React.useState(state[k]));
  }

  private __inited = this.__init();
  /** デバッグ用に生成関数にクラス名をつけたり、defaultPropsを繋げます。 */
  private __init() {
    const desc = Object.getOwnPropertyDescriptor(this.toJSX, 'name');
    if (!desc) {
      return;
    }
    desc.value = (this as any).constructor.name;
    Object.defineProperty(this.toJSX, 'name', desc);
    (this.toJSX as any).defaultProps = this.defaultProps;
  }
}

/** 
 * 利用側でクラスのインスタンスを都度生成しなくてもFCにしてくれるサポート関数です。
 * Propsに定義されていないキーが存在する場合や、型に互換性がない場合、ここでエラーが出ます。
 */
export const convertToFC = <
  T extends {
    toJSX: (props: any) => ReturnType<React.FC>,
    defaultProps: { [K2 in K]?: T['props'][K2] },
    props: any;
  },
  K extends (keyof T['defaultProps'] extends keyof T['props'] ? keyof T['defaultProps'] : '__notExistsKey')
>(clazz: new () => T) => {
  const func = (props: Parameters<T['toJSX']>[0]) => {
    const instance = React.useMemo(() => new clazz(), []);
    return <instance.toJSX {...props} />;
  };
  func.displayName = `${clazz.name}_Creator`;
  return func;
};

```

基本的に、Class Componentと同じように`props`と`state`を設定しつつ、`render`の戻り値をFCにして返しています。
そのままだとインスタンスを都度生成しないといけないため、`convertToFC`関数を用意しています。

## 使い方

試しに基底となるクラスを作成します。
今回はpropsに`name`と`type`、Stateに`examined`（診察したかどうか）を持たせています。
ついでにHooks APIとして`useMemo`も使ってみました。

継承したクラスでは`type`の初期値を返す`defaultType`を実装させるようにします。

```tsx:animal_component.tsx


import * as React from 'react';
import { FunctionComponent } from './function_component';

interface IProps {
  name: string;
  type: string;
}

interface IState {
  examined: boolean;
}

export abstract class AnimalComponentClass extends FunctionComponent<IProps, IState> {
  public get defaultProps() {
    return {
      type: this.defaultType(),
    };
  }

  public get defaultState() {
    return {
      examined: false,
    };
  }

  protected render() {
    const { name, type } = this.props;
    const { examined } = this.state;
    return React.useMemo(() => (
      <div onClick={this.onClick}>
        <div>{name}</div>
        <div>{type}</div>
        <div>{examined ? '済' : '未'}</div>
      </div>
    ), [examined]);
  }

  protected abstract defaultType(): IProps['type'];

  private onClick = () => {
    this.setState({ examined: true });
  }
}

```

そしてこれを継承したコンポーネントを作成します。
`type`の初期値をもたせるほか、メソッドとして`cry`を実装しました。

```tsx:dog_component.tsx

import { AnimalComponentClass } from './animal_component';
import { convertToFC } from './function_component';

export class DogComponentClass extends AnimalComponentClass {
  public cry() {
    console.log('bow');
  }

  protected defaultType() {
    return '犬';
  }
}

const component = convertToFC(Class);
```

最後に、これを使ってみましょう。

```tsx


const Hospital = () => {
  // インスタンスを生成することで、メソッドを通した操作が可能になります。
  const instance = React.useMemo(() => new DogComponentClass(), []);
  React.useEffect(() => instance.cry(), []);

  // convertToFCを通したものか、インスタンスのtoJSXを呼ぶことでJSX形式として扱うことが可能になります。
  // defaultPropsで指定しているtypeは省略可能とみなされています。
  return (
    <div>
      <DogComponent name='太郎' />
      <instance.toJSX name='次郎' type='レトリーバー' />
    </div>
  );
}
```

これを実行すると、以下のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/281108/6dc63739-ed17-3526-0a8c-bb2a8f652e4a.png)

どちらの方法でも正しく描画出来ていること、`type`を省略した場合`defaultProps`が機能していることが分かると思います。
また、クリックすることで`state`の書き換えも行えます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/281108/64341f27-8f14-7dfd-4116-f9f6cf2f4f11.png)

下の方について、`未`だった部分が`済`に書き換わりました。こちらも上手く機能しています。

## 型の警告について

必須の`name`プロパティを省略した場合、以下のように警告が表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/281108/ca82e427-52a8-623f-ebed-8f9a3bbbea99.png)

`defaultProps`に、Propsに存在しないプロパティ`test`を指定した場合、以下のように警告が表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/281108/f86779c1-221b-7da6-29f9-dc192a7e6285.png)

同様に、`defaultProps`で`type`に`null`を指定するなど、型違反を起こした場合、以下のように警告が表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/281108/c33260c4-1f82-c397-da7b-3da6727cb652.png)



[^1]: Class Componentのときは出来た、defaultPropsで返しているプロパティを判別してくれる型定義になっていないとか、stateやprops引き連れ回すときに毎回定義書かなきゃいけないので面倒等。