修正しやすい UI を作るためのコンポーネント設計

こんにちは、フロントエンドエンジニア兼スクラムマスターをやっています、白井です。

今回は、日々フロントエンド実装やレビューをしていく中で、コンポーネント設計をする際にここは抑えておきたいと感じた基礎的な部分についてまとめてみました。

様々な意見はあると思いますが、後からでも楽に修正・拡張できるような一例を紹介したいと思います。

背景

昨今、Cursor、Claude Code、GitHub Copilot といった AI を活用したコーディングは日常的に利用されている方も多いと思います。

特にフロントエンドはその恩恵を受けやすいと感じでおり、自分自身コードを書く時間よりも AI に指示している時間の方が長いのではないか...と思うくらいです。

ただその中で同時に、次のようなことも感じ始めました。

  • コード自体は自然言語で簡単に生成できるようになり、速いスピードで実際に動くものを作ることができるようになった。
  • しかし、そのコードが今後の機能追加や修正があった場合に、保守しやすい設計思想になっているのかどうかの最終決定はエンジニアに依存する。
  • 特に初期段階で設計を誤ると、動くが修正しづらいコードになり、時間が経つほど負債が大きくなる。

レビューをしている際にも「今は動くけど、今後拡張する時に困りそうだ」と思うような場面も多く、まずは比較的わかりやすい「コンポーネント」にフォーカスして、抑えておいた方が良いものをまとめようと思ったのが今回の経緯です。

コンポーネント設計に大事なこと

1. ページとコンテンツを分離する

React などでページルートのファイルとして page/hoge/index.tsx のようなものを作成した時に、そのファイルに全ての処理や UI を詰め込んでしまうと、バックエンドで言うところの Controller のメソッドに全てのロジックが入っているような状態となり、修正時に影響範囲が無駄に広がってしまいます。

また1ファイルの行数も伸びるので、修正する方も読みづらくなります。

解決策

少なくともルートファイルにはロジックはほとんど持たせず、必要なコンポーネントの呼び出し程度に抑えておくと良いでしょう。

  • ページのルートファイルはヘッダやサイドナビなど「共通要素の呼び出し」に限定する。
  • メインのコンテンツは専用のコンポーネントに分離する。

役割を別のファイルに分離し、行数も抑えることができます。

サンプルコード

❌ ページに全てを詰め込んでいる

const UserProfilePage = () => {
  const { data: user } = useSWR(‘user’, () => fetchUser());
  const { data: posts } = useSWR('posts', () => fetchPosts());
  
  return (
    <div>
      <Header />
      <nav>
        <ul>
          <li>ホーム</li>
          <li>プロフィール</li>
        </ul>
      </nav>
      <main>
        <section>
          <img src={user?.avatar} alt="アバター" />
          <h1>{user?.name}</h1>
          <button>編集</button>
        </section>
        <section>
          <h2>投稿一覧</h2>
          {posts.map(post => (
            <article key={post.id}>
              <h3>{post.title}</h3>
              <p>{post.content}</p>
            </article>
          ))}
        </section>
      </main>
      <Footer />
    </div>
  );
};

多くのプロダクトではサイドナビやヘッダーが共通化されているとは思いますが...例で全て詰め込んでいます。

✅ ページとコンテンツのコンポーネントを適切な粒度で分離している

// pages/UserProfilePage.jsx - ページのルートファイル
const UserProfilePage = () => {
  return (
    <PageLayout>
      <UserProfileContent />
    </PageLayout>
  );
};

// components/UserProfileContent.jsx - メインコンテンツ
const UserProfileContent = () => {
  const { user }, setUser] = useSWR(() => fetchUser());
  const { posts }, setPosts] = useSWR(() => fetchPosts());

  
  return (
    <main>
      <UserProfileHeader user={user} />
      <UserPostsList posts={posts} />
    </main>
  );
};

// components/PageLayout.jsx - 共通レイアウト
const PageLayout = ({ children }) => {
  return (
    <div>
      <Header />
      <Navigation />
      {children}
      <Footer />
    </div>
  );
};

ページルートファイルが共通箇所の呼び出しとコンテンツの呼び出しだけの最小限構成となり、修正時の影響が分かりやすくなりました。

上記のようにコンテンツだけでなく UserProfileHeader や UserPostsList のようなさらに細かい粒度に分離しておくと、1ファイルの行数もさらに減り修正の影響範囲をより狭めることができます。

2. 最小単位のコンポーネントを共通化する

Button や Input のような、よくあるパーツをページごとに都度スタイルを定義していると、デザイン変更の際に全て探して修正しなければならなかったり、定義ミスで微妙な差異が出てしまいデザインの統一感が失われるなどの原因になります。

今ではコンポーネント化することは当然だと思いますが、古いプロダクトなどでは共通化されていないこともあり、追加の開発時もコピペで流用されることもしばしばあります。

解決策

  • Button や Input などよく使うUIは必ず共通化して再利用可能にする。
  • 変更は一箇所で済むように設計する。

「他のページはコピペで作ってしまってるから...」ではなく、ぜひ気づいた時にコンポーネントにしていきましょう。

そして、こういったコンポーネントは作っただけで終わりではありません。作ったはいいものの、実際にレビューをしているとなかなか使われないこともあります。周知しつつ、レビューでしっかり指摘して伝えくことも重要です。

サンプルコード

❌ よく使われるパーツに対して都度 styled-component でスタイル定義している

import styled from 'styled-components';

// UserForm.jsx
const Tag = styled.span`
  padding: 4px 8px;
  background-color: #e3f2fd;
  color: #1976d2;
  border-radius: 4px;
  font-size: 12px;
`;

const UserForm = () => {
  return (
    <div>
      <Tag>管理者</Tag>
      <Tag>アクティブ</Tag>
    </div>
  );
};

// ProductForm.jsx
const ProductTag = styled.span`
  padding: 4px 8px;
  background-color: #e3f2fd;
  color: #1976d2;
  border-radius: 4px;
  font-size: 12px;
`;

const ProductForm = () => {
  return (
    <div>
      <input placeholder="商品名を入力" />
      <ProductTag>新商品</ProductTag>
      <ProductTag>在庫あり</ProductTag>
    </div>
  );
};

二つのページでほぼ同じスタイルを適用しています。おそらく今後別のページでも同様のコードが生まれることでしょう。

✅ 共通コンポーネントとして切り出している

// components/ui/Tag.jsx - 共通Tagコンポーネント
import styled from 'styled-components';

const StyledTag = styled.span`
  padding: 4px 8px;
  border-radius: 4px;
  font-size: 12px;
  background-color: ${props => {
    switch (props.variant) {
      case 'primary': return '#e3f2fd';
      case 'success': return '#e8f5e8';
      default: return '#f5f5f5';
    }
  }};
  color: ${props => {
    switch (props.variant) {
      case 'primary': return '#1976d2';
      case 'success': return '#2e7d32';
      default: return '#666';
    }
  }};
`;

export const Tag = ({ variant = 'default', children, ...props }) => {
  return (
    <StyledTag variant={variant} {...props}>
      {children}
    </StyledTag>
  );
};

共通的に利用できるコンポーネントに切り分け、毎度定義する必要はなくなりました。

3. デザインのために作られたコンポーネントを外部状態に依存させない

デザインのために作られた UI コンポーネントで、例えばグローバル状態やページパスに依存して変化してしまうと、影響がわかりづらく、ただそのデザインを使いまわしたい時に思わぬバグを生む原因になります。

そのコンポーネントに与えた props だけでは動作を予測しにくくなり、テストが困難になったり、再利用性が低下してしまいます。

解決策

  • UI コンポーネントを制御する情報は可能な限り props で受け取る。
  • ページパスや状態などは UI コンポーネントには含めず上位のコンポーネントで制御する。

UI コンポーネントをシンプルに保つことで、再利用性の高いコンポーネントにすることができます。

サンプルコード

❌ 外部状態に依存している汎用コンポーネント

import { useAtom } from 'jotai';
import { modalAtom } from './atoms/modalAtom';

const Modal = () => {
  const [modal, setModal] = useRecoilState(modalAtom);
  
  if (!modal.isOpen) return null;
  
  return (
    <div>
      <p>{modal.message}</p>
      <button onClick={() => setModal({ ...modal, isOpen: false })}>
        閉じる
      </button>
    </div>
  );
};

この Modal は modalAtom に依存しており、別の方法でデータを Modal に渡したい時にこのコンポーネントを利用できなくなります。

✅ propsで状態を受け取る純粋なコンポーネント

const Modal = ({ isOpen, message, onClose }) => {
  if (!isOpen) return null;

  return (
    <div role="dialog" aria-modal="true">
      <p>{message}</p>
      <button onClick={onClose}>閉じる</button>
    </div>
  );
};

// SomeContent.tsx - Modal の呼び出し 
const SomeContent = () => {
  const { isOpen, message, close } = useModal()

  return (
    <div>
      <button onClick={() => open('保存しました')}>モーダルを開く</button>
      <Modal
        isOpen={isOpen}
        message={message}
        onClose={close}
      />
    </div>
  );
};

Modal は props でデータを受け取るだけのシンプルな形になり、再利用しやすくなりました。

また useModal のような hooks を作成し Modal の開閉状態の管理を別に分けておくと、別の箇所でより楽に Modal を再利用することができます。

4. UI ライブラリのラッパーコンポーネントを作る

MUI などの UI ライブラリを利用した場合に、そのサービス全体でデフォルトの Button に少し独自のデザインを加えたくなったとしましょう。

その場合に Button を import して毎回独自デザインのための props / style を設定していると、使うたびに設定が必要となってしまい、また今後の全体のデザイン変更の際にも手間となります。

解決策

  • MUI などを直接使うのではなく、オリジナルの Button などでラップしておくと共通的に設定しなくてはいけない props を設定し、呼び出すときに都度設定する必要がなくなります。
  • またライブラリ変更時や、一部共通的に入れたい(例えばアクセシビリティの対応で特定の属性を全ての Button に付与したいなど)時にも、ラップした Button コンポーネントを改修することで全箇所に適用することができます。

💡 MUI の場合は MUI Theme の defaultProps を利用することもできる

MUI を例に挙げましたが、MUI Theme の defaultProps を使うことでスタイル設定の上書きを一括で行うことができます。もちろんこちらの手法であればラップしたコンポーネントは不要です。

ただラッパーコンポーネントにしておけば後からいくらでも好きにカスタマイズできるので、こちらはプロジェクトに合わせて適切な方法を利用してみてください。

参考:https://mui.com/material-ui/customization/theme-components/#theme-default-props

サンプルコード

❌ MUI を直接使用している

import { Button, TextField } from '@mui/material';

// UserForm.jsx
const UserForm = () => {
  return (
    <form>
      <Button
        variant="contained"
        sx={{ backgroundColor: '#1976d2', borderRadius: '8px' }}
      >
        保存
      </Button>
    </form>
  );
};

// ProductForm.jsx 
const ProductForm = () => {
  return (
    <form>
      <Button
        variant="contained"
        sx={{ backgroundColor: '#1976d2', borderRadius: '8px' }}
      >
        登録
      </Button>
    </form>
  );
};

✅ ラッパーコンポーネントでライブラリを隠蔽

// components/ui/Button.jsx - MUIをラップした独自Button
import { Button as MuiButton } from '@mui/material';
export const Button = ({ children, ...props }) => {
  return (
    <MuiButton
      variant="contained"
      sx={{
        backgroundColor: '#1976d2',
        borderRadius: '8px',
      }}
      {...props}
    >
      <ButtonInner>
      {children}
      </ButtonInner>
    </MuiButton>
  );
};

こちらの Button コンポーネントを利用することで、毎回設定していた共通的な props が不要になり、今後の共通的な変更にも強くなります。

派生パターン:フォーム連携用のラッパーコンポーネント

もう一つ違うラッパーコンポーネントの例として、React Hook Form を利用する場合を考えてみましょう。

React Hook Form には Controller というフォーム入力コンポーネントとバリデーションロジックをつなぐための仕組みがありますが、そちらを使う際に利用側で毎回 Controller を使用するとコードが長くなりがちで、かつ似たようなコードが増える要因になります。

このようなシーンで使い回すために Controller と UI を含めたコンポーネントを作成して利用した場合、React Hook Form を常に使う分には問題ありませんが、もし利用しないようなシーンが出てきた場合に再利用できなくなること、また先ほど挙げた外部依存の UI コンポーネントとなってしまいます。

Controller 用のラッパーコンポーネントを別で用意しておくと、UI と機能が分離され、それぞれ使いたいシーンに合わせてコンポーネントを選ぶことができます。

❌ Controller と UI のコードを一緒にする

export const TicketCounter = ({ control, name, label, min, max }) => {
  return (
    <Controller
      name={name}
      control={control}
      render={({ field }) => (
        <div>
          <label>{label}</label>
          <button onClick={() => field.onChange(Math.max(min, field.value - 1))}>-</button>
          <span>{field.value}</span>
          <button onClick={() => field.onChange(Math.min(max, field.value + 1))}>+</button>
        </div>
      )}
    />
  )
}

Controller ありきのコンポーネントのため、React Hook Form を必ず利用しなくてはならないコンポーネントとなってしまっています。

✅ Controller と UI を分離する

// Controller Wrapper Component
export const TicketCounterController = ({ control, name, label, min, max }) => {
  return (
    <Controller
      name={name}
      control={control}
      render={({ field }) => (
        <TicketCounter
          label={label}
          count={field.value}
          onChange={field.onChange}
          min={min}
          max={max}
        />
      )}
    />
  )
}

// UI Component
export const TicketCounter = ({ label, count = 0, onChange, min, max }) => {
  return (
    <div>
      <label>{label}</label>
      <button onClick={() => onChange(Math.max(min, count - 1))}>-</button>
      <span>{count}</span>
      <button onClick={() => onChange(Math.min(max, count + 1))}>+</button>
    </div>
  )
}

分離することで、React Hook Form と接続したい場合は TicketCounterController を利用し、そうでない場合は TicketCounter を利用することができます。

TicketCounterController の方は UI には関与していないため、UI の変更があった際に追加で修正する必要もありません。

5. レイアウト(配置、余白)と見た目(色、線、形)を分離する

見た目(このセクションでは色、線、形などを表すものを指します)のために存在するコンポーネント自身が、margin や位置(position や align-self 等)に関する情報を持ってしまうと、呼び出し側での調整が困難になってしまう、という問題があります。

例えばコンポーネントルートに常に margin があり、呼び出し元で marign が不要だった場合はネガティブマージンで打ち消したり、props を追加する必要が出てきます。また、もしかしたら逆に margin を増やしたくなるシーンもあるかもしれません。

回避するために呼び出し元で余白や位置の設定をオーバーライドをしていくと、次第にレイアウトのスタイルが複雑化してしまいます。

解決策

  • コンポーネントはなるべく見た目の情報のみを持たせる。
  • 呼び出し元はレイアウト(配置、余白)を管理し、見た目の情報はできる限り持ち込まない。

呼び出し元で余計なオーバーライドを減らせると、管理がシンプルになりレイアウト調整がしやすく崩れも起きにくくなります。

サンプルコード

❌ レイアウトと見た目が混在している

import styled from 'styled-components';

const SearchBox = styled.div`
  background-color: white;
  border-radius: 8px;
  padding: 16px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  margin-bottom: 24px;
`;

const SectionTitle = styled.h2`
  font-size: 20px;
  color: #333;
  font-weight: bold;
  margin-bottom: 16px;
`;

const ProductCard = styled.div`
  background-color: white;
  border-radius: 8px;
  padding: 16px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  border: 1px solid #eee;
  width: 300px;
  margin: 8px;
`;

const SearchContent = () => {
  return (
    <div>
      <SearchBox>
        <input placeholder="商品名を入力" />
      </SearchBox>
      <SectionTitle>検索結果</SectionTitle>
      <div style={{ display: 'flex', flexWrap: 'wrap' }}>
        <ProductCard>
          <h3>商品A</h3>
          <p>商品Aの説明です</p>
        </ProductCard>
        <ProductCard>
          <h3>商品B</h3>
          <p>商品Bの説明です</p>
        </ProductCard>
      </div>
    </div>
  );
};

SearchBox や ProductCard 自身が余白情報を持っており、新たにセクションを追加した場合に同じように margin を設定する必要も出てきます。また、各セクションがどれくらい余白が空いているのかもぱっと見わかりづらいです。

✅ レイアウトと見た目を分離している

// components/ui/Card.jsx - デザインのみを担当
const StyledCard = styled.div` ... `;
export const Card = ({ children, ...props }) => {
  return <StyledCard {...props}>{children}</StyledCard>;
};

// components/ui/SectionTitle.jsx - デザインのみを担当
const StyledSectionTitle = styled.h2` ... `;
export const SectionTitle = ({ children, ...props }) => {
  return <StyledSectionTitle {...props}>{children}</StyledSectionTitle>;
};

// components/ui/SearchBox.jsx - デザインのみを担当
const StyledSearchBox = styled.div` ... `;
export const SearchBox = ({ placeholder, ...props }) => {
  return (
    <StyledSearchBox {...props}>
      <input placeholder={placeholder} />
    </StyledSearchBox>
  );
};

// components/ProductCard.jsx - デザインのみを担当
import { Card } from './ui/Card';
const CardTitle = styled.h3` ... `;
const CardDescription = styled.p` ... `;
export const ProductCard = ({ title, description, ...props }) => {
  return (
    <Card {...props}>
      <CardTitle>{title}</CardTitle>
      <CardDescription>{description}</CardDescription>
    </Card>
  );
};

レイアウトコンポーネント(配置のみを担当):

// components/SearchContent.jsx - レイアウトのみを担当
import styled from 'styled-components';
import { SearchBox } from './ui/SearchBox';
import { SectionTitle } from './ui/SectionTitle';
import { ProductCard } from './ProductCard';

const SearchGrid = styled.div`
  display: grid;
  grid-template-rows: auto auto 1fr;
  gap: 24px;
`;

const ResultsGrid = styled.div`
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: 16px;
`;

export const SearchContent = ({ products }) => {
  return (
    <SearchGrid>
      <SearchBox placeholder="商品名を入力" />
      <SectionTitle>検索結果</SectionTitle>
      <ResultsGrid>
        {products.map(product => (
          <ProductCard
            key={product.id}
            title={product.title}
            description={product.description}
          />
        ))}
      </ResultsGrid>
    </SearchGrid>
  );
};

SearchContent が非常にシンプルになりました。呼び出し側のコンポーネントでは配置と余白だけを管理しており、レイアウトを変えたくなったら grid-template で好きなように変更ができます。

各セクションの余白管理には gap を利用しており、子要素たちがどれくらいの余白を持つのか一目でわかります。

終わりに

今回は比較的すぐに実践できるような UI コンポーネントにフォーカスしてみました。今回のやり方がマッチしないこともあるかもしれませんが、もし設計の際にご参考になれば幸いです。

今では AI を用いればこのあたりの修正は容易に行えますので、ぜひ試していただければと思います。

また、アソビュー株式会社では新しいメンバーを随時募集していますので、ご興味ある方はお気軽にカジュアル面談ご応募ください。

www.asoview.com

speakerdeck.com