72
49

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NIJIBOXAdvent Calendar 2022

Day 10

初 React 案件参画当初の自分に伝えたい、コンポーネント設計でつまづいたこと

Last updated at Posted at 2023-02-01

約2ヶ月前、いわゆるモダンと言われる技術スタックを採用するプロジェクトチームへ FE として配属されることとなりました。
以前は HTML、CSS、jQuery を用いて、主にデザイン再現のみを行うエンハンス業務を担当してきましたが、環境が一転して React や Next.js、GraphQL といった新しい技術を扱う事となり、今まであまり考えてこなかったコンポーネント設計や API スキーマ定義、GraphQL クライアントを用いた BFF によるデータ取得など、幅広い範囲を見渡す必要が出てきました。

今回はその中でも特に React のコンポーネント設計について、今、参画前の自分に伝えたいなと思ったことを、振り返りも兼ねていくつかまとめてみました。
自学習で React のルール自体はある程度分かっていたものの、実際のプロジェクトに落とし込んだ時にどのように実装するべきか悩んだところが多々ありました。少しでも同じように思っている方の参考になる内容となっていれば幸いです。

どれくらいの粒度でコンポーネントを分割すべきなのか

以前の現場では HTML、CSS、JS ファイルはページ単位で管理されていました。
CSS 設計は BEM を採用していたためある程度 Block や Element に分けるということはしていましたが、コンポーネント単位での運用を深く意識する必要はありませんでした。
そのため、実案件で初めて React を使用した UI 作成を任された時、何を基準にコンポーネントを分割すべきか判断に迷う場面も少なくありませんでした。

【例】 スライドメニューの実装を考えてみる

参画後、自分が初めて実装したスライドメニューを例にとりあげてみます。
ヘッダーのハンバーガーメニューアイコンをタップするとサイドからにょきっと生えてくる、誰もが一度は目にしたことのある UI です。

悩んだポイント

例えばこのようなデザインに対して、下にある左図のような子コンポーネントを持たない1階層のみのコンポーネントで定義するのか、それとも右図のように繰り返されている見出しのついたメニューリストのブロックをさらにコンポーネント化した方が良いのか…といったことに悩み、先輩 FE に相談していた記憶があります。
ただただ漠然と、コンポーネントはなるべく細かく分けておいた方がなんとなく後々色々使えて便利な気がするな…と特に根拠もなく曖昧に捉えていました。

After 実装

先に最終的な実装を記載すると、上記スライドメニューの例は以下のようにしてみました。
▼ SlideMenu コンポーネント

const MENU_LIST = [
  {
    title: "マイページ",
    list: [
      {
        pageName: "お気に入り",
        path: "/favorite",
      },
      {
        pageName: "閲覧履歴",
        path: "/history",
      },
    ],
  },
  {
    title: "メニュー",
    list: [
      {
        pageName: "ブログ",
        path: "/blog",
      },
      // …
    ],
  },
  // …
];

export const SlideMenu: React.FC<Props> = ({ className }) => {
  return (
    <nav>
      <ul>
        {MENU_LIST.map((menu, index) => (
          <li key={index} className={styles.menuBlock}>
            <h2 className={styles.menuTitle}>{menu.title}</h2>
            <ul className={styles.menuList}>
              {menu.list.map((data, index) => (
                <li key={index} className={styles.menuListItem}>
                  <Link href={data.path}>{data.pageName}</Link>
                </li>
              ))}
            </ul>
          </li>
        ))}
      </ul>
    </nav>
  );
};

▼ SlideMenu を読み込む Header コンポーネント

export const Header: React.FC<Props> = () => {
  // メニューの表示、非表示を state として管理
  const [isShown, setIsShown] = useState(false);

  const handleClickMenu = () => {
    setIsShown(!isShown);
  };

  return (
    <header>
      <button type="button" className={styles.button} onClick={handleClickMenu}>
        <MenuIcon />
      </button>
      <Portal>
        <div area-modal="true" data-is-shown={isShown}>
          <button
            type="button"
            className={styles.overlay}
            onClick={handleClickMenu}
          ></button>
          {/* data 属性はスライド移動アニメーションのために付与 */}
          <div className={styles.menuInner} data-is-shown={isShown}>
            <CloseIcon />
            <SlideMenu />
          </div>
        </div>
      </Portal>
    </header>
  );
};

まず overlay(透過の背景)と CloseIcon( ✕ ボタン)を親コンポーネントに持たせています。
可能ならメニューの開閉トリガーを対として同階層に置くと、見通しが良く分かりやすいというアドバイスをいただいてこのような構造にしてみました。
その上で、サイドメニューの部分は細かく分割したりせず、純粋にメニューリストのデータを map するだけのコンポーネントとしました。
純粋にそこまで複雑な UI ではなく、Storybook でこのブロックだけを切り出して別途確認したいという需要も無いだろうと考え、コンポーネント化するメリットを感じなかったためです。
初めは細かくコンポーネントに区切った方がコードの量も減ってすっきりするのではくらいに考えていたのですが、実際に手を動かして考えてみるとちゃんと適切な粒度があるのだということが分かってきました。

自分の中にある程度の判断基準を作っておくと良さそう

では結局何を基準にコンポーネントの分割を考えれば良いのでしょうか。
先輩方の意見を参考にしつつ、いくつかコンポーネントを作成していく中で分かってきたことは、「明確な基準はないし正解もない。それでもある程度判断の目安はありそう。」ということです。

例えば、自分は今何かしらの UI を作るときになんとなく以下のような点を目安としてコンポーネントを分けているように思います。

  • API から取得するデータの粒度に合わせる
  • デザインシステムの定義に合わせる
  • 後々 Storybook で確認するのに分かりやすいだろうと思う粒度に合わせる
  • 1つのコンポーネントにするとコードが肥大化し見通しが悪くなってしまいそうなら、区切り良い範囲でより小さく分割することを検討する
  • 複雑な処理をしなければならなそうなら、より小さい機能ごとに細かく分割することを検討する
  • 将来的に新たな機能が付け足される可能性が考えられるなら、あらかじめ分割してしまうことを検討する

こうして書き出してみると、デザイナーやバックエンドチーム、ディレクターなど、他職種の方々との認識をあわせておくことも重要なのだと改めて感じます。
上に挙げたとおり、デザインシステムはどのように作られているのか、BE からどんなデータが渡ってくるのか、今後エンハンスしていくにあたってどういったビジョンを持っているのかといった情報を元に判断することが必要だと思うためです。

しかし最終的にはケースバイケース

それでも、最終的には状況によって適切なコンポーネント分割の粒度は変わってきます。
ただ分ければいいというものでもなく適切な大きさがあるのだというのは分かってきましたが、その人の経験や感性によりその「適切な大きさ」の基準も変わってくるのだろうと思います。
なお、Atomic Design の考え方を取り入れればよいのでは?と思われるかもしれませんが、そうしたとしても悩みは変わりません。

そのため、こんなことを言うのもなんですが色々な基準を考えてもやっぱり難しい!よく分からん!とうのが今のところの自分の中での結論です。
人によって意見が違うところなので、迷った時はその都度チーム内で議論して共通認識を持つことが大事なのだと思います。

props としてどんな値を受け取るようにしておけば良いのか

慣れないコンポーネント作成の中でよく悩んだのが、何を props で渡すようにするべきなのかということでした。

【例】 スイッチの実装を考えてみる

例えば、下画像のようなスイッチが並ぶ form の実装について考える機会がありました。

※ 引用:https://chakra-ui.com/docs/components/switch/usage

スイッチ自体はクリックすると白い丸の部分が左右に動いてスイッチの色が変わるような、こちらもよく見る UI だと思います。

悩んだポイント

ON、OFF の切り替えをそのスイッチ自体のコンポーネントが行うようにすべきなのか、または親コンポーネントに委ねるべきなのか、いまいちピンときていませんでした。
スイッチはつまり、機能としてはチェックボックスと同様であるため、<input type="checkbox" /> で実装すれば、特段外部から ON / OFF(true / false)の値を渡さずともクリックするだけで自動的に切り替えは実現可能です。
そのため、初めはあまり深く考えずに以下のようなシンプルな形で実装しようとしていました。

type Props = {
  id: string;
};

export const Switch: React.FC<Props> = ({ id }) => {
  return (
    <div>
      <input type="checkbox" id={id} />
      <span className={styles.switchInner}>
        {/* 左右に動く白丸の部分 */}
        <span className={styles.round}></span>
      </span>
    </div>
  );
};

After 実装

しかし最終的には、親コンポーネントから checked の値を props として受け取るよう変更することにしました。

type Props = {
  id: string;
  isChecked: boolean;
  handleChangeCheck: (e: React.ChangeEvent<HTMLInputElement>) => void;
};

export const Switch: React.FC<Props> = ({
  id,
  isChecked,
  handleChangeCheck,
}) => {
  return (
    <div>
      <input
        type="checkbox"
        className={styles.checkbox}
        id={id}
        checked={isChecked}
        onChange={handleChangeCheck}
      />
      <span className={styles.switchInner}>
        {/* 左右に動く白丸の部分(アニメーションのために data 属性を付与) */}
        <span className={styles.round} data-is-checked={isChecked}></span>
      </span>
    </div>
  );
};

あわせて onChage イベント関数も追加しています。

コンポーネントが使用される場面をイメージする

変更前の実装でも、スイッチの ON / OFF の切り替え自体は実現できていました。
しかし、form で使用されることを考えると、最終的にどのスイッチが ON でどのスイッチが OFF になっているのかを判別して POST する必要があります。
そのため、親コンポーネント(form の送信ボタンがあるコンポーネント)で状態を管理している方が都合が良いと思い、上述のように変更することにしました。
初めは完全に見た目だけを考えて実装してしまっていましたが、ある程度そのコンポーネントが使用される状況を考えて構造を考えると良かったなと感じました。

迷ったら有名なコンポーネントライブラリの実装を参考にしてみる

と言いつつ最初はやっぱり判断に迷う部分もあり、もっと具体的な実装例があれば嬉しいなと思ったりもしていました。
そこで、こういったよくある UI の実装であれば最近はまず MUIChakra UI といった UI コンポーネントライブラリ等の実装を見てみるようにしています。
先輩 FE の方に、分からなくなったらこういったものを参考にすると良いという風にアドバイスいただいてからは、個人的に大変お世話になっています。
props として何を渡せるようになっているのかなども簡単に確認でき、使用例に GitHub へのリンクがあるためそこからより詳しい内部実装を確認することも可能です。

実際にスイッチのコンポーネントに関しては以下のページを参考にしました。

不慣れなうちは、こういったリソースを元に自分の引き出しを増やしていくのも良いかもしれません。

コンポーネント同士、どこまで依存関係を持たせて良いものなのか

親コンポーネントから子コンポーネントの DOM を操作するような実装は問題ないのだろうかと悩んだ場面がありました。
JavaScript では比較的気軽に document.getelementbyId などを用いて対象の要素を取得し、DOM 操作を行っていました。
React においても useRef を使用することで DOM の取得は可能です。

【例】 スクロール可能なモーダルの実装を考えてみる

中身がスクロールできるモーダルを実装する際、モーダルが開く度にスクロール位置を一番上に戻したいという要件があり、そのためにはモーダルコンポーネントから中身のコンテンツである子コンポーネントの DOM を取得し、scrollTo メソッドを使用する必要があると考えていました。

悩んだポイント

当初は下に記載したコードのような実装をしていました。
しかし、せっかくコンポーネントに分けてそれぞれの役割を分離することができたのに、親で操作することを前提のコンポーネントを作ってしまうのは、なんとなく依存度が高くなってしまい良くない気がする…と感じていました。

▼ モーダルコンポーネント

type Props = {
  isShown: boolean;
  handleCloseClick: () => void;
}

export const Modal = forwardRef<HTMLDivElement, Props>(
  ({ isShown, handleCloseClick }, ref) => {
    return (
      <div area-modal="true" data-is-shown={isShown}>
        <div className={styles.overlay}></div>
        <button
          type="button"
          onClick={handleCloseClick}
        >
          <CloseIcon />
        </button>
        <div ref={ref}>
          {/* スクロール可能なコンテンツ */}
        </div>
      </div>
    );
  },
);

Modal.displayName = "Modal";

forwardRef を使うことで、親コンポーネントから ref を受け取ることができるようにしています。

▼ モーダルコンポーネントを使用する親コンポーネント

export const Parent: React.FC = () => {
  const [isShown, setIsShown] = useState(false);
  const ref = useRef<HTMLDivElement>(null);
  
  const handleCloseClick = () => {
    setIsShown(!isShown);
  };  

  const handleOpenClick = () => {
    if (ref.current) ref.current.scrollTo(0, 0);
    setIsShown(!isShown);
  };

  return (
    <>
      <button type="button" onClick={handleOpenClick}>
        開く
      </button>
      <Modal ref={ref} isShown={isShown} handleCloseClick={handleCloseClick} />
    </>
  );
}

After 実装

今回の場合は、useEffect を使って親コンポーネントから props で渡している isShown フラグ(モーダル開閉フラグ)の変化を監視することで、forwardRef を使わずに実装することができました。

▼ モーダルコンポーネント

const Modal: React.FC<Props> = ({ isShown, handleCloseClick }) => {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (isShown) ref.current && ref.current.scrollTo(0, 0);
  }, [isShown])

  return (
    <div area-modal="true" data-is-shown={isShown}>
      <div className={styles.overlay}></div>
      <button
        type="button"
        onClick={handleCloseClick}
      >
        <CloseIcon />
      </button>
      <div ref={ref}>
        // スクロール可能なコンテンツ
      </div>
    </div>
  )
}

ref の参照もモーダルコンポーネント内に閉じることができたので親コンポーネントに操作を委ねずとも良く、よりスッキリ書くことができたと思います。

コンポーネントの特性を考えて判断する

一律どこでも同じような使われ方をして欲しいようなコンポーネントに関しては、コンポーネント外から簡単に操作できてしまうのはあまり良くないだろうということで、親と子は疎結合にしておいた方が良いという判断のもと、上記のような実装になりました。

しかし、例えば色んな場面で部品的な使われ方をするようなパーツだった場合、ある程度自由がきくほうが取り回しが良く、かえって外から操作できるようにしておくのも良いというご意見もいただきました。
当初「コンポーネント同士は props での値受け渡し以外はなるべく疎結合にすべき」と思っていたのですが、そのコンポーネントの特性を考えて、ケースバイケースで柔軟に対応するべきなんだなという学びになりました。

ちゃんと公式サイトを確認すると、より良い設計のヒントがあるかも

少し脱線するかもしれませんが、React 公式ドキュメントの useRefforwardRef ついての項目を確認すると、「お互いのコンポーネントの DOM 構造に過剰に依存する実装はあまり望ましくない。ただし Atomic Design で言うところの Atoms のような “末梢の” コンポーネントに関してはその限りではない」といったニュアンスのことがしっかり記載されていました。

forwardRef は今まで使ったことがなく、公式サイトをあまり深く読み込まずに Qiita や Zenn の記事を見て実装してしまっていたのですが、信頼できるリソースをしっかり読み解く癖をつけることも大事だと改めて感じました。

レイアウトのためのスタイルとコンポーネントのスタイルを分離できているか

CSS の観点で意外とできていなかったなと思うのが、レイアウトのスタイルとコンポーネント自身のスタイルを分離することです。
ページとコンポーネントの両方にレイアウト用のスタイルを分散させてしまい、PR で指摘されてしまうことがありました。

【例】 よくあるページレイアウトのパターンを考えてみる

例えば、以下のようなレイアウトを実現するとします。

  • <Header /><Main /><Footer /> が縦に並ぶ
  • <Header /><Footer /> は高さが固定
  • <Main /> は viewport に応じて高さが可変となる

Before 実装

当初、自分は以下のようにレイアウトのためのスタイルとコンポーネントのスタイルが混在した実装にしてしまっていました。

▼ Page

// page.index.tsx
const Page: NextPage = () => (
  <div className={styles.root}>
    <Header />
    <Main />
    <Footer />
  </>
);
/* page/style.module.css */
.root {
  display: flex;
  flex-direction: column;
}

▼ Main コンポーネント

// component/Main/index.tsx
export const Main: React.FC = () => (
  <div className={styles.root}>
    // コンテンツ
  </div>
);
/* component/Main/style.module.css */
.root {
  flex: 1;
}

ここで <Main /> は自分自身が flex アイテムであることを知っているべきではありません。
レイアウトを変えようと思った時に2つのファイルにまたがって修正を加える必要が生じるためあまり効率的でありませんし、flex での配置を前提としたコンポーネントになってしまうので再利用性も低くなりそうです。

After 実装

コンポーネントの props に className を渡し、ページ側からレイアウト用のスタイルを適用する

上記例のような場合、例えば以下のような手段でレイアウトとコンポーネントのスタイルを分離することができます。

▼ Page

// page.index.tsx
const Page: NextPage = () => (
  <div className={styles.root}>
    <Header />
    <Main className={styles.main} />
    <Footer />
  </>
);
/* page/style.module.css */
.root {
  display: flex;
  flex-direction: column;
}

.main {
  flex: 1;
}

▼ Main コンポーネント

// component/Main/index.tsx
import clsx from "clsx";

type Props = {
  className?: string;
}

export const Main: React.FC<Props> = ({ className }) => (
  <div className={clsx([className, styles.root])}>
    // コンテンツ
  </div>
);
ページ側にある親の DOM に grid を適用し、子コンポーネントをレイアウトする

また、この場合は grid を使うことで CSS のみで解決することもできました。

▼ Page

/* page/style.module.css */
.root {
  display: grid;
  grid-template-rows: max-content 1fr max-content; 
}

余談ですが、異動前の現場ではある程度レガシーなブラウザや OS に対応する必要があり、grid などの CSS プロパティはあまり使用できる機会がありませんでした。
しかしそういった制約がないところでは、少ない記述で柔軟なレイアウトが実現できるため、積極的に使用していきたいと感じています。

まとめ

自学習で React を書くことはありましたが、実際にプロダクト開発の中でコーディングしてみると「よりよい設計とは?」を考える機会が格段に増えました。
今回記載したことが全て正しいというわけではなく、今もまだ悩みながら模索中の部分も多いのですが、参画当初よりも少しは考え方が分かってきたのかも、と思っています。
在籍するプロジェクトチームでは、週3回ほど実装に困っていることを持ち込める場が設けられていて、相談のハードルが低くなっていることもとてもありがたいなという印象です。
チームとコミュニケーションを取りながら、プロジェクト全体でより良い設計ができるよう精進していきたいなと思います。

72
49
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
72
49

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?