🧑‍💻

【React】Qiitaのクローンを作ってみよう(記事一覧画面)

に公開

取り組み

Reactの実案件に挑みましたが全然理解できていないので、わからないまま進めてしまった実案件の復習を兼ねて、Qiitaのクローンを作ってみようかと思いました。
chatGPT先生に聞いたところ、まずは初級レベルの記事一覧画面から進めます。

コンポーネント

まずはどこをコンポーネント化するか考えてみます。
他で使い回す可能性のあるものはコンポーネント化しておきます。(私こういうの考えるの苦手)
ここ(赤枠)のカードは使い回しそうですね。

こんな感じにしてみよう。
カードのコンポーネント。

import { Link } from "react-router"

export type ArticleCardProps = {
  id: string
  title: string
  createdAt: string
  updatedAt?: string
  user: UserProps
}

export type UserProps = {
  id: string
  name: string
  icon: string
}

export function ArticleCard({ id, title, createdAt, updatedAt, user }: ArticleCardProps) {
  return (
    <>
      <div className="grid grid-cols-[32px_1fr] grid-rows-2 gap-x-2">
        <span className="row-span-2 block w-8 h-8 rounded-full overflow-hidden">
          <img src={user.icon} alt={user.name} width={32} height={32} />
        </span>
        <p className="text-sm">{user.name}</p>
        <p className="text-xs text-[rgba(0,0,0,0.6)]">
          <time dateTime={updatedAt ?? createdAt}>
            {updatedAt
              ? `更新日:${new Date(updatedAt).toLocaleDateString("ja-JP")}`
              : `作成日:${new Date(createdAt).toLocaleDateString("ja-JP")}`}
          </time>
        </p>
      </div>
      <div className="mt-2 ml-10">
        <h3 className="text-xl font-bold">
          <Link to={`/articles/${id}`}>{title}</Link>
        </h3>
      </div>
    </>
  )
}

そして、これをmapメソッドで配列を繰り返し処理する。
リストのコンポーネント。(これは検索結果でも使えそう)

import { ArticleCard } from "@/components/blocks"
import type { ArticleListProps } from "./ArticleListProps"

export type ArticleListItem = ArticleCardProps

export type ArticleListProps = {
  items: ArticleListItem[]
}

export function ArticleList({ items }: ArticleListProps) {
  return (
    <ul className="mt-2 space-y-6">
      {items.map(item => (
        <li key={item.id} className="bg-white py-4 px-6 rounded-xl">
          <ArticleCard {...item} />
        </li>
      ))}
    </ul>
  )
}

ロジックを考える

ロジックはこうかな。

import type { ArticleCardProps } from "@/components/blocks"
import { useEffect, useState } from "react"

// Qiita APIのURL
const QIITA_API_ITEMS_URL = "https://qiita.com/api/v2/items"

// 1ページあたり表示件数のデフォルトは20件
export function useArticle(perPage: number = 20) {
  // 記事の配列を管理するstate
  const [items, setItems] = useState<ArticleCardProps[]>([])
  // 読み込み中かどうかを管理するstate
  const [loading, setLoading] = useState(true)
  // エラー情報を管理するstate
  const [error, setError] = useState<string | null>(null)

  // コンポーネントがマウントされた時、またはperPageが変わった時にAPIを呼ぶ
  useEffect(() => {
    // 非同期関数でAPI呼び出し
    async function fetchArticles() {
      try {
        // Qiita APIを呼び出す
        const response = await fetch(`${QIITA_API_ITEMS_URL}?page=1&per_page=${perPage}`, {
          headers: {
            // アクセストークンをAuthorizationヘッダーに設定
            Authorization: `Bearer ${import.meta.env.VITE_QIITA_TOKEN}`,
          },
        })

        // レスポンスが失敗(ステータスコードが200系以外)の場合はエラーを投げる
        if (!response.ok) {
          throw new Error(`Qiita API 取得エラー:${response.status}`)
        }

        // JSONをパース
        const data = await response.json()

        // APIのレスポンスをArticleCardProps型に変換
        const mapped: ArticleCardProps[] = data.map((item: any) => ({
          id: item.id,
          title: item.title,
          createdAt: item.created_at,
          updatedAt: item.updated_at,
          url: item.url,
          user: {
            id: item.user.id,
            name: item.user.name,
            icon: item.user.profile_image_url,
          },
        }))

        // stateにセットしてコンポーネントを再レンダリング
        setItems(mapped)
      } catch (e) {
        // エラー発生時はerror stateにセット
        setError(e instanceof Error ? e.message : "不明なエラー")
      } finally {
        // 成功・失敗に関わらずloadingをfalseに
        setLoading(false)
      }
    }

    // fetchArticlesを呼び出す
    fetchArticles()
  }, [perPage])

  return { items, loading, error }
}

ざっくりの説明。
非同期通信でQiitaのAPIを呼び出し、発行したアクセストークンを設定して、返ってきたJSON形式をJavaScriptのオブジェクトに変換(パース)して、必要な型を整形(マッピング)して、Reactのstateに保存。

Qiita API アクセストークン発行方法
https://qiita.com/maiamea/items/680cca06f7825595cba0

ページ表示

ここまで準備した記事一覧を表示させてみる。

import { ArticleList } from "@/components/blocks"
import { useArticle } from "./hooks/use-article"

export function Home() {
  const { items, loading, error } = useArticle(20)

  if (loading) return <div>読み込み中…</div>
  if (error) return <div>エラー:{error}</div>

  return (
    <div className="max-w-[1040px] mx-auto">
      <h2 className="font-bold">記事一覧</h2>
      <ArticleList items={items} />
    </div>
  )
}

おわり

今回ひとまずトップページに表示させてみました。
ArticleCardとArticleListは一つのファイルでもよかったかなぁ。でも冗長になりそう…

Discussion