React Presentational/Container Component非分離パターン
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 Component
をPresentational/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;
いい感じですね!
PresentationalとContainerに分けなく使ってみましょう!
PresentationalとContainerにComponentを分けなければどうすれば良いでしょう?
Dan Abramov
さんは語りました。
"Hooks let me do the same thing without an arbitrary division".(原文)
(翻訳)Hooksを使って(コンポーネント)を任意的に分離しなくても同じ作業ができる。
どういう話かぱっときますか?自分は最初には理解しにくかったですね。X)
平たく言うとuseSelector
とuseDispatch
で構成されたカスタム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
};
}
これからはこちらのuseCounter
Hookを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を用いる方式でトライすることも良い方法だと思います。
Discussion