もやもやエンジニア

IT系のネタで思ったことや技術系のネタを備忘録的に綴っていきます。フロント率高め。

責務を意識してReact/Reduxを使う

rei19.hatenablog.com

  • ↑の続き。仕事でReact/ReduxなSPAを書き始めているのですが、引き継いだコードを読んだり書き直したりして思ったことなど。

TL;DR

  • Componentの仕事はもらってきたpropsを素直に表示するのとEventの発火だけにしような。

FatなComponent

  • なかなかに巨大でやたらRender以外の処理が太ってるReactのComponentだな。。。と思いながらコードを読むと、以下のようなロジックになっていました。具体的なコードは書けないので雑なイメージはこんな感じ

containers/Sample.tsx

const mapStateToProps = (state: GlobalState) => {
    const { hogeList, fugaList } = state;
    return {
        hogeList,
        fugaList
    };
};

const mapDispatchToProps = {
    callGetHogeApi,
    callGetFugaApi,
    callPostHogeFugaApi
}

export default connect(mapStateToProps, mapDispatchToProps)(Sample)

components/Sample.tsx

interface Props {
    hogeList?: Hoge[]; // HogeAPIのレスポンス
    fugaList?: Fuga[]; // FugaAPIのレスポンス
    callGetHogeApi: () => void; // 表示に必要な情報をGetするActionをDispatchするやつ
    callGetFugaApi: () => void; // 表示に必要な情報をGetするActionをDispatchするやつ
    callPostHogeFugaApi: (param: HogeFugaRequestParam) => void; // 更新をPostするActionをDispatchするやつ
}

// 略

export default class Sample extends React.Component<Props, State> {

// 略

    public componentDidMount() {
        this.props.callHogeApiAction();
        this.props.callFugaApiAction();
    }

// 略

    public render() {
        // 各APIのレスポンスが全部揃っているかpropsを見て判定

        // レスポンスが全部揃ってたらrender開始。それ以外はローディング表示
        const piyoList = createPiyoList();
        return (
            // 略
        );
    }

    // Componentの状態をサーバー側に送信する的な処理 
    private onSubmit() {
        // Props/StateからAPIにリクエストするための情報を作成する
        const params: HogeFugaRequestParam = {
            // 略
        };
        this.props.callPostHogeFugaApi(params);
    }

    private createPiyoList() : Piyo[] {
        const { hogeList, fugaList } = this.props;
         // 表示するために必要なpiyoListをhogeListとfugaListをいい感じにマージして作る
        return piyoList;
    }
}

上の例だと表示するために呼んでるAPIは2本ですが、実際には4本とか5本とかあったりします。今の作りでのReact/Reduxなアプリの各レイヤーの責務を見てみると以下の感じになります。

  • Action
  • Reducer
    • APIのレスポンスをそのまま新しいStateとしてReduxStoreに保存する
  • Container
    • Storeから画面で使うレスポンスとdispatchするActionをcomponentに渡す
  • Component
    • APIを呼ぶActionをdispatchする
    • レスポンスから必要な値を抜いて自分が必要な形に加工する。またcomponentによってはそれをstateに保存していたりしている。
      • 加工のためのutilityあるいはhelperという名で切り出されたロジックが大量にある。または各component内のprivate関数で重複して存在しているものもある
    • APIに投げるパラメータを作る(ActionのIF === APIのIFになってる)

という感じで、API呼ぶ->storeにレスポンスが保存される->containerはそれをcomponentにスルーパス->componentが色々頑張るといった形になっていて、componentにロジックが集中する作りになってます。

どうやってcomponentを薄くするか

今の作りの問題は以下の点です。

  • Propsとして生のAPIのレスポンスを受け取って自分で加工しているため、componentはAPIのレスポンスを意識しなければいけなく、そのための余計なロジックを実装しなければいけない。
  • Actionを通り越してAPIの詳細な実装をcomponentが意識しなければいけない。

本来、Reduxに状態の管理を移譲することでReactのComponentは渡ってきたPropsを素直に表示するだけという作りになるはずなので、componentではなくてcontainerに仕事をさせてcomponentが望む形に加工したものを渡すようにします。また、Action周りも実装を意識させないようなIFにしてAPIの存在をAction内に閉じ込めます。修正後はこのような形になりました。

containers/Sample.tsx

const mapStateToProps = (state: GlobalState) => {
    const { isLoading, hogeList, fugaList } = state;
    const piyoList = createPiyoList(this.props.hogeList, this.props.fugaList);
    return {
        isLoading,
        piyoList
    };
};

const mapDispatchToProps = (dispatch: Dispatch<GlobalState>) => {
    return {
        onStart: () => {
            dispatch(fetchHogeFugaList());
        },
        onSubmit: (name: string) => {
            dispatch(createPiyo(name));
        }
    }
};

export default connect(mapStateToProps, mapDispatchToProps)(Sample)

components/Sample.tsx

interface Props {
    isLoading: boolean;  // 読み込み中か判定
    piyoList: Piyo[]; // 画面で表示するPiyoの一覧
    onStart: () => void; // 画面レンダリング開始時のイベント
    onSubmit: (name: string) => void; // 新しいPiyo作成時のイベント
}

// 略

export default class Sample extends React.Component<Props, State> {

// 略

    public componentDidMount() {
        this.props.onStart();
    }

// 略

    public render() {
        if (this.props.isLoading) {
            return (
                // くるくる表示する
            );
        }

        return (
            // PiyoList表示する
        );
    }

    private onSubmit() {
        // 実際にはこのへんもonChangeで全部reduxに流すのでstateは無くなる。
        this.props.onSubmit(this.state.name);
    }
}

という感じになりました。変更後の責務は以下の通り。

  • Container
    • Storeから画面で使うレスポンスとdispatchするActionをcomponentに渡す
    • Storeから必要なレスポンスを加工してcomponentに渡す
    • componentで発火するイベントに対応するActionをmappingする(mapDispatchToPropsの通りですね)
  • Component
    • APIを呼ぶActionをdispatchする
    • レスポンスから必要な値を抜いて自分が必要な形に加工する。またcomponentによってはそれをstateに保存していたりしている。
      • 加工のためのutilityあるいはhelperという名で切り出されたロジックが大量にある。または各component内のprivate関数で重複して存在しているものもある
    • APIに投げるパラメータを作る(ActionのIF === APIのIFになってる)
    • containerから受け取ったpropsを表示する
    • 画面で発生するライフサイクル/ユーザーのアクションのイベントをcontainerに通知する

また、複数API呼んでいたところは1つのアクションとして再定義しています。非同期部分はもともと redux-promise を使ってたのでそのまま使って、Promiseでまとめて2本走らせて両方取れたらreducerに回るようにしています。

actions/fetchHogeFugaList.ts

export function fetchHogeFugaList(): FetchHogeFugaListAction {
    return {
        type: FETCH_HOGE_FUGA_LIST,
        payload:  Promise.all([
            axios.get(GET_HOGE_LIST),
            axios.get(GET_FUGA_LIST),
        ]
    };
} 

おわり

  • このように対応を入れたことでcomponentの見通しが良くなり、componentがAPIを意識しなくなりました。また、メリットとしてテストが格段に書きやすくなりました。この記事の例だと大したことなさそうなのですが、実際には大量のPropsが定義されていてテストデータを作ろうにも本当に必要な情報はどれなんだーという感じだし、イベントの確認もAPIと密結合しているので大変でした。
  • reduxをちゃんと使えばreactのcomponentは薄く保つことができ、Viewとロジックを分離できるようになるので意識して書いていきたいですね。
    • まだredux触り始めな感じなので理解が間違ってたらコメントください m( )m