😇

コードベースで見るページコンポーネントと部品コンポーネントの違い

2020/10/07に公開

React や vue.js で開発を進めようとすると、必ず共通化や再利用性を意識して開発しようという文脈が登場します。
これ自体には皆納得しますが、実際にコードにどのように反映していくか落とし込んでいくかまで話がされることは稀です。
それ故に、汎用性がありすぎてただのラッパーになってしまっていたり、アプリケーションに依存したコードが書かれていたりといったことが散見されます。
これらはコンポーネントを呼び出して使う際に影響を与えます。

この記事では、ページコンポーネントと部品コンポーネントの違いをコードベースで解説していきます。

コンポーネントの種類

色々な観点があると思いますが、筆者はアプリケーションに依存しているか否かを軸に考えています。

  • アプリケーションに依存したコンポーネント(ページコンポーネント)
  • アプリケーションに依存していない汎用的なコンポーネント(部品コンポーネント)

この 2 種類に大別され、前者をページコンポーネントとして後者を部品コンポーネントの定義としてこの記事では扱います。

余談

アプリケーションに依存した汎用的なコンポーネントもあり、これは両者 2 つの特性を併せ持ちます。
関心を分離するために通信処理や副作用を記述する層(Container Component)と見た目の振舞いを記述する層(Presentational Component)をわけます。
これをセットで記述して、1 つのコンポーネントの中に閉じ込めて汎用的に呼び出せるようにしたものもあったりします。

ページコンポーネントって?

アプリケーションに依存したコンポーネントとはどういうことでしょうか。
あるドメイン内で、例えばhttps://hoge/fooというアドレスにアクセスされた時に表示されるコンポーネントを指します。
通信処理が記述されており、単体で呼び出しても動かなかったり(ユーザにとって)意味がなかったりします。

<Route exact path="/foo" component={<Foo />} />
<Route exact path="/bar" component={<Bar />} />

これは React の react-router を使ったコードですが、Route コンポーネントの中に path に対応するコンポーネントを指定しています。
この<Foo />と<Bar />がページコンポーネントに相当します。

ページコンポーネント内では、

  • ユーザが使うこと意識して作られる
  • 再利用されることを前提としていない(https://hoge/foo 内でのみ利用される)
  • ユースケースが必ず明確となっている
  • Global State へのアクセスや 通信処理(アクションの Dispatch)を行う

このような特色があり、表示に必要な情報の取得やページ構成を記述するなどの役割を担うことが多いです。
Redux における Container Component がほぼ確実に組み込まれています。

部品コンポーネントって?

部品コンポーネントは、ページコンポーネントとは別に管理されることが多く、別のライブラリーとして外出しされることもあります。
アプリケーションとは独立して動き、単体で呼び出しても見た目と振舞いが完結しています。
コンポーネントの大きさは、ボタンのように単一のノードを返すこともあれば、カードのように複数の要素を束ねて 1 つのノードを返すこともあります。
また、簡易的にテーブルを表示できるようにしたり、props で異なる見た目のボタンを複数表示できるような、いわばユーティリティ的なコンポーネントも存在し、粒度が区々です。
部品コンポーネント内では、

  • エンジニアが使うことを意識して作られる
  • 汎用的に利用されることを前提としている(https://hoge でも https://foo でも利用できる)
  • ユースケースは props を渡すことで明確となる
  • どのアプリケーションで呼び出しても同じ振舞いをする
  • アプリケーションとは独立した状態管理や副作用を持つ

言葉だけ説明されてもイマイチぱっとしませんが、
React のMaterial-UIや vue.js のvuetifyを想像してもらえれば分かりやすいと思います。
これらは、大小さまざまな部品コンポーネントのみを提供している UI ライブラリーになります。
また、Figma の component や Sketch の symbol で管理されているデザインを部品コンポーネントとしているプロジェクトも少なくはないでしょう。

フォルダ管理とファイル命名規則

ページコンポーネントと部品コンポーネントは違う役割を担うため、フォルダを分けてそれぞれを管理することをおすすめします。

src/
  ├ components/
  │ ├ Button/
  │ │ ├ RaisedButton/
  │ │ ├ FloatingActionButton/
  │ │ └ OutlinedButton/
  │ ├ TextField/
  │ │ ├ BasedTextField/
  │ │ └ FloatingLabelTextField/
  │ ├ Dialog/
  │ ├ Card/
  │ ├ Progress/
  │ ├ Icons/
----- 省略 -----
  ├ pages/
  │ ├ Login/
  │ │ ├ Description.tsx
  │ │ ├ LoginForm.tsx
  │ │ └ index.tsx
  │ └ SignUp/
----- 省略 -----

このサンプルでは components フォルダに部品コンポーネントを、pages フォルダにページコンポーネントを管理するような構成になっています。
components フォルダは、直下で部品の種別をフォルダ命名し、ファイル名はどのような部品であるかにフォーカスした命名にしています。
これらは pages フォルダで、ページコンポーネント内で適宜部品コンポーネントを組み合わせて使うようなことを想定しています。

書き方の違い

上記のサンプルの中に LoginForm というファイルがあります。
これを例に、アプリケーションに依存したコードを書いてみます。

// LoginForm.tsx
import * as React from "react";
import { FloatingLabelTextField } from "../../components/TextField";
import { RaisedButton, OutlinedButton } from "../../components/Button";

type Props = {
  isLoading: boolean;
};

export const LoginForm: React.FC<Props> = ({ isLoading }) => {
  const [user, setUser] = React.useState("");
  const [password, setPassword] = React.useState("");

  React.useEffect(() => {
    // ログイン済みであればログイン処理をスキップ
  }, []);

  const onFetchLogin = () => {
    // ログイン処理
  };
  const onPageNationSignUp = () => {
    // サインアップページへのページ遷移
  };

  return (
    <>
      <div>
        <FloatingLabelTextField
          floatingLabel="ユーザ名"
          id="user"
          value={user}
          onChange={(e) => setUser(e.target.value)}
        />
        <FloatingLabelTextField
          password
          floatingLabel="パスワード"
          id="user"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
      </div>
      <div>
        <RaisedButton
          color="primary"
          onClick={onFetchLogin}
          disabled={isLoading}
        >
          ログイン
        </RaisedButton>
        <OutlinedButton onClick={onPageNationSignUp}>
          ユーザ登録する
        </OutlinedButton>
      </div>
    </>
  );
};

ID とパスワードを入力して、ログインボタンで認証を行うような、よくあるフォームです。
もし、ログイン済みであればログイン工程をスキップ(React.useEffect 内の処理)したり、認証処理(onFetchLogin)や、登録ページへ遷移(onPageNationSignUp)するような処理がありますが、このような記述はそのアプリ専用の処理になります。
違うアプリでこのコンポーネントを呼び出した所で、認証を行うサーバや、サインアップページがないとこのコンポーネントは機能しません。
また、変数名や関数名も自ずと機能に基づいた命名になってきます。
まぁ、ログイン機能は大体のアプリにはありますが・・・。
このように自アプリでのみ正常に動く前提で書かれたものがアプリケーションに依存したコードになります。

ユースケースの注入

部品コンポーネントの特徴でユースケースは props を渡すことで明確となるをあげましたが、
LoginForm では RaisedButton という部品コンポーネントを使っています。

<RaisedButton color="primary" onClick={onFetchLogin} disabled={isLoading}>
  ログイン
</RaisedButton>

onClick にログイン処理を行う props、children に"ログイン"という props を渡すことでこの RaisedButton は押すことでログイン処理を行うボタンというユースケースが明確になっています。

部品コンポーネントの作り方

先ほどの LoginForm にある FloatingLabelTextField を例にアプリケーションに依存していないコードを書いてみます。

import * as React from "react";
import styled from "styled-components";
import { colorManager, Colors } from "../utils";

export type FloatingLabelTextFieldProps = {
  color?: "default" | "primary" | "secondary" | "warning";
  floatingLabel?: string;
  value?: string;
  password?: boolean;
  onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
} & JSX.IntrinsicElements["input"];

type Styled = {
  colors: Colors;
  floating: boolean;
};
const StyledComponent = styled.input<Styled>``;

const FloatingLabelTextField = React.forwardRef<
  HTMLInputElement,
  FloatingLabelTextFieldProps
>(
  (
    {
      color = "default",
      label,
      value: pValue = "",
      password,
      type = "text",
      onChange,
      ...props
    },
    ref
  ) => {
    const [value, setValue] = React.useState(pValue);
    const [floating, setFloating] = React.useState(false);

    React.useEffect(() => {
      // floatingさせるか否かの判定
    }, [value]);

    const changeHundler = (
      e: React.ChangeEvent<HTMLInputElement>
    ) => {
      setValue(e.target.value);
      if (onChange) {
        onChange(e);
      }
    };
    const colorPreset = colorManager(color);

    return (
      <StyledComponent
        ref={ref}
        colors={colorPreset}
        floating={floating}
        label={label}
        type={password ? "password" : type}
        value={value}
        onChange={changeHundler}
        {...props}
      />
    );
  }
);

export default FloatingLabelTextField;

styled-components でスタイリングを施し、ラベルが浮いて表示される簡易的な input を拡張したコンポーネントになっています。
また、color という props で色を変えることができるようにしています。
部品コンポーネントを作る際は

  • 型定義を export する
  • ref を渡せるようにする
  • props 名、関数名は大域的な名前にする
  • 入力値は自コンポーネントの state を利用する

を意識すると良いです。

型定義の export と forwardRef

型定義の export や ref を受け取れるようにすると呼び出す時や拡張する時などアプリ側で利用する際の助けになります。
ページコンポーネント側で部品コンポーネントの型定義を利用したい、ref で DOM 情報を参照するケースはよくあります。
また、アプリケーション側で部品コンポーネントを拡張する際などは

import FloatingLabelTextField, {
  FloatingLabelTextFieldProps,
} from "../../components/TextField";

type TextFieldProps = Pick<FloatingLabelTextFieldProps, "floatinLabel", "id">;

type Props = {
  hoge: string;
  foo: number;
} & TextFieldProps;

もしくは、、、

interface Props extends TextFieldProps {
  hoge: string;
  foo: number;
}

このように型定義を import して Pick するなり Omit するなりして使いましょう。
ページコンポーネントでの Props の型定義にはPropsという型定義を使う場合が多く、
FloatingLabelTextFieldPropsと略せずに定義しているのはこのためです。

今回は type alias で定義しいますが、拡張されることを前提としているのでinterface で定義した方が良いという考え方もありますが、筆者は好きなものを使えばいいと思っています。

JSX.IntrinsicElements について

JSX.IntrinsicElements は、input や div などの要素が取る型定義を持っています。
見た目だけスタイリングして、デフォルトの input の props は全て受け取れるようにしたいなどの場合はこの型定義を使いましょう。
ページコンポーネントではあまり使用しませんが、部品コンポーネントを作る際は重宝します。

storybook で型定義から props のテーブルを生成してる人は、これがかなり厄介だったりしますが。。。

命名規則は大域的に

名前を大域的にすることは、ユースケースを明確にさせない(汎用的に使えるようにする)ためにという理由が挙げられます。
それ以上に大域的な命名規則を課すことで作る側にパスワードを入力するためのフォームのような特定シーンの挿入を作り手に意識させないことの方に大きな意味があります。
そして、FloatingLabelTextField 内の value には入力値以上の意味を持たせないようにします。
こうすることで

const [password, setPassword] = React.useState("");

<FloatingLabelTextField
  password
  value={password}
  onChange={(e) => setPassword(e.target.value)}
/>;

ページコンポーネント側で props と value の変数名でパスワードを入力するためのフォームというユースケースを注入します。
逆に、ここで value にpassworduserIdのような名前をつけると、途端にそれ以外の用途で使うには不適切なコンポーネントとなってしまうので注意しましょう。

入力値は自コンポーネントの state を利用する

利用する側で入力値を定義して監視していれば、正直部品コンポーネント内の useState はなくても動きます。
部品コンポーネント内での state は、主に UI の状態を保つために利用します。
逆に部品コンポーネントの useState を無くした場合、props を渡さないと機能しないことを意味します。
また、state がないと入力値にまつわるテスト(onChange で更新されたかなど)ができなくなる点も踏まえると必要だと言えます。

<FloatinLabelTextField />

自コンポーネントに value を state で持たせているので、単体で呼び出しても

  • input への入力
  • 文字が入力されるとラベルが浮かぶ

がちゃんと機能するようになります(floatingLabel を渡してないのでラベルは表示されませんが)。

input 要素を例にとって解説しましたが、大小様々なコンポーネントを作っていると今回とは違った物差しがでてくることもあります。
いずれも、アプリケーションに依存しないという軸で考えたり、OSSにヒントをもらいましょう。
筆者は困った時はMaterial-UIのコードを見て参考にさせてもらってます。

Context API の利用や、パフォーマンスチューニングはするべきなのか、など大きい部品コンポーネントにはそれ特有の悩みがあったりもしますが、これはまた別の機会に書くことにします。

Discussion