Reactで文字・単語数を解析するアプリを作ってみたのでポイントを解説


こんにちは、とまだです。

React アドベントカレンダー 2024、2 日目の記事をお届けします！

https://qiita.com/advent-calendar/2024/react

今回は、React で文字数や単語数を解析するアプリを作ってみました。

![スクリーンショット 2024-11-09 11.12.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/af8becd2-f1d1-96a3-ea4d-3be98be12ebb.png)

エディタでテキストを入力すると、その**文字数や単語数をリアルタイムで解析**するアプリです。
また、**文字数制限の警告表示**や**クリップボードへのコピー機能**も備えています。

**設計やコンポーネントの分割方法**など、ポイントをご紹介するので、ぜひ参考にしてください！

ユーザーのアクションに応じて、UI がリアルタイムに変化する機能を含んでいるので、React の基本を学べるかと思います。

ちなみに今回は、拙著で紹介している**万能 React テンプレート**を使用しています。

https://zenn.dev/tmasuyama1114/books/react-setup-project

ソースコードは GitHub で公開していますので、細かい実装を確認したい方はこちらをご覧ください。
（Vitest の書き方や、GitHub ACtions による CI/CD の設定も含まれています）

https://github.com/tmasuyama1114/text-analyzer-challenge

## 0. はじめに

### 0.1 対象読者

- **React の基本を学びたい初心者**
- **React Hooks の使い方を理解したい方**
- **コンポーネントの設計方法を学びたい方**

### 0.2 技術スタック

- React
- TypeScript
- Tailwind CSS
- Vite
- ESLint
- Prettier
- GitHub Actions
- Vercel

## 1. アプリの概要と機能

このアプリは、入力されたテキストの以下の情報をリアルタイムで解析します。

- **文字数**（空白含む/含まない）
- **単語数**
- **文字数制限の警告表示**（制限の 80%を超えた場合）
- **クリップボードへのコピー**

Vercel を使ってデプロイしているので、以下のリンクから実際に動作を確認できます。

https://text-analyzer-challenge.vercel.app/

## 2. 各コンポーネントと実装のポイント

### 2.1 メインコンポーネント：`TextAnalyzer`

アプリ全体のレイアウトとデータ管理を担当します。

ポイントは以下の通りです。

- **カスタムフック`useTextAnalysis`を使用**して状態を管理。
- **子コンポーネントを組み合わせて UI を構築**。

こちらを `App.tsx` から読み込むイメージです。

まずは全体像を確認しましょう。

```tsx:src/components/TextAnalyzer.tsx
import React from 'react'

import { useTextAnalysis } from '../hooks/useTextAnalysis' // カスタムフック
import { TextAnalyzerProps } from '../types' // 型定義
import { DEFAULT_MAX_CHARS, TEXT_LABELS } from '../constants' // 定数
import { TextArea } from './TextArea'
import { Warning } from './Warning'
import { StatsGrid } from './StatsGrid'
import { CopyButton } from './CopyButton'

const TextAnalyzer: React.FC<TextAnalyzerProps> = ({
  // プロパティを受け取る
  maxChars = DEFAULT_MAX_CHARS,
}) => {

  // カスタムフックを使用
  const { text, setText, stats, isWarning, handleCopy, remainingChars } =
    useTextAnalysis(maxChars)

  return (
    <div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
      <div className="max-w-4xl mx-auto">
        <div className="bg-white rounded-lg shadow-lg overflow-hidden">
          <div className="px-6 py-4 border-b border-gray-200">
            <h1 className="text-2xl font-bold text-gray-900">
              {TEXT_LABELS.TITLE}
            </h1>
          </div>

          <div className="p-6 space-y-4">
            {/* テキスト入力フィールド */}
            <TextArea value={text} onChange={setText} maxLength={maxChars} />

            {/* 警告メッセージ */}
            {isWarning && (
              <Warning maxChars={maxChars} remainingChars={remainingChars} />
            )}

            {/* 統計情報 */}
            <StatsGrid stats={stats} />

            {/* クリップボードへのコピー */}
            <div className="flex justify-end">
              <CopyButton onClick={handleCopy} />
            </div>
          </div>
        </div>
      </div>
    </div>
  )
}

export default TextAnalyzer
```

全体として、カスタムフックや型、定数をインポートし、それらを組み合わせて UI を構築しています。

### 2.2 状態管理のカスタムフック：`useTextAnalysis`

こちらは、テキストの状態管理や解析を行うカスタムフックです。

```tsx:src/hooks/useTextAnalysis.ts
import { useState, useEffect, useCallback } from 'react' // React フックをインポート
import { TextStats } from '../types'
import { analyzeText, shouldShowWarning } from '../utils/textAnalyzer' // テキスト解析関数
import { DEFAULT_MAX_CHARS, WARNING_THRESHOLD } from '../constants'

// カスタムフックの定義
export const useTextAnalysis = (maxChars: number = DEFAULT_MAX_CHARS) => {
  const [text, setText] = useState('') // テキストの状態

  // テキスト解析結果の状態
  const [stats, setStats] = useState<TextStats>({
    chars: 0, // 文字数（空白含む）
    charsNoSpace: 0, // 文字数（空白含まない）
    words: 0, // 単語数
    lines: 0, // 行数
  })
  const [isWarning, setIsWarning] = useState(false) // 警告表示の状態

  // テキストが変更されたら解析
  useEffect(() => {
    const newStats = analyzeText(text) // テキスト解析
    setStats(newStats) // 結果をセット
    setIsWarning(shouldShowWarning(newStats.chars, maxChars, WARNING_THRESHOLD)) // 警告表示の判定
  }, [text, maxChars])

  // クリップボードへのコピー
  const handleCopy = useCallback(async () => {
    try {
      await navigator.clipboard.writeText(text) // テキストをクリップボードにコピー
      return true
    } catch (err) {
      console.error('Failed to copy text: ', err) // エラー処理
      return false
    }
  }, [text])

  return {
    text,
    setText,
    stats,
    isWarning,
    handleCopy,
    remainingChars: maxChars - stats.chars,
  }
}
```

実装のポイントは以下の通りです。

1. **`useState`** でテキストの状態を管理。
2. **`useMemo`** を使い、テキスト解析結果の不要な再計算を防止。
3. **`handleCopy`** でクリップボードへのコピー機能を提供。

それぞれ、既存の React フックを活用して、状態管理や副作用を管理しています。

なお、カスタムフックを一つにまとめていますが、たとえば `useTextStats` と `useTextCopy` など、機能ごとに分割することも可能です。

### 2.3 テキスト入力フィールド：`TextArea`

テキストを入力し、その変更を親コンポーネントに伝える役割を持つコンポーネントです。

```tsx:src/components/TextArea.tsx
import React from "react";
import { TEXT_LABELS } from "../constants";

interface TextAreaProps {
  value: string; // テキストの値
  onChange: (text: string) => void; // テキスト変更時のコールバック
  maxLength: number; // 最大文字数
}

export const TextArea: React.FC<TextAreaProps> = ({ value, onChange, maxLength }) => (
  // テキストエリアの実装
  <textarea
    value={value}
    onChange={(e) => onChange(e.target.value)}
    className="w-full h-64 p-4 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm"
    placeholder={TEXT_LABELS.PLACEHOLDER}
    maxLength={maxLength}
  />
);
```

テキストエリアの実装には、**`onChange`イベント**を利用して、ユーザーの入力を親コンポーネントに通知しています。

- **`onChange`イベント**を利用し、ユーザーの入力を親に通知。
- 最大文字数を`maxLength`で制限し、ユーザーの入力ミスを防ぎます。

React ではコンポーネントをよく分割するため、データの受け渡し方は抑えておきたいポイントです。

### 2.4 警告メッセージ：`Warning`

今回のアプリでは、入力可能な文字数に制限を設けています。

入力内容に応じて警告を表示する、という UI はよくあるので実装例を紹介します。

文字数制限の 80%を超えた場合に警告を表示します。

![スクリーンショット 2024-11-09 11.30.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/5216aaf9-169c-48a4-c0b4-acaca1263d5e.png)

```tsx:src/components/Warning.tsx
import React from 'react'
import { TEXT_LABELS } from '../constants'

// 警告メッセージのプロパティ
interface WarningProps {
  maxChars: number // 最大文字数
  remainingChars: number // 残り文字数
}

// 警告メッセージのコンポーネント
export const Warning: React.FC<WarningProps> = ({
  maxChars,
  remainingChars,
}) => (
  <div className="bg-yellow-50 border border-yellow-400 rounded-lg p-4 text-yellow-800">
    {TEXT_LABELS.WARNING_MESSAGE.replace('{max}', maxChars.toString()).replace(
      '{remaining}',
      remainingChars.toString()
    )}
  </div>
)
```

受け取ったプロパティに応じて UI を変化させるコンポーネントです。

条件付きレンダリングにより、特定条件でのみ表示される UI を実装する際に活用できます。

### 2.5 統計情報の表示：`StatsGrid`と`StatItem`

テキスト解析結果を見やすく表示するためのコンポーネントを紹介します。

**`StatsGrid`：**

```tsx:src/components/StatsGrid.tsx
import React from 'react'
import { StatItem } from './StatItem'
import { TextStats } from '../types'
import { TEXT_LABELS } from '../constants'

interface StatsGridProps {
  stats: TextStats // テキスト解析結果
}

// 統計情報のコンポーネント
export const StatsGrid: React.FC<StatsGridProps> = ({ stats }) => (
  <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
    <StatItem label={TEXT_LABELS.CHARS_WITH_SPACES} value={stats.chars} />
    <StatItem
      label={TEXT_LABELS.CHARS_WITHOUT_SPACES}
      value={stats.charsNoSpace}
    />
    <StatItem label={TEXT_LABELS.WORD_COUNT} value={stats.words} />
  </div>
)
```

**`StatItem`：**

```tsx
import React from 'react';

// 統計情報のアイテム
interface StatItemProps {
  label: string; // ラベル
  value: number; // 値
}

export const StatItem: React.FC<StatItemProps> = ({ label, value }) => (
  <div className="bg-white p-4 rounded-lg shadow-sm border">
    <div className="text-sm text-gray-500">{label}</div>
    <div className="text-2xl font-bold text-gray-900">{value}</div>
  </div>
);
```

今回は、**`StatsGrid`** と **`StatItem`** という 2 つのコンポーネントを組み合わせて、統計情報を表示しています。

- **`StatsGrid`** は、`StatItem` を 3 つ並べたグリッドレイアウト。
- **`StatItem`** は、ラベルと値を表示する UI コンポーネント。

このように、コンポーネントを細かく分割することで、再利用性を高めているのがポイントです。

### 2.6 クリップボードへのコピー：`CopyButton`

最後に、テキストをクリップボードにコピーする機能を実装します。

```tsx:src/components/CopyButton.tsx
import React from 'react'
import { TEXT_LABELS } from '../constants'

interface CopyButtonProps {
  onClick: () => void // クリック時のコールバック
}

export const CopyButton: React.FC<CopyButtonProps> = ({ onClick }) => (
  <button
    onClick={onClick}
    className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
  >
    {TEXT_LABELS.COPY_BUTTON}
  </button>
)
```

**`CopyButton`** は、クリック時に親コンポーネントに通知するだけのシンプルなコンポーネントです。

単純にコピーするだけなら親コンポーネントに通知する必要はありませんが、コンポーネントの再利用性を考えて、クリック時の挙動を親コンポーネントに委ねるようにしています。

## 3. まとめ

今回の記事では、React 初心者でも理解しやすいように、アプリの主要な部分を具体的なコードとともに解説しました。

特に、**React Hooks を活用した状態管理**や**コンポーネントの役割分担**が学べたと思います。

必要最低限の機能にしてありますが、以下のような機能を追加しても面白いかもしれません。

- **ローカルストレージでのデータ保存**: リロードしても入力内容が消えないように
- **コピー時に通知メッセージ**: コピーが完了したことをユーザーに通知
- **ダークモード**: Tailwind CSS のクラスを切り替えて実装

## 4. ちょっと宣伝

Qiita だけじゃなく、個人ブログでも React に関する記事を書いています。
お時間があれば読んでいただけると、大変励みになります 🙇

https://musclecoding.com/react-study/

## 5. 他にもアドベントカレンダー記事を書いています！

他にも、2024 年のアドベントカレンダーに参加しています。

以下の記事でまとめているので、よければ他の記事も読んでいただけると嬉しいです！

https://qiita.com/tomada/items/025b3745df8c484f5856