Open11

React Component設計のContainer/Presentationalパターンについてお勉強

kk

Reactに入門してContainer/Presentationalパターンを教えてもらったのでいろいろ書いてみる。

kk

何も考えずにとりあえず動くコンポーネントを作ってみる

みんな大好きカウントアップ。
UIの話をするのでちゃんとUIを書こうというので、画面をメイン、フッターに分割してフッターにボタンを配置、ボタンを押すとメインに表示された数字が増える的なイメージになります。

※ 擬似コードなのでたぶん動かないです

interface Props {
}

export function MyComponent(_: Props): ReactElement {
  const [count, setCount] = useState(0);
  const handleClick = () => {
    setCount(count + 1);
  };

  return (
    <div style={{display: "flex", flexFlow: "row", width: '100%', height: '100%', background: 'black'}} >
      <div style={{flexDirection: "row", flexGlow: 1, width: '100%'}}>
        <p style={{fontSize: '100%', color: 'white'}}>{count}</p>
      </div>
      <div style={{flexDirection: "row", width: '100%', height: '128px', background: 'darkred'}}>
        <button onClick={handleClick}><i class="add-icon"></button>
      </div>
    </div>
  );
}

生で書いているというのもあるけど、これを見てどういうことをやりたいのかパッと分かる人はいないと思う。そういう意味でとても可読性の悪いコードと言える。

kk

Layoutの概念を入れてみる

だいたい辛いのはHTMLとCSSがパッと見で何を表しているのかわからないところにあると思う。
https://ja.reactjs.org/docs/composition-vs-inheritance.html
↑で言われてるコンポジションを使って、とりあえずメインとフッターという概念があることを明示してみる。

Layout.tsx

interface Props {
  content: ReactNode
  footer: ReactNode
}

export function Layout({content, footer}: Props): ReactElement {
  return (
    <Wrapper>
      <MainContent>{content}</MainContent>
      <Footer>{footer}</Footer>
    </Wrapper>
  );
}

const Wrapper = ({children}: PropsWithChildren): void => (
  <div style={{flexDirection: "row", flexGlow: 1, width: '100%'}}>
    {children}
  </div>
);

const MainContent = ({children}: PropsWithChildren): void => (
  <div style={{display: "flex", flexFlow: "row", width: '100%', height: '100%', background: 'black'}} >
    {children}
  </div>
);

const Footer = ({children}: PropsWithChildren): void => (
  <div style={{flexDirection: "row", width: '100%', height: '128px', background: 'darkred'}}>
    {children}
  </div>
);

MyComponent.tsx

interface Props {
}

export function MyComponent(_: Props): ReactElement {
  const [count, setCount] = useState(0);
  const handleClick = () => {
    setCount(count + 1);
  };

  return (
    <Layout
      content={(
        <p style={{fontSize: '100%', color: 'white'}}>{count}</p>
      )}
      footer={(
        <button onClick={handleClick}><i class="add-icon"></button>
      )}/>
  );
}

これでどういうレイアウトなのかMyConponentを見てわかりやすくなった。
Layout.tsxの内容は何を描画に絞っていて完全なPresentationalなコンポーネントと言える。

kk

NumberViewとAddIconButtonという概念を入れてみる

同じようにメインとフッターの中身の要素も概念を作ってみる。

NumberView.tsx

interface Props {
  value: number
}

export function NumberView({value}: Props): ReactElement {
  return (
    <p style={{fontSize: '100%', color: 'white'}}>{value}</p>
  );
}

AddIconButton.tsx

interface Props {
  onClick: () => void
}

export function AddIconButton({onClick}: Props): ReactElement {
  return (
    <button onClick={onClick}><i class="add-icon"></button>
  );
}

MyComponent.tsx

interface Props {
}

export function MyComponent(_: Props): ReactElement {
  const [count, setCount] = useState(0);
  const handleClick = () => {
    setCount(count + 1);
  };

  return (
    <Layout
      content={<NumberView count={count} />}
      footer={<AddIconButton onClick={handleClick} />} />
  );
}

これでMyComponentを見て何をやっているのかわかりやすくなったと思う。
アプローチとしてContainer/Presentationalパターンをやろうとした訳ではないが、見通しをよくしようとすると自然とContainer/Presentationalパターンになるの面白い。

Container

  • MyComponent

Presentational

  • Layout
  • NumberView
  • AddIconButton

こんな感じの分け方になる。

kk

AddIconButtonを汎用化する

できたコードを見ているとAddIconButtonって必要か・・・?って思えてくる。そこでIconButtonとIconの二つの概念に割って汎用的な形にしてみる。
(Layout、NumberViewなんかも大なり、小なり汎用的にできそうだけどAddIconButtonほどやばい臭いはしないのでそのままの方向で)

Button.tsx

interface Props {
  onClick: () => void
}

export function Button({
  onClick,
  children
}: PropsWithChildren<Props>): ReactElement {
  return (
    <button onClick={onClick}>{children}</button>
  );
}

AddIcon.tsx

interface Props {
}

export function Button(_: Props): ReactElement {
  return (
    <i class="add-icon">
  );
}

MyComponent.tsx

interface Props {
}

export function MyComponent(_: Props): ReactElement {
  const [count, setCount] = useState(0);
  const handleClick = () => {
    setCount(count + 1);
  };

  return (
    <Layout
      content={<NumberView count={count} />}
      footer={<Button onClick={handleClick}><AddIcon /></Button>} />
  );
}

AddIconButtonを分割してみたけどこのぐらいなら見通しはよいままになる。
何でも分ければ良いという分けではなく、過不足なくちょうど良いところにできると良さそう。まぁそれが難しいんだけど。

kk

コンポーネントライブラリとPresentationalコンポーネント

分割したAddIconButtonを見ていて思うことがある。

https://mui.com/material-ui/react-button/

_人人人人人人人人人人人人人人人人人人人人人人人人人人人人_
> MUIのドキュメントで見たことあるやつだ!(進◯ゼミ風) <
 ̄Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^ ̄

つまり、Presentationalと呼ばれるコンポーネントは最終的にUI Frameworkに行き着くのでは?
さきほどは省略したLayoutは突き詰めればカラムレイアウトを作るためのコンポーネントにできるし、NumberViewなんかも要素サイズいっぱいに文字を表示するコンポーネントにできる。

じゃあ、なんでLayoutやNumberViewは汎用化しなかったかと言うと面倒臭いからになる。
もっとちゃんと言えばAddIconButtonと違い、再利用性が低そう、MyComponentでやりたかった独自の要素で汎用化の手間が大きそう、コスパにあわなさそうという感覚から分割していなかった。

ここまでくると最初にContainer/Presentationalパターンを読んだときに受けた認識と変わってくる。

before: コンポーネントを作ったときにUIを切り出してロジックと分離しておk
after: PresentationalコンポーネントはUIの部品の一つであり、そのコンポーネントに強く密接した部品となる。再利用性を高めたものはそのアプリ独自のコンポーネントライブラリになり、そこからさらに汎用的に扱えるようにすると公開可能なコンポーネントライブラリになる

簡単にまとめると、Presentationalコンポーネントって特定のコンポーネントに強く依存していて、再利用性0なんじゃないかな。
記事とか見てると再利用云々言ってるけど、再利用の仕方はあると思っていてアプリでパッケージを切ってコンポーネントライブラリとして、そっちに移動させるぐらいのことをした方が良いと思う。

kk

NumberView・・・?

見返していて思ったけど、微妙にNumberViewが汎用化を狙うような色気を感じるネーミングになってるのに気づいた。

interface Props {
}

export function MyComponent(_: Props): ReactElement {
  const [count, setCount] = useState(0);
  const handleClick = () => {
    setCount(count + 1);
  };

  return (
    <Layout
      content={<CountView count={count} />}
      footer={<Button onClick={handleClick}><AddIcon /></Button>} />
  );
}

もし、MyComponentと密接にするなら用語を揃えるべきでCountViewと言った方が良い。
そこをわざわざ抽象化して言っているのにものすごい汎用化したい色気を感じる。

もし、ちゃんと汎用化するなら

interface Props {
}

export function MyComponent(_: Props): ReactElement {
  const [count, setCount] = useState(0);
  const handleClick = () => {
    setCount(count + 1);
  };

  return (
    <Layout
      content={<FullText>{count}</FullText>}
      footer={<Button onClick={handleClick}><AddIcon /></Button>} />
  );
}

こんな感じになりそう。ネーミングセンスがなさすぎて泣きそうになるけど。

kk

Container in Container/Container in Presentational

実際に、Container/Presentationalパターンで書いているとConatiner in Conatinerは普通に出てくる。
Container/Presentationalパターンを知ったときはなんか気持ち悪かったけど、今は特に何も思わず自然にそうなっているので何か知識足りなかったのだろうと思う。

逆にContainer in Presentationalはないなと思う。
実際のコンポーネント階層ではありえる話で
 Container -> Layout (Presentational) -> Container

というようになるが、コード上の依存関係で言うと
 Container -> Layout (Presentational)
      -> Container

と言う形で親のContainerでPresentationalにContainerを注入する形でPresentationalからは呼ばない。

この気持ち悪さの正体はわかりやすくて、PresentationalはUIの部品でしかないのにその部品がロジックに依存してるところにある感じ。コンポーネントライブラリはロジックに依存しないよねみたいな。

kk

どこまでPresentationalなコンポーネントを汎用化すべき?

interface Props {
}

export function MyComponent(_: Props): ReactElement {
  const [count, setCount] = useState(0);
  const handleClick = () => {
    setCount(count + 1);
  };

  return (
    <Layout
      content={<FullText>{count}</FullText>}
      footer={<Button onClick={handleClick}><AddIcon /></Button>} />
  );
}

汎用化すると、この規模だから許されるけどもうちょっと大きいとだいぶ可読性は落ちると思う。なんならAddIconButtonのあったときのコードが一番見通しがいい。
そうなるとどうなるかと言うと、汎用的なコンポーネントを固めたPresentationalコンポーネントが生まれる。

なので、一定のそのコンポーネントに密接したコンポーネントというものに必要性はありそう。
極まるとContainer -> Presentational -> UI Componentという階層構造になるのではという推測。

今、React+MUIでコード書いているけど実際そういう感じだし。
UI部分が少ないのであればContainer -> UI Componentみたいなケースもあるけど、だいたいはContainer -> Presentational -> UI Componentみたいになっている。
なんと言うかPresentationalの可読性を維持するためのUI Componentみたいなところがありそう。

kk

Presentationalなコンポーネントをどう書くべき?

レイヤー構造を意識して適度に汎用的なコンポーネントを作っていくのが良さそう。
今回ぐらいの内容だとContainer -> UI Componentぐらいにガチガチに汎用的に組んでも良いけど、もう少し大きいコンポーネントになるとPresentationalは必須になる。

なので、Containerと密接になるPresentationalを見極めつつ、汎用的にできるところはUI Componentにしていくような感じ。

ただ、これが個人での開発なら良いんだけど、多人数でのチーム開発では度合いが人によって違うので悩ましいとは思う。
最初にデザイントークンと定番のUI Componentを作って切り出し先を作ってから、各コンポーネントを作ってチームで協議しながらやると良さそう。
(最初にやらないとUI Componentの切り出しの初回構築コストが面倒でやらなくなるので最初にやるのが良さそう)