React Presentational/Container Component非分離パターン

7 min read読了の目安(約6500字

React Hooksが登場した以来、React開発において色々なところで変化があったと思います。今回はその中でも既存のPresentationalとContainerに分けて使っていたComponentを分離せず使うパターンをシンプルなCounterAppを作りながら紹介したいと思います。

Project Set Up

create-react-appでcounter-appというtypescript react projectを立ち上げます。
reduxを使うためのライブラリをインストールします。

$ npx create-react-app counter-app --typescript 
$ cd counter-app 
$ yarn add redux react-redux @types/react-redux

Create Redux Module

DucksパターンでCounterのためのRedux Moduleを作成します。

src/modules/counter.ts

Action Type

宣言したAction Typeのタイプがstringにならなく、実際の値を指すようにas constというキーワードを必ずつけてください。

const INCREASE = 'counter/INCREASE' as const;
const DECREASE = 'counter/DECREASE' as const;
const INCREASE_BY = 'counter/INCREASE_BY' as const;

Creating Action Function

increaseByの場合、payloadという名でデータを返しますが、これはFSA規則に従うためです。絶対的に従わなければならないのではないので、気に入らない場合は他の名にしても構いません。

export const increase = () => ({ type: INCREASE });
export const decrease = () => ({ type: DECREASE });
export const increaseBy = (diff: number) => ({
  type: INCREASE_BY,
  payload: diff
});

Type Definition

type CounterAction =
  | ReturnType<typeof increase>
  | ReturnType<typeof decrease>
  | ReturnType<typeof increaseBy>;

Create State, Reducer

type CounterState = {
  count: number;
}

const initialState: CounterState = {
  count: 0
};

function counter(state: CounterState = initialState, action: CounterAction) {
  switch (action.type) {
    case INCREASE:
      return { count: state.count + 1 };
    case DECREASE:
      return { count: state.count - 1 };
    case INCREASE_BY:
      return { count: state.count + action.payload };
    default:
      return state;
  }
}

export default counter;

Apply Redux to App

src/modules/index.ts

import { combineReducers } from 'redux';
import counter from './counter';

const rootReducer = combineReducers({
  counter
});

export default rootReducer;

export type RootState = ReturnType<typeof rootReducer>;

index.tsx

import { render } from "react-dom";
import { Provider } from "react-redux";
import { createStore } from "redux";
import rootReducer from "./modules";

import App from "./App";

const store = createStore(rootReducer);

const rootElement = document.getElementById("root");
render(
  <Provider store={store}>
    <App />
  </Provider>,
  rootElement
);

OK!セットアップは終わりました。これからは先ずCounter ComponentPresentational/Container Componentパターンで作ってみます。

src/components/Counter.tsx

type CounterProps = {
  count: number;
  onIncrease: () => void;
  onDecrease: () => void;
  onIncreaseBy: (diff: number) => void;
};

function Counter({
  count,
  onIncrease,
  onDecrease,
  onIncreaseBy
}: CounterProps) {
  return (
    <div>
      <h1>{count}</h1>
      <button onClick={onIncrease}>+1</button>
      <button onClick={onDecrease}>-1</button>
      <button onClick={() => onIncreaseBy(5)}>+5</button>
    </div>
  );
}

export default Counter;

src/containers/CounterContainer.tsx

import { useSelector, useDispatch } from 'react-redux';
import { RootState } from '../modules';
import { increase, decrease, increaseBy } from '../modules/counter';
import Counter from '../components/Counter';

function CounterContainer() {
  const count = useSelector((state: RootState) => state.counter.count);
  const dispatch = useDispatch();

  const onIncrease = () => {
    dispatch(increase());
  };

  const onDecrease = () => {
    dispatch(decrease());
  };

  const onIncreaseBy = (diff: number) => {
    dispatch(increaseBy(diff));
  };

  return (
    <Counter
      count={count}
      onIncrease={onIncrease}
      onDecrease={onDecrease}
      onIncreaseBy={onIncreaseBy}
    />
  );
}

export default CounterContainer;

src/App.tsx

import CounterContainer from './containers/CounterContainer';

function App() {
  return <CounterContainer />;
}

export default App;

Counter App V1完成

いい感じですね!

PresentationalとContainerに分けなく使ってみましょう!

PresentationalとContainerにComponentを分けなければどうすれば良いでしょう?
Dan Abramovさんは語りました。

"Hooks let me do the same thing without an arbitrary division".(原文)
(翻訳)Hooksを使って(コンポーネント)を任意的に分離しなくても同じ作業ができる。

どういう話かぱっときますか?自分は最初には理解しにくかったですね。X)
平たく言うとuseSelectoruseDispatchで構成されたカスタムHookを作ってそれを使うとOKですよとう意味です。

それでは、CounterコンポーネントをReduxと連携するためにuseCounterというカスタムHookを作成します。

src/hooks/useCounter.ts

import { useSelector, useDispatch } from 'react-redux';
import { RootState } from '../modules';
import { increase, decrease, increaseBy } from '../modules/counter';
import { useCallback } from 'react';

export default function useCounter() {
  const count = useSelector((state: RootState) => state.counter.count);
  const dispatch = useDispatch();

  const onIncrease = useCallback(() => dispatch(increase()), [dispatch]);
  const onDecrease = useCallback(() => dispatch(decrease()), [dispatch]);
  const onIncreaseBy = useCallback(
    (diff: number) => dispatch(increaseBy(diff)),
    [dispatch]
  );

  return {
    count,
    onIncrease,
    onDecrease,
    onIncreaseBy
  };
}

これからはこちらのuseCounterHookをCounter.tsx(Presentational Component)で使えば良いです。あ、もうContainerとPresentationalの区別がなくなったのでPresentational Componentと呼ぶ必要もないですね!

src/components/Counter.tsx

import useCounter from '../hooks/useCounter';

function Counter() {
  const { count, onIncrease, onDecrease, onIncreaseBy } = useCounter();

  return (
    <div>
      <h1>{count}</h1>
      <button onClick={onIncrease}>+1</button>
      <button onClick={onDecrease}>-1</button>
      <button onClick={() => onIncreaseBy(5)}>+5</button>
    </div>
  );
}

export default Counter;

必要な関数と値をContainer ComponentからPropsとして受けることではなくuseCounter Hookから受け取りました。最後にsrc/containersディレクトリを削除して、App.tsxから直接にCounterをレンダリングするように修正します。

src/App.tsx

import Counter from './components/Counter';

function App() {
  return <Counter />;
}

export default App;

完了です!完成したコードは以下のCodeSandBoxから確認ができます。

終わりに

Presentational/Container Componentパターンがもうなれた方々には既存もパターンを固守することも全然OKです!問題ありません。
ただ、このようにHooksを使ってロジックを分離するのもかなり良いパターンなので新たに作成するコンポーネントからはこんな感じでカスタムHookを用いる方式でトライすることも良い方法だと思います。