POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

Joseph Mukorivo

本記事は、原著者の許諾のもとに翻訳・掲載しております。

はじめに

目まぐるしく進化するフロントエンド開発の世界では、常に最新の知識や技術をいち早く取り入れることが、エンタープライズアプリケーションの開発を成功させる上で欠かせません。Tailwind CSS、TypeScript、Turborepo、ESLint、React Queryなどを含む強力なツールキットとNext.jsを4年間使用してきた結果、開発に役立つさまざまな知見やベストプラクティスが得られました。この記事では、大企業向けフロントエンドアプリケーションのパフォーマンス、保守性、拡張性を最大限に高める設計・構築手法を紹介したいと思います。

注記:ここに記載する内容はあくまでも個人的な見解であり、筆者が推奨する手法が必ずしも適さない場合もあります。

効果的なエンタープライズ向けフロントエンドアーキテクチャの基本原則

エンタープライズ規模のアプリケーション向けにフロントエンドソリューションを設計する場合、基本原則を明確に定めておくことで開発作業の方向性を示すことができます。このセクションでは、エンタープライズ環境において筆者が実際にNext.jsを使用してきた経験を踏まえた基本原則を紹介します。

モジュール性とコンポーネント化

原則:分割統治

大規模なエンタープライズアプリケーションでは、コードはすぐに肥大化し、手に負えなくなってしまいます。モジュール性を備えたフロントエンド設計により、コードを扱いやすい大きさのコンポーネントに分割しましょう。コンポーネントはブロック玩具のように、それぞれ特定の役割を担います。そのため、コンポーネント化することでコードを再利用しやすくなるだけでなく、保守や開発チーム内での連携が容易になります。また、アプリケーションを小さなコンポーネントに分割するだけでなく、独立した小さなアプリケーションに分割できないか検討しましょう。それには、Turborepoなどのツールが役立ちます。

関心の分離(SOC)

原則:コードベースを整理する

コードの健全性を保つため、関心の分離(SoC)の原則に従いましょう。UIのレンダリング、ビジネスロジックの処理、状態(ステート)の管理といった各コンポーネントの役割を、それぞれ明確に分離します。そうすることで、コードが分かりやすくなるだけでなく、テストやデバッグを行いやすくなります。

スケーラブルな設計

原則:拡張に備える

エンタープライズアプリケーションは静的ではなく、進化します。スケーラビリティを念頭に置きながらフロントエンドアーキテクチャを設計しましょう。これはつまり、トラフィックやデータ量の増大、機能の複雑化に対応できるパターンやツールを選ぶということです。Next.jsはスケーラビリティに対応可能な設計になっているため、その点においても有益です。

保守性とコード品質

原則:丁寧にコーディングする

コードは製品の土台です。最初から保守性とコード品質を重視し、コーディング規約を定め、コードレビューを実施し、テスト自動化に投資しましょう。管理の行き届いたコードベースは作業しやすいだけでなく、バグやリグレッションが発生しにくくなります。筆者は最近、フロントエンドアプリケーションのコーディング規約を順守させるため、コンポーネントライブラリと簡単なスタイルガイドを作成しました。ドキュメントはまだ完成していないので、あしからず😂.

アクセシビリティの標準化

原則:最初からインクルーシブデザイン

モダンWeb開発において、アクセシビリティは絶対条件です。最初からアクセシビリティの高いWeb開発を実践しましょう。障がいの有無にかかわらず、誰もがアプリケーションを利用できるようにします。アクセシビリティの基準や、インクルーシブなユーザーエクスペリエンスを生み出すためのツールについては、Next.jsのサポートが役立ちます。筆者は、タブやドロップダウンなどアクセシビリティへの配慮が必要なコンポーネントには、Radix UIなどのツールを使用します。

パフォーマンス重視の開発

原則:スピードは重要

エンタープライズユーザーはスピードを求めています。あらゆる場面でパフォーマンスを重視しましょう。アセットを最適化し、不要なリクエストを最小限に減らし、自動コード分割、Suspenseを使ったストリーミング、画像最適化といったNext.jsのパフォーマンス機能を活用します。素早く動くアプリケーションはユーザーの満足度が高いだけでなく、SEOにも好影響を与えます。

セキュリティ第一

原則:城を守る

フロントエンドアーキテクチャの構造の中にセキュリティを組み込み、クロスサイトスクリプティング(XSS)やクロスサイトリクエストフォージェリ(CSRF)などの一般的な脆弱性から守りましょう。セキュリティアップデートやベストプラクティスの実践を怠らず、Next.jsに組み込まれたセキュリティ機能は予備的な防御層とみなします。

国際化(i18n)と地域化(l10n)

原則:グローバルな視点で考える

つながりあったこの世界では、グローバルな視点で考えることが不可欠です。多様なユーザーに対応するため、最初から国際化(i18n)と地域化(l10n)を実施しましょう。Next.jsはこれらの機能のサポートが充実しているため、多言語アプリケーションの作成を容易にします。

これらの基本原則は、Next.jsを使用して効果的なエンタープライズ向けフロントエンドアーキテクチャを構築する上での土台を形成します。大規模アプリケーションの要求に沿って開発作業を進め、強固で保守性に優れ、使いやすいアプリケーションを開発できるよう、羅針盤の役割を果たします。以下のセクションでは、これらの原則を実践的な戦略やベストプラクティスに落とし込む方法を詳しく見ていきます。

フォルダとファイル構造

Reactでは、綿密に考えられたフォルダ構造によってプロジェクトを整理することが、保守性とスケーラビリティを実現する上で重要です。アプローチとしては、機能や目的に応じてファイルを整理する方法が一般的です。筆者が自分のアプリケーションでよく使用するフォルダ構造をサンプルとして紹介します。

├─ src/
│ ├─ components/
│ │ ├─ ui/
│ │ │ ├─ Button/
│ │ │ ├─ Input/
│ │ │ ├─ ...
│ │ │ └─ index.tsx
│ │ ├─ shared/
│ │ │ ├─ Navbar/
│ │ └─ charts/
│ │ │ ├─ Bar/
│ ├─ modules/
│ │ ├─ HomePage/
│ │ ├─ ProductAddPage/
│ │ ├─ ProductPage/
│ │ ├─ ProductsPage/
│ │ │ ├─ api/
│ │ │ │ └─ useGetProducts/
│ │ │ ├─ components/
│ │ │ │ ├─ ProductItem/
│ │ │ │ ├─ ProductsStatistics/
│ │ │ │ └─ ...
│ │ │ ├─ utils/
│ │ │ │ └─ filterProductsByType/
│ │ │ └─ index.tsx
│ │ ├─ hooks/
│ │ ├─ consts/
│ │ └─ types/
│ │ └─ lib/
| | └─ styles/
│ │ │ ├─ global.css
│ │ └─ ...
│ ├─ public/
│ │ ├─ ...
│ │ └─ index.tsx
│ ├─ eslintrc.js
│ ├─ package.json
│ └─ tsconfig.json
└─ ...
  • src/components: このディレクトリにはUIコンポーネントを格納します。さらに、一般的なUIコンポーネントを格納するuiと、アプリケーションの他の部分で再利用する可能性のあるコンポーネントを格納するsharedに分かれています。
  • src/modules: このディレクトリには、アプリケーションのさまざまなモジュールやページを格納します。モジュールごとにフォルダが分かれ、APIコール、コンポーネント、ユーティリティ機能のためのサブディレクトリが含まれる場合があります。
  • src/pages: Next.jsを使用している場合、このフォルダはアプリケーションのエントリーポイントとしてのみ使用します。ここにはビジネスロジックは格納しません。pagesフォルダに格納されたコンポーネントは、必ずmodulesフォルダからページをレンダリングします。
  • src/modules/ProductsPage: このモジュールは製品に関連し、APIコール、コンポーネント(ProductItemProductsStatisticsなど)、ユーティリティ機能(filterProductsByType)のサブディレクトリが含まれます。
  • src/lib: このフォルダには、後でパッケージに変換し、複数のアプリケーションで使用することができるユーティリティ機能を格納します。後でパッケージに変換することのないユーティリティ機能を格納するsrc/utilsとは異なります。
  • src/styles: このディレクトリには、グローバルスタイル(global.css)や、スタイルに関連するその他のファイルを格納します。
  • src/public: このフォルダには、ビルドプロセスで使用しない画像、フォント、index.htmlファイルなどの静的アセットを格納します。
  • src/consts, src/types: これらのディレクトリには、それぞれ定数とTypeScriptの型定義を格納します。
  • src/hooks: このディレクトリには、アプリケーション全体で使用するカスタムフックを格納します。
  • eslintrc.js: これは、よく知られたJavaScript用構文チェックツールであるESLintの設定ファイルです。コーディング規約を適用し、コードの間違いを見つけるために使用します。

tsconfigファイルは、例えばButtonコンポーネントをインポートしたい場合、import { Button } from '@/components/ui'のように指定してインポートできるよう設定されています。これをtsconfig.jsonから設定するためのコードスニペットを以下に示します。

{
  ...
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

TypeScriptのコーディング規約

筆者が使用する規約はこちらのガイドに基づいています。ぜひ一読してみてください。以下のコードスニペットはこのガイドからの抜粋です。

型は全て型エイリアスで定義する

// ❌ Avoid interface definitions unless you need to extend or implement them

interface UserRole = 'admin' | 'guest'; // invalid - interface can't define (commonly used) type unions

interface UserInfo {
  name: string;
  role: 'admin' | 'guest';
}

// ✅ Use type definition
type UserRole = 'admin' | 'guest';

type UserInfo = {
  name: string;
  role: UserRole;
};

複数の引数の使用を避ける

// ❌ Avoid having multiple arguments
transformUserInput('client', false, 60, 120, null, true, 2000);

// ✅ Use options object as argument
transformUserInput({
  method: 'client',
  isValidated: false,
  minLines: 60,
  maxLines: 120,
  defaultInput: null,
  shouldLog: true,
  timeout: 2000,
});

命名規則

最適な名前を決めるのは難しいものですが、既定の規則に従い、今後作業する開発者のためにコードの可読性を高め、一貫性を保つよう努めましょう。

変数・定数

  • ローカル変数 productsをキャメルケースで記述する(productsFiltered
  • ブール型変数 ishasなどの接頭辞を先頭に付ける(isDisabledhasProduct
  • 定数 大文字で記述(PRODUCT_ID
  • オブジェクト型の定数

単数形を使用し、大文字で記述します。constアサーション(as const)を使用し、任意でsatisfies演算子を使用して型(その型が存在する場合)にマッチするかチェックします。

  const ORDER_STATUS = {
    pending: 'pending',
    fulfilled: 'fulfilled',
    error: 'error',
  } as const satisfies OrderStatus;

関数

キャメルケース

filterProductsByType, formatCurrency

ジェネリクス

TRequestTFooBarのように先頭に大文字のTが付きます(.Netの内部実装と同様)。

よく使われる規約ではありますが、ジェネリクスをTKなど1文字で命名するのは避けましょう。変数が増えれば増えるほど、間違いやすくなります。

// ❌ Avoid naming generics with one character
const createPair = <T, K extends string>(first: T, second: K): [T, K] => {
  return [first, second];
};
const pair = createPair(1, 'a');

// ✅ Name starts with the capital letter T
const createPair = <TFirst, TSecond extends string>(
  first: TFirst,
  second: TSecond
): [TFirst, TSecond] => {
  return [first, second];
};
const pair = createPair(1, 'a');

パッケージとツール

アプリケーションの開発では、作業の不要な重複を避けるため、一般的にサードパーティツールを活用します。筆者がスケーラブルなアプリケーションをビルドする際に使用するパッケージをいくつか紹介します。

React Query/TanStack Query

React Queryは、複雑なエンタープライズアプリケーションにおけるデータフェッチや同期化を管理するのに非常に便利です。APIからのデータフェッチやキャッシング、ミューテーション関数の処理へのアプローチを統一できます。エンタープライズ環境では、アプリケーションは複数のAPIやサービスと連携しなくてはならない場合が多くあります。React Queryは、データ管理を一元化し、ボイラープレートコードを減らすことで、このプロセスを効率化できます。

React Context

React Contextは、propsのバケツリレーを行うことなく、複数のコンポーネントにまたがるグローバルステートを管理するのに役立ちます。これは、アプリケーション全体でユーザー認証や環境設定などの共有ステートにアクセスする必要があるエンタープライズアプリケーションでは特に有益です。

通常、筆者はReact Contextなどの状態管理ツールの使用は最後の手段として取っておきます。グローバルステートに頼るのは必要最低限にとどめておくのがよいでしょう。その代わり、ステートはなるべく必要な場所の近くに置いておきましょう。

Cypress

Cypressは、E2Eのテストに非常に役立つツールです。エンタープライズアプリケーションでは、重要なワークフローや機能が異なる画面やコンポーネント上で正常に機能するようにすることが何よりも重要です。Cypressはダントツで筆者が一番気に入っているツールです。テストに合格すれば、新たに導入したコードがアプリケーションに不具合をもたらすことはないと確信できます。エンタープライズアプリケーションが進化していく中で、コードに新たな変更を加えた際にリグレッションテストを実施し、意図せぬ副作用が生じていないか確認することが重要です。Cypressは、テストプロセスを自動化することで、この確認作業を容易にします。

React Testing Library:

React Testing Libraryは、Reactコンポーネントの単体テストと統合テストに欠かせません。エンタープライズアプリケーションでは、個々のコンポーネントが期待どおり動作するか検証することが、強固なアプリケーションを作る上で重要です。React Testing Libraryは、各コンポーネントを単体で、さらに他のコンポーネントと組み合わせて、入念にテストすることを可能にします。

NextAuth.js:

NextAuth.jsは、Next.jsアプリケーションにおける認証と認可の実装を容易にします。エンタープライズ環境では、ユーザー管理のセキュリティは絶対条件です。企業の間では、複数のアプリケーションをまたいだユーザー認証を効率化するためにシングルサインオン(SSO)ソリューションがしばしば採用されます。NextAuth.jsはさまざまなSSOプロバイダーに対応しているため、エンタープライズ認証のニーズに最適なツールです。また、NextAuth.jsではカスタム認証フローを柔軟に実装することもできます。

NextAuth.jsでTypeScriptによるモジュール拡張を使用してデフォルトのUserモデルをカスタマイズする方法を説明した筆者のブログ記事もご覧ください。

Turborepo

これも筆者が気に入っているツールの1つです。Turborepoはモノレポの管理に役立ちます。大規模なエンタープライズアプリケーションでは、さまざまなモジュールやサービス、共有コードによりコードベースが膨大になる場合があります。Turborepoは、肥大化したコードベースを効率的に整理し、バージョン管理やデプロイメントを行いやすくします。エンタープライズ環境では、複数のチームやプロジェクトでコードを共有するのは一般的です。Turborepoは、効果的なコード共有を可能にし、共有ライブラリやコンポーネントを使用したチームコラボレーションをサポートします。

Storybook

Storybookを使用することで、開発者はUIコンポーネントを切り離し、制御された環境で動かすことができます。そのため、アプリケーション全体を操作することなく、個々のコンポーネントの外観や動作のデモを簡単に実施できます。大規模なエンタープライズアプリケーションでは、複数の開発者やチームがそれぞれUIの異なる部分を担当している場合があります。Storybookは、UIコンポーネントを披露して議論を交わすための一元的なプラットフォームを提供することで、コラボレーションの効率化を促し、デザイン言語の一貫性を確保します。こちらは筆者がStorybookを使用して開発し、文書化したコンポーネントライブラリのサンプルです(まだ未完成ですが)。

エンタープライズ環境では、これらのツールを組み合わせることで、大規模アプリケーションを構築、テスト、維持管理し、データ管理、状態管理、テスト、認証、コード整理などの重要な課題に対処できます。

再利用可能なコンポーネントのコーディングスタイル

筆者が入力やダイアログなどの再利用可能なコンポーネントを開発する際には、なるべくベストプラクティスに沿って作業するようにしています。

では、Buttonコンポーネントの開発を例にいくつかのベストプラクティスを紹介したいと思います。ベストプラクティスがビジュアルデザインだけにとどまらないことがお分かりいただけるかと思います。

コンポーネントの再利用性

ボタンコンポーネントはアプリケーションのさまざまな場所で再利用できるよう設計しましょう。さまざまなユースケースに対応可能な柔軟性が必要です。

カスタマイズのためのPROPS

サイズ、色、バリアント(1次、2次など)、無効化状態などの一般的なカスタマイズを行うためのpropsを用意しましょう。そうすることで、開発者はさまざまなUIコンテキストに合わせて容易にボタンを調整できます。

アクセシビリティへの配慮

aria-label属性やaria-disabled属性、フォーカス管理などの適切なアクセシビリティ機能を実装しましょう。そうすることで、アシスティブテクノロジー(支援技術)の利用者も効果的にボタンを使用できます。

セマンティックHTML

ボタンコンポーネントにセマンティックHTMLの要素を使用しましょう。そうすることで、アクセシビリティとSEOが向上し、異なるデバイス上でも適切な動作を確保できます。

ボタンのネイティブ要素の模倣

これらのベストプラクティスは全て、予測可能なコードを記述することにつながります。カスタムボタンコンポーネントを開発する場合、ボタンとして機能し、動作するようにしましょう。後述するコンポーネントの例では、ボタンのネイティブ要素を拡張することで、ボタンに追加できるあらゆるpropsを含めています。

エラーハンドリング

ボタンがエラー状態(フォームの送信など)につながる可能性がある場合、エラーを処理し、ユーザーに伝える方法を用意しましょう。

テスト

さまざまなシナリオでボタンコンポーネントが期待どおり動作することを検証するための単体テストを作成しましょう。テストケースはさまざまなpropsやイベントハンドラを網羅する必要があります。

ドキュメント化

利用可能なprops、イベントハンドラ、特定のユースケースなども含め、ボタンコンポーネントの使用方法をドキュメント化しましょう。開発者の参考になるような例やコードスニペットも記載します。これにはStorybookが役立ちます。

クロスブラウザ互換性:

異なるブラウザでボタンコンポーネントをテストし、動作や外観が一貫しているか確認しましょう。

バージョン管理と変更履歴

ボタンコンポーネントが共有ライブラリの一部である場合、バージョン管理を実施し、変更履歴を残すことで開発者に更新や変更について知らせましょう。

コーディング

筆者のコンポーネントには、大抵Button.tsxButton.stories.tsxDocs.mdxButton.test.tsといったファイルが含まれます。CSSを使用している場合、Button.module.cssのようなファイルがあるかもしれません。

components/ui/Button.tsx

これがメインのコンポーネントであり、cn関数でクラスをマージし、コンフリクトに対処します。tw-mergeライブラリのラッパーです。

import React from 'react';
import {
  forwardRef,
  type ButtonHTMLAttributes,
  type JSXElementConstructor,
  type ReactElement,
} from 'react';
import { AiOutlineLoading3Quarters } from 'react-icons/ai';
import type { VariantProps } from 'cva';
import { cva } from 'cva';
import Link from 'next/link';
import { cn } from '@/lib';

const button = cva(
  'flex w-max items-center border-[1.5px] gap-2 transition duration-200 ease-linear focus:outline-0 focus:ring ring-offset-1 dark:ring-offset-blue-dark',
  {
    variants: {
      variant: {
        outline: '...',
        solid: '...',
        naked: '...',
      },
      rounded: {
        none: 'rounded-none',
        sm: 'rounded',
        md: 'rounded-lg',
        lg: 'rounded-xl',
        full: 'rounded-full',
      },
      color: {
        primary: '...',
        danger: '...',
        info: '...',
        warning: '...',
        light: '...',
        secondary: '...',
      },
      size: {
        xs: '...',
        sm: '...',
        md: '...',
        lg: '...',
      },
      disabled: {
        true: '...',
      },
      active: {
        true: '...',
      },
      loading: {
        true: '...',
      },
      fullWidth: {
        true: '...',
      },
      align: {
        center: '...',
        left: '...',
        right: '...',
        between: '...',
      },
    },
    compoundVariants: [
      {
        variant: 'solid',
        color: ['secondary', 'warning', 'danger', 'info'],
        className: '...',
      },
      {
        variant: 'solid',
        color: 'primary',
        className: '...',
      },
      {
        variant: 'outline',
        color: ['primary', 'secondary', 'warning', 'danger', 'info'],
        className: '...',
      },
      {
        variant: 'outline',
        color: 'light',
        className:
          '...',
      },
      {
        variant: 'naked',
        color: ['primary', 'secondary', 'warning', 'danger', 'info'],
        className:
          '...',
      },
      {
        disabled: true,
        variant: ['solid', 'outline', 'naked'],
        color: ['primary', 'secondary', 'warning', 'danger', 'info', 'light'],
        className: '...',
      },
      {
        variant: 'outline',
        color: ['primary', 'secondary', 'warning', 'danger', 'info', 'light'],
        className: '...',
      },
      {
        variant: 'naked',
        color: 'primary',
        className: '...',
      },
    ],
    defaultVariants: {
      size: 'md',
      variant: 'solid',
      color: 'primary',
      rounded: 'lg',
      align: 'center',
    },
  }
);

interface BaseProps
  extends Omit<
      ButtonHTMLAttributes<HTMLButtonElement>,
      'color' | 'disabled' | 'active'
    >,
    VariantProps<typeof button> {
  href?: string;
  loadingText?: string;
  target?: '_blank' | '_self' | '_parent' | '_top';
  as?: 'button' | 'a' | JSXElementConstructor<any>;
}

export type ButtonProps = BaseProps &
  (
    | {
        rightIcon?: ReactElement;
        leftIcon?: never;
      }
    | {
        rightIcon?: never;
        leftIcon?: ReactElement;
      }
  );

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  (props, ref) => {
    const {
      as: Tag = 'button',
      variant,
      color,
      rounded,
      size,
      target = '_self',
      loading,
      fullWidth,
      align,
      loadingText,
      href,
      active,
      rightIcon,
      leftIcon,
      className,
      disabled,
      children,
      ...rest
    } = props;

    const classes = cn(
      button({
        variant,
        color,
        size,
        disabled,
        loading,
        active,
        rounded,
        fullWidth,
        align,
      }),
      className
    );

    return (
      <>
        {href ? (
          <Link className={classes} href={href} target={target}>
            {leftIcon}
            {children}
            {rightIcon}
          </Link>
        ) : (
          <Tag className={classes} disabled={disabled} ref={ref} {...rest}>
            {loading ? (
              <>
                <AiOutlineLoading3Quarters className='animate-spin' />
                {loadingText || 'Loading...'}
              </>
            ) : (
              <>
                {leftIcon}
                {children}
                {rightIcon}
              </>
            )}
          </Tag>
        )}
      </>
    );
  }
);

Button.displayName = 'Button';

components/ui/Button.stories.tsx

このファイルには、Storybook向けのボタンのストーリーが含まれます。

import { Meta, StoryObj } from '@storybook/react';
import React from 'react';
import { FaRegSmileWink, FaThumbsUp, FaYinYang } from 'react-icons/fa';
import { FiArrowUpRight } from 'react-icons/fi';
import { Button } from './Button';

export default {
  title: 'Components/Button',
  component: Button,
  parameters: {},
  args: {
    children: 'Click me!',
  },
  argTypes: {
    children: {
      description: 'This is the text of the button, can be a node.',
      control: { type: 'text' },
    },
    color: {
      options: ['primary', 'danger', 'info', 'warning', 'secondary', 'light'],
      control: { type: 'select' },
      description: 'This controls the color scheme of the button',
      table: {
        defaultValue: { summary: 'primary' },
      },
    },
    variant: {
      options: ['solid', 'outline', 'naked'],
      control: { type: 'select' },
      description: 'This controls the variant of the button',
      table: {
        defaultValue: { summary: 'solid' },
      },
    },
    size: {
      options: ['sm', 'md', 'lg'],
      control: { type: 'radio' },
      description: 'This controls the size of the button',
      table: {
        defaultValue: { summary: 'md' },
      },
    },
    loading: {
      control: { type: 'boolean' },
      description: 'This controls the loading state of the button',
      table: {
        defaultValue: { summary: false },
      },
    },
    href: {
      control: { type: 'text' },
      description:
        'If this is set, the button will be rendered as an anchor tag.',
    },
    className: {
      control: { type: 'text' },
      description: 'Classes to be applied to the button',
    },
    disabled: {
      control: { type: 'boolean' },
      description: 'If true, the button will be disabled',
      table: {
        defaultValue: { summary: false },
      },
    },
    rightIcon: {
      options: ['Smile', 'ThumbsUp', 'YinYang'],
      mapping: {
        Smile: <FaRegSmileWink />,
        ThumbsUp: <FaThumbsUp />,
        YinYang: <FaYinYang />,
      },
      description:
        'If set, the icon will be rendered on the right side of the button',
    },
    leftIcon: {
      options: ['Smile', 'ThumbsUp', 'YinYang'],
      mapping: {
        Smile: <FaRegSmileWink />,
        ThumbsUp: <FaThumbsUp />,
        YinYang: <FaYinYang />,
      },
      description:
        'If set, the icon will be rendered on the left side of the button',
    },
    loadingText: {
      control: { type: 'text' },
      description:
        'If set, the text will be rendered while the button is in the loading state',
    },
    target: {
      control: { type: 'text' },
      description:
        'If set, the target will be rendered as an attribute on the anchor tag',
      table: {
        defaultValue: { summary: '_self' },
      },
    },
    as: {
      options: ['button', 'a'],
      control: { type: 'select' },
      description:
        'If set, the button will be rendered as the specified element',
      table: {
        defaultValue: { summary: 'button' },
      },
    },
  },
} as Meta<typeof Button>;

type Story = StoryObj<typeof Button>;

export const Primary: Story = {
  args: {},
};

export const Secondary: Story = {
  args: {
    color: 'secondary',
  },
};

export const Danger: Story = {
  args: {
    color: 'danger',
  },
};

export const Warning: Story = {
  args: {
    color: 'warning',
  },
};

export const Light: Story = {
  args: {
    color: 'light',
  },
};

export const Info: Story = {
  args: {
    color: 'info',
  },
};

export const Custom: Story = {
  args: {
    className: 'bg-[yellow] text-[black] border-[orange]',
    style: { borderRadius: '3.5rem' },
  },
};

export const WithRightIcon: Story = {
  args: {
    rightIcon: <FiArrowUpRight className='h-5 w-auto' />,
  },
};

export const WithLeftIcon: Story = {
  args: {
    leftIcon: <FiArrowUpRight className='h-5 w-auto' />,
  },
};

export const Disabled: Story = {
  args: {
    disabled: true,
  },
};

export const OutlineVariant: Story = {
  args: {
    variant: 'outline',
    color: 'danger',
  },
};

export const NakedVariant: Story = {
  args: {
    variant: 'naked',
    color: 'danger',
  },
};

export const Loading: Story = {
  args: {
    loading: true,
  },
};

export const CustomLoadingText: Story = {
  args: {
    loading: true,
    loadingText: 'Processing...',
  },
};

export const AsLink: Story = {
  args: {
    href: 'https://fin.africa',
    children: 'Visit fin website',
    rightIcon: <FiArrowUpRight className='h-5 w-auto' />,
  },
};

export const FullWidth: Story = {
  args: {
    fullWidth: true,
    children: 'Visit fin website',
    rightIcon: <FiArrowUpRight className='h-5 w-auto' />,
  },
};

components/ui/Docs.mdx

コンポーネントの仕組みをドキュメント化するのにストーリーファイルを使用することもできますが、Markdownファイルのほうがより詳細なドキュメント化が可能です。

Buttonコンポーネントの開発に使用した規約は、筆者が全てのコンポーネントで実践している規約と同じものです。

重要なポイント

  • オープンソースソリューションであれ、自ら開発したものであれ、何らかのデザインシステムを用意しましょう。
  • TypeScriptと仲良くなりましょう。TypeScriptをうまく活用し、コンポーネントの使い方を規定しましょう。ボタンコンポーネントが良い例です。leftIconrightIconという2つのpropsがありますが、この記事ではTypeScriptを使用することでいずれか一方のみが設定されるようにし、両方設定された場合はエラーを返すようにしました。
export type ButtonProps = BaseProps &
  (
    | {
        rightIcon?: ReactElement;
        leftIcon?: never;
      }
    | {
        rightIcon?: never;
        leftIcon?: ReactElement;
      }
  );
  • コードとコンポーネントをドキュメント化しましょう。Storybookなどのツールを使用するとよいでしょう。
  • 何らかのスタイルガイドを用意し、チームと共通のデザイン言語を使用しましょう。
  • 分かりやすいコードを書きましょう。コードベースは簡単で分かりやすいものにし、焦点を絞ります。それぞれのコードには単一の明確な目的が必要です。
  • 内部の仕組みを理解しましょう。Reactがどのようにして2つの値が同一であるかを確認するのかについて、筆者が調べた内容をこちらの記事にまとめています。

おわりに

この記事では、筆者が使用している手法やツールの一部を紹介しました。手元にあるツールを全て網羅してはいませんが、ご自身のニーズに合ったものが見つかると幸いです。全く新しいものを採用するよりも、熟知している技術を使い続けるのが賢明でしょう。

つまるところ、クライアントが最も重視するのは最終製品であって、どのような技術を使用したかではないのです。Reactであれ、Vueであれ、あるいはその他のツールであれ、素早くデプロイでき、ユーザーにメリットがあるようなツールやワークフローを優先的に使用しましょう。

Resources