🐷

【React/Next.js】コンポーネント化してコードの見通しをよくする

2024/03/03に公開

概要

以下のような、コンポーネント化されていないベタ書きの処理をコンポーネントを用いて書き直します。
コンポーネントについては、意味のある最小の単位に分けることを意識しています。

page.tsx
"use client";
import { useState } from "react";
import axios from "axios";

const RandomBookSelector = () => {
  const [isLoading, setIsLoading] = useState(false);
  const [bookInfo, setBookInfo] = useState<any>(null);

  // 無作為にISBNを生成する処理
  const generateRandomISBN = () => {
    let isbn = "";
    const digits = "0123456789";
    // 最初の3桁は978or979
    const first3Digits = Math.random() < 0.5 ? "978" : "979";
    // 978or979の次は4で固定(日本語の書籍に絞る)
    isbn += first3Digits + "4";

    // 出版社を表す4桁のコードをランダムで生成
    for (let i = 0; i < 4; i++) {
      isbn += digits.charAt(Math.floor(Math.random() * digits.length));
    }

    // 本そのものを表す4桁のコードをランダムで生成
    for (let i = 0; i < 4; i++) {
      isbn += digits.charAt(Math.floor(Math.random() * digits.length));
    }

    /* 最後の1桁はチェックデジット。
    ※チェックデジットとは、バーコードの読み間違いがないかを検査するための数値。
    以下のような手順で作る。
    ・「左から奇数桁の数字の合計」に「偶数桁の数字の合計」を3倍した値を加える
    ・↑の下1桁を特定し、10から引く
    */
    let sum = 0;
    for (let i = 0; i < isbn.length - 1; i++) {
      const digit = parseInt(isbn.charAt(i));
      if (i % 2 === 0) {
        sum += digit;
      } else {
        sum += digit * 3;
      }
    }

    const checkDigit = (10 - (sum % 10)) % 10;
    isbn += checkDigit;

    return isbn;
  };

  /* openBD APIにランダムで生成したISBNを渡して検索することを、
  書籍がヒットするまでやり続ける。
  ※ランダムで生成したISBNに紐づく書籍が必ず実在する保証がないため。
  */
  const fetchBookInfo = async () => {
    setIsLoading(true);
    let isbn;
    do {
      isbn = generateRandomISBN();
      const response = await axios.get(
        `https://api.openbd.jp/v1/get?isbn=${isbn}`
      );
      if (response.data && response.data[0] && response.data[0].summary) {
        setBookInfo(response.data[0].summary);
        setIsLoading(false);
        break;
      }
    } while (true);
  };

  // 版元ドットコムに遷移するためのリンクを生成
  const handleBuyButton = () => {
    const hanmotoLink = `https://www.hanmoto.com/bd/isbn/${bookInfo.isbn}`;
    window.open(hanmotoLink);
  };

  return (
    <div className="flex flex-col items-center justify-center h-screen">
      <h1 className="text-3xl font-bold mb-4">Random Book Selector</h1>
      {isLoading && (
        <p style={{ fontWeight: "bold", fontSize: "20px" }}>
          <span role="img" aria-label="glass" className="mr-2">
            🔎
          </span>
          本を探しています...
        </p>
      )}
      {!isLoading && bookInfo && (
        <div className="text-center">
          <h2 className="text-2xl mb-3">以下の本が選ばれました!</h2>
          <p style={{ fontWeight: "bold", fontSize: "20px" }}>
            {bookInfo.title}
          </p>
          <p style={{ color: "grey", fontSize: "16px" }}>
            {bookInfo.author}, {bookInfo.publisher},{" "}
            {bookInfo.pubdate.slice(0, 4) + "/" + bookInfo.pubdate.slice(4)}
          </p>
          <div className="flex justify-center items-center">
            <button
              className="bg-blue-500 text-white px-4 py-2 m-5 rounded-md flex items-center"
              onClick={handleBuyButton}
            >
              <span role="img" aria-label="wallet" className="mr-2">
                👛
              </span>
              この本を購入
            </button>
          </div>
        </div>
      )}
      {!isLoading && (
        <button
          className="bg-blue-500 text-white px-4 py-2 rounded-md flex items-center justify-center mb-2"
          onClick={fetchBookInfo}
          disabled={isLoading}
        >
          <span role="img" aria-label="book" className="mr-2">
            📓
          </span>
          新しい本に出会う
        </button>
      )}
    </div>
  );
};

export default RandomBookSelector;

初期表示は以下のようになっており、

「新しい本に出会う」ボタンを押すと以下のようにランダムに本をお勧めしてくれるのですが、

ただコードがベタ書きされているだけだと、何が書いてあるのかぱっと見てさっぱりわからないと思います。
(Reactの良さである宣言的であることが活用できているとは到底言い難いです。)

コンポーネント化する

これから記載するように、なるべく関心事が同じである小さな単位に分け、見通しをよくしてみました。
※ディレクトリ構成は以下のような感じです。

src
│   └── app
│       ├── components
│       │   ├── RandomBookSelector
│       │   │   ├── BookDisplay
│       │   │   │   └── BookDisplay.tsx
│       │   │   ├── LoadingMessage
│       │   │   │   └── LoadingMessage.tsx
│       │   │   ├── RandomBookSelector.tsx
│       │   │   ├── SearchButton
│       │   │   │   └── SearchButton.tsx
│       │   │   └── Title
│       │   │       └── Title.tsx
│       │   ├── bookInfoFetcher
│       │   │   └── bookInfoFetcher.ts
│       │   ├── buyButtonHandler
│       │   │   └── buyButtonHandler.ts
│       │   └── isbnGenerator
│       │       └── isbnGenerator.ts

簡単に説明します。

最終的な表示内容

以下のコードのように、上の画像の画面全体を1つのコンポーネントとし、
そのコンポーネントは複数のコンポーネントを束ねた結果を表示しているという感じです。

page.tsx
"use client";
import RandomBookSelector from "./components/RandomBookSelector/RandomBookSelector";
export default function Home() {
  return <RandomBookSelector />;
}
RandomBookSelector.tsx
import { useState } from "react";
import { fetchBookInfo } from "../bookInfoFetcher/bookInfoFetcher";
import { handleBuyButton } from "../buyButtonHandler/buyButtonHandler";
import Title from "./Title/Title";
import LoadingMessage from "./LoadingMessage/LoadingMessage";
import BookDisplay from "./BookDisplay/BookDisplay";
import SearchButton from "./SearchButton/SearchButton";

function RandomBookSelector() {
  const [isLoading, setIsLoading] = useState(false);
  const [bookInfo, setBookInfo] = useState<any>(null);

  const handleFetchBookInfo = async () => {
    setIsLoading(true);
    const info = await fetchBookInfo();
    setBookInfo(info);
    setIsLoading(false);
  };

  return (
    <div className="flex flex-col items-center justify-center h-screen">
      <Title />
      {isLoading && <LoadingMessage />}
      {!isLoading && bookInfo && (
        <BookDisplay
          bookInfo={bookInfo}
          handleBuyButton={() => handleBuyButton(bookInfo)}
        />
      )}
      {!isLoading && (
        <SearchButton onClick={handleFetchBookInfo} disabled={isLoading} />
      )}
    </div>
  );
}

export default RandomBookSelector;

この時点で、コンポーネントなどの命名のされ方からして、以下のようなことは察しがつくのではないでしょうか?

  • 検索ボタンがある(SearchButton)
  • 本の情報の表示領域(BookDisplay/bookInfo)があり、そこから購入ページに飛べそう(handleBuyButton)
  • ローディングメッセージ(LoadingMessage)を表示させる局面がある

引き続き、各コンポーネントについてみていきましょう。

Webページのタイトル


上の写真の「RandomBookSelector」と表示されている部分です。
ここは特に込み入った処理は必要なく、タイトルを表示させるだけです。

Title.tsx
const Title = () => {
  return <h1 className="text-3xl font-bold mb-4">Random Book Selector</h1>;
};

export default Title;

「タイトル」という関心事のみをこのファイルに分離しているため、
タイトルを変えたいとなった場合はこのファイルを触ればいいとわかりやすいと思います。

また、Tailwind CSSを用いているため、スタイルの当て方を変える場合も、
このファイルのクラス名を変更すれば良いです。

「新しい本に出会う」ボタン

SearchButton.tsx
interface SearchButtonProps {
  onClick: () => void;
  disabled: boolean;
}

const ActionButton: React.FC<SearchButtonProps> = ({ onClick, disabled }) => {
  return (
    <button
      className="bg-blue-500 text-white px-4 py-2 rounded-md flex items-center justify-center mb-2"
      onClick={onClick}
      disabled={disabled}
    >
      <span role="img" aria-label="book" className="mr-2">
        📓
      </span>
      新しい本に出会う
    </button>
  );
};

export default ActionButton;

上記のようになりました。
onClickでは、ボタンを押した際に呼び出される、検索処理を定義した関数を渡しています。
※関心事を分離させるため、このファイルの中に検索処理を書きませんでした。

本を検索する処理

以下の2つの段階に分かれるため、それぞれ分離させています。

  • ISBN(書籍を識別するためのコード)をランダムに生成
  • ISBNに該当する本の情報を取得

ISBNをランダムに生成

ISBNを、ISBNを作成する際のルールに従ってランダムに作る関数です。

isbnGenerator.ts
export const generateRandomISBN = () => {
  let isbn = "";
  const digits = "0123456789";
  // 最初の3桁は978or979
  const first3Digits = Math.random() < 0.5 ? "978" : "979";
  // 978or979の次は4で固定(日本語の書籍に絞る)
  isbn += first3Digits + "4";

  // 出版社を表す4桁のコードをランダムで生成
  for (let i = 0; i < 4; i++) {
    isbn += digits.charAt(Math.floor(Math.random() * digits.length));
  }

  // 本そのものを表す4桁のコードをランダムで生成
  for (let i = 0; i < 4; i++) {
    isbn += digits.charAt(Math.floor(Math.random() * digits.length));
  }

  /* 最後の1桁はチェックデジット。
  ※チェックデジットとは、バーコードの読み間違いがないかを検査するための数値。
  以下のような手順で作る。
  ・「左から奇数桁の数字の合計」に「偶数桁の数字の合計」を3倍した値を加える
  ・↑の下1桁を特定し、10から引く
  */
  let sum = 0;
  for (let i = 0; i < isbn.length - 1; i++) {
    const digit = parseInt(isbn.charAt(i));
    if (i % 2 === 0) {
      sum += digit;
    } else {
      sum += digit * 3;
    }
  }

  const checkDigit = (10 - (sum % 10)) % 10;
  isbn += checkDigit;

  return isbn;
};

ISBNに該当する本の情報を取得する処理

以下のように、生成されたISBNと本が紐づいていたら情報を返し、
紐づかないのであれば、もう一度ISBNを作り直した上で検索するということが繰り返されます。

bookInfoFetcher.ts
import { generateRandomISBN } from "../isbnGenerator/isbnGenerator";
import axios from "axios";

export const fetchBookInfo = async () => {
  let isbn;
  do {
    isbn = generateRandomISBN();
    const response = await axios.get(
      `https://api.openbd.jp/v1/get?isbn=${isbn}`
    );
    if (response.data && response.data[0] && response.data[0].summary) {
      return response.data[0].summary;
    }
  } while (true);
};

ローディングメッセージ

「新しい本に出会う」ボタンが押された後に、本を検索している最中に画面に表示するメッセージです。
タイトル同様込み入った処理はなく、単にメッセージの内容を規定しているだけです。

LoadingMessage.tsx
const LoadingMessage = () => {
  return (
    <p style={{ fontWeight: "bold", fontSize: "20px" }}>
      <span role="img" aria-label="glass" className="mr-2">
        🔎
      </span>
      本を探しています...
    </p>
  );
};

export default LoadingMessage;

本の検索結果の表示


上の画像の、本の情報を表示している部分となります。

BookDisplay.tsx
interface BookDisplayProps {
  bookInfo: {
    title: string;
    author: string;
    publisher: string;
    pubdate: string;
  };
  handleBuyButton: () => void;
}

const BookDisplay: React.FC<BookDisplayProps> = ({
  bookInfo,
  handleBuyButton,
}) => {
  return (
    <div className="text-center">
      <h2 className="text-2xl mb-3">以下の本が選ばれました!</h2>
      <p style={{ fontWeight: "bold", fontSize: "20px" }}>{bookInfo.title}</p>
      <p style={{ color: "grey", fontSize: "16px" }}>
        {bookInfo.author}, {bookInfo.publisher},{" "}
        {bookInfo.pubdate.slice(0, 4) + "/" + bookInfo.pubdate.slice(4)}
      </p>
      <div className="flex justify-center items-center">
        <button
          className="bg-blue-500 text-white px-4 py-2 m-5 rounded-md flex items-center"
          onClick={handleBuyButton}
        >
          <span role="img" aria-label="wallet" className="mr-2">
            👛
          </span>
          この本を購入
        </button>
      </div>
    </div>
  );
};

export default BookDisplay;

選ばれた本に関する関心事(本の情報や検索した本の購入ページに誘導するボタン)が集まっています。
「この本を購入」ボタンを押した際に別タブが開く処理は以下のように分離させています。

buyButtonHandler.ts
export const handleBuyButton = (bookInfo: any) => {
  const hanmotoLink = `https://www.hanmoto.com/bd/isbn/${bookInfo.isbn}`;
  window.open(hanmotoLink);
};

やってみた所感

私は業務ではjqueryを用いています。
jqueryのように命令的に書かず、Reactでは宣言的に書くため、
画面のどこに何が表示されているかの雰囲気が掴みやすいという恩恵を受けられることを強く感じられました。

Discussion