146
175

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【初心者完全版】0からReact開発して基礎をマスターできる最強チュートリアル【図解解説】

Last updated at Posted at 2025-06-08

react-beginner-tutorial.png

はじめに

こんにちは、Watanabe jinです。
ReactがAIも相まって選択されるケースが増えて学びたいと思っている人も多いかと思います。

私も過去にたくさんのReact教材をやりましたが、本当にやってよかったと思える教材はほぼありませんでした。
最初に選ぶ教材によってはReactの学習スピードは圧倒的に変わりますし、Reactの教材はJavaScriptの非同期処理やmapなどを踏まえて説明する必要があります。

このチュートリアルはそんな駆け出しエンジニアだった私に「これ1本だけやっておけば基礎は終わり」と言えるような完全版を目指して作成しました。JavaScriptがなんとなくという方でも最後まで絶対走りきれるように丁寧に解説しています。

最後までチュートリアルを行うと映画アプリが完成します。

名称未設定のデザイン (6).gif

動画教材も用意しています

こちらの教材にはより詳しく解説した動画もセットでご用意しています。
テキスト教材でわからない細かい箇所があれば動画も活用ください

対象者

  • Reactを初めてやる
  • JavaScriptが不安
  • フロントエンド開発に興味がある
  • 個人開発をしてみたい
  • アプリを作りながら学びたい

このチュートリアルはHTMLとCSSの経験があれば行うことが可能です

1. Reactの環境構築

このチャプターで学べること

  • React開発環境の基本設定
  • Reactの基本的な動作原理
  • 開発効率を高める仕組み

Reactの環境構築について解説していきます。
ここで学んでほしいのは手順通りに環境構築をして再現性をもってReactを始められるようにすることです。

まずはNode.jsの環境を用意しましょう。
Node.jsとはJavaScriptを実行するための開発環境です。JavaScriptをウェブブラウザだけでなく、パソコン上で直接動かせるようにするプログラムのことです。
ReactはJavaScriptのライブラリなので、JavaScriptを実行できる環境が必要です。

インストールは以下のサイトから行えます。

もしわからない方がいたらQiitaでインストール方法を調べるとたくさん記事が出てきますので、ここでは省略します。

インストールが終わったらインストールできているかを確認しましょう

node -v
v22.04

ここでエラーがでていなければNode.jsが正しくインストールされて使える状態です。

次にViteを使ってReact環境を構築します。
Viteは最新のフロントエンド開発ツールで、特に高速な開発環境を提供するビルドツールです。Viteを使ってReactを開発する理由は大きく2つあります。

image.png

1. 生のReactコードは直接実行できない

image.png

ReactではJSXという特殊な記法を使っているため、ブラウザではそのまま解釈(理解)することができません。

JSXはJavaScriptXMLの略称でHTMLをJavaScriptコードの中で直接記述できるシンタックスシュガー(ある構文を別の記法で記述できるようにしたもの)です。

function Greeting() {
  return <div>こんにちは</div>
}

このように書いてもブラウザでは認識することができません。
これはJavaScriptをわかりやすく書くためにJSXという直感的に書ける記法を用いて書いています。

実際にはJSXが内部でコンパイルされてJavaScriptに変換されています。

function Greeting() {
  return React.createElement(
    'div',
    null,
    'こんにちは、世界!'
  );
}

JSXは最終的にReact.createElementの呼び出しに変換されます。
しかしこれでは直感的に開発するのが難しいのでJSXというわかりやすい形で書いています。(もちろんこの形式でjsファイルに書いてもReactは利用可能です)

このJSXからJavaScriptに変換してくれるのがViteです。
このように変換してくれるツールをビルドツールと呼びます。

JSXはJavaScriptを利用する場合の形式で、本チュートリアルはTypeScriptを利用するため TSXで行います。拡張子がhoge.jsxでなくhoge.tsxと変わるくらいなので、名前が違うということだけ覚えておけば大丈夫です。

2. 高速な開発環境の提供

Viteを利用することでコードを変更したときに、画面をリフレッシュすることなく、変更が即座に反映させることができます。

例えば画面内の文字を変更した時にViteを利用することで即座に変更をビルド(JSXからJavaScriptの変換する)をしてくれるので開発をスムーズに行うことができます。

またビルドツールにはWebpackなど色々ありますが、Viteはその中でも特に高速なツールです。

そんなReactの開発には欠かせないViteを使った開発環境を簡単に用意できるのでやっていきましょう。

npm create vite@latest
Need to install the following packages:
create-vite@6.5.0
Ok to proceed? (y) y
│
◇  Project name:
│  movie-app
│
◇  Select a framework:
│  React
│
◇  Select a variant:
│  TypeScript
│
◇  Scaffolding project in /home/jinwatanabe/workspace/tmp/movie-app...
│
└  Done. Now run:

途中で選択がでるので「y」->「movie-app」->「React」->「TypeScript」で答えます。
今回は初心者向けではありますがTypeScriptの選択は今の時代必須になっているので慣れていくためにも利用していきます。ここも詳しく解説していきます。

プロジェクトができたらViteサーバーを起動してみましょう

cd movie-app
npm install
npm run dev

 VITE v6.3.5  ready in 115 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help

するとサーバーが起動します。
http://localhost:5173を開くと以下の画面が表示されれば起動できています。

image.png

npmというコマンドがでてきたので解説しておきます。

npm install

このコマンドはプロジェクトに必要なすべてのパッケージ(部品)をインターネットからダウンロードしてインストールします。パッケージのリストはディレクトリにあるpackage.jsonに書かれています。

package.json
{
  "name": "movie-app",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^19.1.0",
    "react-dom": "^19.1.0"
  },
  "devDependencies": {
    "@eslint/js": "^9.25.0",
    "@types/react": "^19.1.2",
    "@types/react-dom": "^19.1.2",
    "@vitejs/plugin-react": "^4.4.1",
    "eslint": "^9.25.0",
    "eslint-plugin-react-hooks": "^5.2.0",
    "eslint-plugin-react-refresh": "^0.4.19",
    "globals": "^16.0.0",
    "typescript": "~5.8.3",
    "typescript-eslint": "^8.30.1",
    "vite": "^6.3.5"
  }
}

ここのdepenciesdevDepenciesにかかれているものをダウンロードして、node_modulesというディレクトリにいれています。試しにnode_modulesをみると以下のようにたくさんのディレクトリがあります。

image.png

このようにReact開発に必要なものを事前に定義してダウンロードできるので、開発に必要な部品をわざわざ1つ1つダウンロードして環境を作る必要がなく誰でも同じ環境がinstallコマンドで行えます。

npm run dev

このコマンドは、開発用のローカルサーバーを起動して、アプリケーションを実行します。
またサーバーが起動するとコードの変更を監視し、変更があれば即座に反映するホットリロードが行えます。これはViteで起動しているメリットです。

試しにコードを変更してみましょう。(ここではよくわからなくても大丈夫です)

src/App.tsxをいかに変更してください

src/App.tsx
import './App.css'

function App() {

  return (
    <div>
      <h1>Hello World</h1>
    </div>
  )
}

export default App

するとサーバーがすぐに更新をしてログを出してくれます。

7:49:20 PM [vite] (client) hmr update /src/App.tsx
7:49:26 PM [vite] (client) hmr update /src/App.tsx (x2)
7:49:34 PM [vite] (client) hmr update /src/App.tsx (x3)
7:49:36 PM [vite] (client) hmr update /src/App.tsx (x4)

そして画面をみるとコード変更して保存した瞬間に反映されています。

image.png

この速さがViteでReactを開発するメリットなのです!

2. 映画一覧を表示してみる

このチャプターで学べること

  • JSXの書き方の理解
  • JavaScriptのmapの考え方
  • カーリーブレス

ここから本格的にReact開発をしていきます。
このチャプターではダミーの映画データを画面に表示できるようにします。

まずはsrc/App.tsxを開きましょう。

App.tsx
import './App.css'

function App() {

  return (
    <div>
      <h1>Hello World</h1>
    </div>
  )
}

export default App

tsxは以下の構成で書くことができます。

function App() {
  // JavaScriptのコードを書く
  
  return (
   /* HTMLで画面の見た目部分を書く */
  )
}

export default App

ざっくりこれくらいの認識で進めて行けば問題はありません。

例えば先程の修正はHTMLでHello Worldを表示するためにreturnの中に書いたものでした

  return (
    <div>
      <h1>Hello World</h1>
    </div>
  )

では試しにJavaScriptのコードを書いてみます。アラートを実装してみましょう

src/App.tsx
import './App.css'

function App() {

  alert('JavaScriptを実行')

  return (
    <div>
      <h1>Hello World</h1>
    </div>
  )
}

export default App

http://localhost:5173を開くとアラートが表示されました
JavaScriptが内部で実行されたあとに、画面表示の処理が行われます。

image.png

import './App.css'

このコードはApp.tsxと同じ階層にあるApp.cssを使えるようにする設定です。(CSSを設定するファイルです)
試しに以下の内容にしてみましょう

src/App.css
h1 {
  color: red;
}

アラートは邪魔なので消しておきます。

src/App.tsx
import './App.css'

function App() {
  return (
    <div>
      <h1>Hello World</h1>
    </div>
  )
}

export default App

image.png

画面を開くとh1要素が赤文字になりました。しっかりCSSファイルが読み込まれていることがわかります。

※ CSSが適応されているのを確認したらCSSをもとに戻してこの先を進めてください

image.png

それでは次にダミーの映画情報をオブジェクト(データをまとめる箱のようなもの)形式でまとめた配列を用意しましょう

src/App.tsx
import './App.css'

function App() {

  const defaultMovieList = [
    {
      id: 1,
      name: "君の名は",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/yLglTwyFOUZt5fNKm0PWL1PK5gm.jpg",
      overview:
        "1ヵ月後に1000年ぶりの彗星が訪れる日本。東京で暮らす平凡な男子高校生・瀧と、山深い村で都会の生活に憧れながら憂鬱な日々を送る女子高校生・三葉。つながりのない2人は、互いが入れ替わる不思議な夢を見る。",
    },
    {
      id: 2,
      name: "ハウルの動く城",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/v0K2e1t6ocUNnkZ9BeiFdcOT9LG.jpg",
    },
    {
      id: 3,
      name: "もののけ姫",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/mVdz3vlmioKWZaHTGfu99zIuayZ.jpg",
    },
    {
      id: 4,
      name: "バック・トゥ・ザ・フューチャー",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/oHaxzQXWSvIsctZfAYSW0tn54gQ.jpg",
    },
  ];

  return (
    <div>
      <h1>Hello World</h1>
    </div>
  )
}

export default App

defaultMovieListという変数に映画の情報をまとめたオブジェクトを用意しました

id : 一意に識別するための値
name : 映画名
image : 映画のポスター画像

これらを実際に画面に表示してみましょう。

src/App.tsx
import './App.css'

function App() {

  const defaultMovieList = [
    {
      id: 1,
      name: "君の名は",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/yLglTwyFOUZt5fNKm0PWL1PK5gm.jpg",
      overview:
        "1ヵ月後に1000年ぶりの彗星が訪れる日本。東京で暮らす平凡な男子高校生・瀧と、山深い村で都会の生活に憧れながら憂鬱な日々を送る女子高校生・三葉。つながりのない2人は、互いが入れ替わる不思議な夢を見る。",
    },
    {
      id: 2,
      name: "ハウルの動く城",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/v0K2e1t6ocUNnkZ9BeiFdcOT9LG.jpg",
    },
    {
      id: 3,
      name: "もののけ姫",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/mVdz3vlmioKWZaHTGfu99zIuayZ.jpg",
    },
    {
      id: 4,
      name: "バック・トゥ・ザ・フューチャー",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/oHaxzQXWSvIsctZfAYSW0tn54gQ.jpg",
    },
  ];

  return (
    <div>
      <div>
        <p>{defaultMovieList[0].name}</p>
        <img src={defaultMovieList[0].image} alt={defaultMovieList[0].name} />
        <p>{defaultMovieList[0].overview}</p>
      </div>
      <div>
        <p>{defaultMovieList[1].name}</p>
        <img src={defaultMovieList[1].image} alt={defaultMovieList[1].name} />
        <p>{defaultMovieList[1].overview}</p>
      </div>
      <div>
        <p>{defaultMovieList[2].name}</p>
        <img src={defaultMovieList[2].image} alt={defaultMovieList[2].name} />
        <p>{defaultMovieList[2].overview}</p>
      </div>
      <div>
        <p>{defaultMovieList[3].name}</p>
        <img src={defaultMovieList[3].image} alt={defaultMovieList[3].name} />
        <p>{defaultMovieList[3].overview}</p>
      </div>
    </div>
  )
}

export default App

image.png

表示はできましたが、同じようなコードを何度も書くのはスマートではありません
そこでJavaScriptのmapを利用してスマートにコードを書きましょう

src/App.tsx
import './App.css'

function App() {

  const defaultMovieList = [
    {
      id: 1,
      name: "君の名は",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/yLglTwyFOUZt5fNKm0PWL1PK5gm.jpg",
      overview:
        "1ヵ月後に1000年ぶりの彗星が訪れる日本。東京で暮らす平凡な男子高校生・瀧と、山深い村で都会の生活に憧れながら憂鬱な日々を送る女子高校生・三葉。つながりのない2人は、互いが入れ替わる不思議な夢を見る。",
    },
    {
      id: 2,
      name: "ハウルの動く城",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/v0K2e1t6ocUNnkZ9BeiFdcOT9LG.jpg",
    },
    {
      id: 3,
      name: "もののけ姫",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/mVdz3vlmioKWZaHTGfu99zIuayZ.jpg",
    },
    {
      id: 4,
      name: "バック・トゥ・ザ・フューチャー",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/oHaxzQXWSvIsctZfAYSW0tn54gQ.jpg",
    },
  ];

  return (
    <div>
      {defaultMovieList.map((movie) => (
        <div key={movie.id}>
          <h2>{movie.name}</h2>
          <img src={movie.image} alt={movie.name} />
          <p>{movie.overview}</p>
        </div>
      ))}
    </div>
  )
}

export default App

これでも同じ画面が表示されます。

defaultMovieList.map()は、実際に「HTMLの配列」を作っています。そして、Reactは自動的にこの配列を展開して表示してくれます。

// 映画データの配列
const movies = [映画1, 映画2, 映画3, 映画4]; 

const movieElements = [
  <div key="1">映画1の情報...</div>,
  <div key="2">映画2の情報...</div>,
  <div key="3">映画3の情報...</div>,
  <div key="4">映画4の情報...</div>
];

// Reactがこの配列を自動的に展開して表示
return (
  <div>
    {movieElements}
  </div>
);

実際には以下のことをしています。

      {defaultMovieList.map((movie) => (
        <div key={movie.id}>
          <h2>{movie.name}</h2>
          <img src={movie.image} alt={movie.name} />
          <p>{movie.overview}</p>
        </div>
      ))}

defaultMovieList配列の各映画(オブジェクト)に対して
それぞれをmovieという変数で受け取り(このmovieにはid、name、image、overviewなどの情報が含まれています)
各movieから<div>...</div>というHTMLを生成
生成されたHTMLの要素を全て集めて表示する

こうすることで繰り返しのコードをスマートに書くことができます。
コードの中には{}がでてきました。カーリーブレスと呼びます。

image.png

<h2>{movie.name}</h2>

HTMLの中でJavaScriptの値を利用する際には必要です。
deafultMovieListはJavaScriptの変数なので利用するために{}を使っています。

     {defaultMovieList.map((movie) => (
        (省略)
     }

最後にkeyという見慣れないワードが出てきました

<div key={movie.id}>

このKeyは初心者の段階では深く理解する必要はないので、「mapを利用したときの外側のタグに一意な値(IDなど)をkeyとして設定しておく」くらいの理解で大丈夫です。

3. 映画のリアルタイム検索をする

このチャプターで学べること

  • useState
  • イベントハンドラ
  • JavaScriptのfilter
  • レンダリングの仕組み

検索ボックスを用意してリアルタイムで映画の検索をしていきましょう
まずはインプットフォームを用意します

src/App.tsx
import './App.css'

function App() {

  const defaultMovieList = [
    {
      id: 1,
      name: "君の名は",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/yLglTwyFOUZt5fNKm0PWL1PK5gm.jpg",
      overview:
        "1ヵ月後に1000年ぶりの彗星が訪れる日本。東京で暮らす平凡な男子高校生・瀧と、山深い村で都会の生活に憧れながら憂鬱な日々を送る女子高校生・三葉。つながりのない2人は、互いが入れ替わる不思議な夢を見る。",
    },
    {
      id: 2,
      name: "ハウルの動く城",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/v0K2e1t6ocUNnkZ9BeiFdcOT9LG.jpg",
    },
    {
      id: 3,
      name: "もののけ姫",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/mVdz3vlmioKWZaHTGfu99zIuayZ.jpg",
    },
    {
      id: 4,
      name: "バック・トゥ・ザ・フューチャー",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/oHaxzQXWSvIsctZfAYSW0tn54gQ.jpg",
    },
  ];

  return (
    <div>
      <input type="text" />
      {defaultMovieList.map((movie) => (
        <div key={movie.id}>
          <h2>{movie.name}</h2>
          <img src={movie.image} alt={movie.name} />
          <p>{movie.overview}</p>
        </div>
      ))}
    </div>
  )
}

export default App

image.png

文字を入力することができます。ここまではHTMLの基本的な話です。
ここからはReactの機能を使って検索機能の実装をしますがまずは図解で流れを理解しましょう

image.png

  1. ユーザーはインプットフォームに「君の名は」と入力します
  2. 入力された値はkeywordという変数に保存されます
  3. 表示する映画はdefaultMovieListの中でキーワードをタイトルに含むものだけにします

「君」であれば「君の名は」と「君に届け」が残りますが、「君の名は」は「君の名は」1つだけが残ります。
このフィルタリングした映画のリストをmovieListという変数に保存しておきます

  1. movieListはフィルタリングされたものだけが残っているのでmapを使って表示をします。
  return (
    <div>
      <input type="text" />
      {movieList.map((movie) => (
        <div key={movie.id}>
          <h2>{movie.name}</h2>
          <img src={movie.image} alt={movie.name} />
          <p>{movie.overview}</p>
        </div>
      ))}
    </div>
  )

defaultMovieListではなく、movieListになっているのがポイントです。
これで検索機能が実装できるのでまずは入力したキーワードを保存して画面に表示してみます

src/App.tsx
import "./App.css";

function App() {
  const defaultMovieList = [
    {
      id: 1,
      name: "君の名は",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/yLglTwyFOUZt5fNKm0PWL1PK5gm.jpg",
      overview:
        "1ヵ月後に1000年ぶりの彗星が訪れる日本。東京で暮らす平凡な男子高校生・瀧と、山深い村で都会の生活に憧れながら憂鬱な日々を送る女子高校生・三葉。つながりのない2人は、互いが入れ替わる不思議な夢を見る。",
    },
    {
      id: 2,
      name: "ハウルの動く城",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/v0K2e1t6ocUNnkZ9BeiFdcOT9LG.jpg",
    },
    {
      id: 3,
      name: "もののけ姫",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/mVdz3vlmioKWZaHTGfu99zIuayZ.jpg",
    },
    {
      id: 4,
      name: "バック・トゥ・ザ・フューチャー",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/oHaxzQXWSvIsctZfAYSW0tn54gQ.jpg",
    },
  ];

  // 追加
  var keyword = "";

  return (
    <div>
      {/* 修正 */}
      <input type="text" onChange={(e) => (keyword = e.target.value)} />
      {/* 追加 */}
      <div>{keyword}</div>
      {defaultMovieList.map((movie) => (
        <div key={movie.id}>
          <h2>{movie.name}</h2>
          <img src={movie.image} alt={movie.name} />
          <p>{movie.overview}</p>
        </div>
      ))}
    </div>
  );
}

export default App;

キーワードという変数を用意します。

  var keyword = "";

入力した値をkeywordに代入して保存しておきましょう

      <input type="text" onChange={(e) => (keyword = e.target.value)} />
      <div>{keyword}</div>

inputにはonChangeというイベントハンドラが設定されています。

image.png

イベントハンドラはユーザーの操作をきっかけにJavaScriptを実行させることができます。
たとえばボタンをクリックしたらアラートを出すなどあります(onClick)

      <input type="text" onChange={(e) => (keyword = e.target.value)} />

onChangeはインプットフォームに入力するたびに実行する関数を指定します。
(e) => (keyword = e.target.value)というアロー関数を設定しているので、入力のたびに実行されます。

eは発生したイベントに関する情報を含むオブジェクトで、e.target.valueで入力したテキストが取得できます。
e.target.valueをkeyword変数に代入をしています。

画面でkeywordが保存できているかを確認するために表示もしてみます

      <div>{keyword}</div>

それでは画面を表示してキーワードを入力してみましょう

image.png

入力した値が画面に表示されそうですが、なぜか表示されません。
実はこれ動かないコードの例です。

Reactで値を保存しておきたいときにはuseStateというHooksを利用します。
HooksとはReactの機能を簡単に利用するための仕組みです。

image.png

useStateを使うと状態を管理することができます。(キーワードの状態)
この仕組みを利用して管理することで、状態が変わったときに画面の影響する部分を再レンダリング(画面を更新する)ことができるようになります。

現在の動かないコードはvarで変数を管理しているため、変数を更新しても画面は再レンダリングされないのです。
なのでキーワードを変えたとしても画面には表示されませんでした

src/App.tsx
import { useState } from "react"; // 追加
import "./App.css";

function App() {
  const defaultMovieList = [
    {
      id: 1,
      name: "君の名は",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/yLglTwyFOUZt5fNKm0PWL1PK5gm.jpg",
      overview:
        "1ヵ月後に1000年ぶりの彗星が訪れる日本。東京で暮らす平凡な男子高校生・瀧と、山深い村で都会の生活に憧れながら憂鬱な日々を送る女子高校生・三葉。つながりのない2人は、互いが入れ替わる不思議な夢を見る。",
    },
    {
      id: 2,
      name: "ハウルの動く城",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/v0K2e1t6ocUNnkZ9BeiFdcOT9LG.jpg",
    },
    {
      id: 3,
      name: "もののけ姫",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/mVdz3vlmioKWZaHTGfu99zIuayZ.jpg",
    },
    {
      id: 4,
      name: "バック・トゥ・ザ・フューチャー",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/oHaxzQXWSvIsctZfAYSW0tn54gQ.jpg",
    },
  ];

  // 修正
  const [keyword, setKeyword] = useState("");

  return (
    <div>
      <div>{keyword}</div>
      {/* 修正 */}
      <input type="text" onChange={(e) => setKeyword(e.target.value)} />
      {defaultMovieList.map((movie) => (
        <div key={movie.id}>
          <h2>{movie.name}</h2>
          <img src={movie.image} alt={movie.name} />
          <p>{movie.overview}</p>
        </div>
      ))}
    </div>
  );
}

export default App;

useStateを利用するためにインポートをして、useStateを利用しています。

  const [keyword, setKeyword] = useState("");

keywordは実際の状態の値、setKeywordは状態を更新する関数です。初期値は""なので空文字です。(つまり画面には空文字が表示されています)

      <input type="text" onChange={(e) => setKeyword(e.target.value)} />

onChangeの中身で変数に代入していたところをsetKeywordというkeywordを更新する関数を使って更新するようにしました。

実際に画面を見てみましょう

image.png

キーワードを入力するたびに画面に表示されるようになりました
Reactでは状態を保存しておきたいときはuseStateを利用すると覚えておきましょう

それでは次にフィルタリングした映画を保存しておくmovieListもuseStateに保存しておくべきかと思うのですが、実はこれは再レンダリングのタイミングを理解すればuseStateを使う必要がありません

src/App.tsx
import { useState } from "react";
import "./App.css";

function App() {
  const defaultMovieList = [
    {
      id: 1,
      name: "君の名は",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/yLglTwyFOUZt5fNKm0PWL1PK5gm.jpg",
      overview:
        "1ヵ月後に1000年ぶりの彗星が訪れる日本。東京で暮らす平凡な男子高校生・瀧と、山深い村で都会の生活に憧れながら憂鬱な日々を送る女子高校生・三葉。つながりのない2人は、互いが入れ替わる不思議な夢を見る。",
    },
    {
      id: 2,
      name: "ハウルの動く城",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/v0K2e1t6ocUNnkZ9BeiFdcOT9LG.jpg",
    },
    {
      id: 3,
      name: "もののけ姫",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/mVdz3vlmioKWZaHTGfu99zIuayZ.jpg",
    },
    {
      id: 4,
      name: "バック・トゥ・ザ・フューチャー",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/oHaxzQXWSvIsctZfAYSW0tn54gQ.jpg",
    },
  ];

  const [keyword, setKeyword] = useState("");

  return (
    <div>
      <div>{keyword}</div>
      <input type="text" onChange={(e) => setKeyword(e.target.value)} />
      {/* 修正 */}
      {defaultMovieList
        .filter((movie) => movie.name.includes(keyword))
        .map((movie) => (
          <div key={movie.id}>
            <h2>{movie.name}</h2>
            <img src={movie.image} alt={movie.name} />
            <p>{movie.overview}</p>
          </div>
        ))}
    </div>
  );
}

export default App;

画面をみて実際に検索をしてみると映画がフィルタリングされます。

image.png

keywordが変更されると状態が変更されるので画面全体が再レンダリングされます。
するとHTML部分も計算のし直しがおきます。

      {defaultMovieList
        .filter((movie) => movie.name.includes(keyword))
        .map((movie) => (

するとこの部分が再度計算されます。今回の修正でfilterを利用しています。
filter関数の動きは以下のようになっています。

defaultMovieListの各要素(映画)に対して、movie.name(映画タイトル)にkeywordが含まれるかチェック(movie.name.includes(keyword)
含まれる場合はtrueを返し、その映画は新しい配列に含まれる
含まれない場合はfalseを返し、その映画は除外される

新しい配列というのはfilterはもとの配列(defaultMovieList)を変更するのではなく、新しい配列にフィルタリングした映画をいれるためこのような表現になっています。(つまりdefaultMovieListは変更されない)

これで検索部分は作れたので、次は実際に映画のAPIを利用してデータを取得していきます。

4. 映画APIからデータを取得する

このチャプターで学べること

  • 非同期処理の仕組み
  • useEffect
  • APIの利用の仕方

今回は映画データの取得にTMDB APIを利用します。
アカウント登録は各自でしていただき、登録が終わった状態から解説します。

右上のアイコンから「設定」をクリック

image.png

左メニューの「API」をクリックしてAPIキーを要求するの「click here」をクリック

image.png

個人利用化を聞かれるので「Yes」をクリック
Is the intended use of our API for personal use?
image.png

フォームを埋めていきます。
アプリケーションURLにはダミーのものを入れて、アプリケーション概要に書いておきましょう

image.png

個人情報をいれたら、チェックボックスにチェックをいれて「Subscribe」をクリックします

image.png

「APIリードアクセストークン」をメモしておきます。
このトークンを利用することでAPIを利用することが可能です。

次に環境変数でアクセストークンを設定します。
環境変数とは、アプリケーションの設定値をコードの外部で管理する仕組みです。APIキーやデータベースの接続情報など、環境によって変わる可能性のある値や、セキュリティ上公開したくない情報を管理するのに使われます。

環境変数の設定には.envファイルが利用できます。

touch .env
.env
VITE_TMDB_API_KEY=あなたのアクセストークン

Viteでは.envで設定した環境変数を読み込むことができます。

const apiKey = import.meta.env.VITE_TMDB_API_KEY;

それでは実際に人気映画を10件取得するAPIを叩いてみます。
今回叩くAPIのドキュメントをみてみましょう

image.png

右側のHeaderにアクセストークンを貼り付けて、「Try it」をクリックします
(ここでけれ叩ければセストークンは正しく取得できています)

{
  "page": 1,
  "results": [
    {
      "adult": false,
      "backdrop_path": "/bVm6udIB6iKsRqgMdQh6HywuEBj.jpg",
      "genre_ids": [
        53,
        28
      ],
      "id": 1233069,
      "original_language": "de",
      "original_title": "Exterritorial",
      "overview": "When her son vanishes inside a US consulate, ex-special forces soldier Sara does everything in her power to find him — and uncovers a dark conspiracy.",
      "popularity": 505.1585,
      "poster_path": "/jM2uqCZNKbiyStyzXOERpMqAbdx.jpg",
      "release_date": "2025-04-29",
      "title": "Exterritorial",
      "video": false,
      "vote_average": 6.674,
      "vote_count": 242
    },
    (省略)

このAPIを叩くことで、人気の映画の情報を取得できます。

original_title: 映画名
overview : 映画のあらすじ
poster_path : ポスターの画像のURL

それでは実際にアプリの中でAPIを叩いてコンソールに表示してみましょう
ここで重要なのがuseEffect非同期処理です。

src/App.tsx
import { useEffect, useState } from "react"; // 修正
import "./App.css";

function App() {
  const defaultMovieList = [
    {
      id: 1,
      name: "君の名は",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/yLglTwyFOUZt5fNKm0PWL1PK5gm.jpg",
      overview:
        "1ヵ月後に1000年ぶりの彗星が訪れる日本。東京で暮らす平凡な男子高校生・瀧と、山深い村で都会の生活に憧れながら憂鬱な日々を送る女子高校生・三葉。つながりのない2人は、互いが入れ替わる不思議な夢を見る。",
    },
    {
      id: 2,
      name: "ハウルの動く城",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/v0K2e1t6ocUNnkZ9BeiFdcOT9LG.jpg",
    },
    {
      id: 3,
      name: "もののけ姫",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/mVdz3vlmioKWZaHTGfu99zIuayZ.jpg",
    },
    {
      id: 4,
      name: "バック・トゥ・ザ・フューチャー",
      image:
        "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/oHaxzQXWSvIsctZfAYSW0tn54gQ.jpg",
    },
  ];

 // 追加
  const fetchMovieList = async () => {
    const response = await fetch(
      `https://api.themoviedb.org/3/movie/popular?language=ja&page=1`,
      {
        headers: {
          Authorization: `Bearer ${import.meta.env.VITE_TMDB_API_KEY}`,
        },
      }
    );
    const data = await response.json();
    console.log(data.results)
    return data.results;
  };

  const [keyword, setKeyword] = useState("");

  // 追加
  useEffect(() => {
    fetchMovieList();
  }, []);

  return (
    <div>
      <div>{keyword}</div>
      <input type="text" onChange={(e) => setKeyword(e.target.value)} />
      {defaultMovieList
        .filter((movie) => movie.name.includes(keyword))
        .map((movie) => (
          <div key={movie.id}>
            <h2>{movie.name}</h2>
            <img src={movie.image} alt={movie.name} />
            <p>{movie.overview}</p>
          </div>
        ))}
    </div>
  );
}

export default App;

映画を取得する関数を作成しました。fetchを利用することでHTTPリクエストをしてデータ取得をしています。

  const fetchMovieList = async () => {
    const response = await fetch(
      `https://api.themoviedb.org/3/movie/popular?language=ja&page=1`,
      {
        headers: {
          Authorization: `Bearer ${import.meta.env.VITE_TMDB_API_KEY}`,
        },
      }
    );
    const data = await response.json();
    console.log(data.results)
    return data.results;
  };

fetchは、JavaScriptでWebサーバーからデータを取得するための関数です。

fetchはこのように利用できます

const response = await fetch(URL, オプション);

APIを叩くには先程取得したアクセストークンが必要なので、オプションで指定します。環境変数でアクセストークンを取得してヘッダーに設定しています。

        headers: {
          Authorization: `Bearer ${import.meta.env.VITE_TMDB_API_KEY}`,
        },

この関数は非同期関数になっています。

const fetchMovieList = async () => {
(省略)
};

ここで初心者がつまづきやすい非同期処理について詳しく解説していきます。
まずは「同期処理」と「非同期処理」の違いから理解していきましょう。

カレーとサラダを作ることを想像してください

image.png

同期処理は順番に作業をする方法です。カレーを作り終わってからサラダを作成します。
つまりカレーを煮込んでいる間はずっと鍋を見つめているのが「同期処理」です。

それに対しておそらく皆さんがやっているのは「非同期処理」です。
鍋を煮込んでいる間にサラダを作ることで時間の節約をしています。

これをよりプログラミング的に説明すると、自分が作業する主要作業を「メインスレッド」と呼びます。
同期処理ではメインスレッドのみですべての作業を行うため直列的になってしまいます。

しかし、煮込みの時間は別の作業をしたいと考えるので煮込みの作業になったタイミングでメインスレッドから優先度を落として裏側で並列して煮込みを行いつつ、メインスレッドではサラダを作るということをします。

今回の映画APIを叩いてデータ取得するのにも時間がかかるので非同期にしてあげることで、メインスレッドではユーザーはアプリを操作することができます。

image.png

同期処理にしてしまうと映画が取得できるまでアプリを操作できなくなります。
今回はすぐにデータが取得できるので困ることは少ないですが、もしデータ取得に長い時間がかかる場合ユーザー体験が悪くなってしまいます。

const fetchMovieList = async () => {
  // 関数の中身
};

asyncを使うことでこの関数が非同期処理を行うことを宣言しています。

const data = await response.json();

awaitは「この処理が完了するまで待つ」という指示です。これがないと映画データが取得される前に次の処理に進んでしまいます。

console.log(data.results);
return data.results;

最後に取得したデータから映画のリストを返却します。
APIを叩くと以下の形で返ってくるので、取得したデータのresultsを表示すると映画の配列を取得できます。

{
  "page": 1,
  "results": [
    {
      "adult": false,
      "backdrop_path": "/bVm6udIB6iKsRqgMdQh6HywuEBj.jpg",
      "genre_ids": [
        53,
        28
      ],
      (省略)

画面を開いてデベロッパーツールを開いてください。
Ctrl+Shif+iで開くことができます。

リロードをするとデベロッパーツールの「Console」に取得したデータが表示されます。

image.png

image.png

データがちゃんとAPIから取得できていることが確認できました。
またデータ取得される前に検索ボックスにキーワードをいれても問題なくアプリを使うことができます。

これは非同期処理をすることでメインスレッドを開けているからです。

また今回はuseEffectというのを使っているので解説します。

  useEffect(() => {
    fetchMovieList();
  }, []);

useEffectはReact Hooksの1つです。

useEffect(() => {
  // 実行される処理
}, [/* 依存配列 */]);

このような形で利用されて、第一引数に実行したい処理を含む関数、第二引数に依存配列を渡します。
依存配列に関してはあとで詳しく説明します。依存配列は空で設定することもできて(今回はこのパターン)、空の場合は画面表示前に1度だけ実行されます。そのあと再レンダリングが起きても実行されることはありません。

中では先ほど作成した非同期関数を呼び出しているだけです。
それではAPIから取得したデータをuseStateを使って状態を保存しておきましょう

src/App.tsx
import { useEffect, useState } from "react";
import "./App.css";

function App() {
  const fetchMovieList = async () => {
    const response = await fetch(
      `https://api.themoviedb.org/3/movie/popular?language=ja&page=1`,
      {
        headers: {
          Authorization: `Bearer ${import.meta.env.VITE_TMDB_API_KEY}`,
        },
      }
    );
    const data = await response.json();
    console.log(data.results)
    setMovieList(data.results); // 追加
  };

  const [keyword, setKeyword] = useState("");
  const [movieList, setMovieList] = useState([]); // 追加

  useEffect(() => {
    fetchMovieList();
  }, []);

  return (
    <div>
      <div>{keyword}</div>
      <input type="text" onChange={(e) => setKeyword(e.target.value)} />
      {/* 修正 */}
      {movieList
        .filter((movie) => movie.name.includes(keyword))
        .map((movie) => (
          <div key={movie.id}>
            <h2>{movie.name}</h2>
            <img src={movie.image} alt={movie.name} />
            <p>{movie.overview}</p>
          </div>
        ))}
    </div>
  );
}

export default App;

取得した映画の状態を保存しておかないといけないのでuseStateを使って用意します。

  const [movieList, setMovieList] = useState([]);

初期値は映画がないことを表す[]空配列にしています。
非同期関数でデータを取得できたら更新関数setMovieListを使って状態を更新します。

    const data = await response.json();
    setMovieList(data.results);

表示する映画はいままでダミーデータでしたが、APIから取得したデータにしたいのでdefaultMovieListからmovieListに変更しました。

    {movieList
        .filter((movie) => movie.name.includes(keyword))
        .map((movie) => (

それでは画面を見てみましょう

image.png

画面は真っ白でデベロッパーツールには以下のエラーが出ています。

Uncaught TypeError: Cannot read properties of undefined (reading 'includes')
    at App.tsx:31:39
    at Array.filter (<anonymous>)
    at App (App.tsx:31:10)

このエラーは

.filter((movie) => movie.name.includes(keyword)

movieにnameという属性がなく(つまりnull)、ないものに対してincludesという関数を呼び出そうとしているのでエラーになっています。

ここで重要になってくるのがいまmovieListにはどんな値が入っているのかです。

image.png

先程の取得したデータをみてみると、nameではなくoriginal_titleという属性名になっています。
私たちが作ったダミーデータとはキー名(属性名)が違うようなので直してあげましょう

src/App.tsx
import { useEffect, useState } from "react";
import "./App.css";

function App() {
  const fetchMovieList = async () => {
    const response = await fetch(
      `https://api.themoviedb.org/3/movie/popular?language=ja&page=1`,
      {
        headers: {
          Authorization: `Bearer ${import.meta.env.VITE_TMDB_API_KEY}`,
        },
      }
    );
    const data = await response.json();
    console.log(data.results);
    setMovieList(data.results);
  };

  const [keyword, setKeyword] = useState("");
  const [movieList, setMovieList] = useState([]);

  useEffect(() => {
    fetchMovieList();
  }, []);

  return (
    <div>
      <div>{keyword}</div>
      <input type="text" onChange={(e) => setKeyword(e.target.value)} />
      {movieList
        .filter((movie) => movie.original_title.includes(keyword))
        .map((movie) => (
          <div key={movie.id}>
            <h2>{movie.original_title}</h2>
            <img
              src={`https://image.tmdb.org/t/p/w500${movie.poster_path}`}
              alt={movie.original_title}
            />
            <p>{movie.overview}</p>
          </div>
        ))}
    </div>
  );
}

export default App;

画像に関してはAPIからは省略したパスが送られてくるので省略されている部分を直接書きました

            <img
              src={`https://image.tmdb.org/t/p/w500${movie.poster_path}`}
              alt={movie.original_title}
            />

image.png

いい感じに表示ができています。

今回はReactの基本を学ぶためにuseEffectの中で初期データの取得をしましたが、実際には初期データを取得する理由でuseEffectを利用してはいけません。React Queryなどのライブラリを使用するようにしましょう

image.png

5. TypeScriptの導入

このチャプターで学べること

  • TypeScriptを利用する理由を理解する
  • 型の活用をする

ここまで開発してとある心配が浮かび上がります。
私たちはmovieListから映画名を取るならmovie.original_titleで取得できることがわかっていますが、もし他の人が開発に参加してmovie.nameとしてしまったらエラーが出てアプリケーション全体が死んでしまいます。

// 想定していた構造
movie.name        // 存在しない
movie.image       // 存在しない
movie.overview    // これは正しい

// TMDB APIの実際の構造
movie.title           // 映画のタイトル(日本語など表示言語)
movie.original_title  // 映画の元のタイトル
movie.poster_path     // 画像パス(例: "/abcdef.jpg")
movie.overview        // あらすじ

もしmovieの中から評価を取得するとなったらどのような名前で取得すればよいのでしょうか?
movie.rank, movie.score, movie.review, movie.starなど考えられるのは色々あるのでエラーを起こさないようにするにはAPIドキュメントを全員が見なければなりません。

そこで利用できるのがTypeScriptです。TypeScriptを使うと、このような「想定と実際の構造の違い」によるエラーを開発中に発見できます。

いまVSCodeをみてみると赤線が出ている箇所があります。(これはTypeScriptをいれたことでエディタがエラーになりそうな箇所を教えてくれています)

image.png

エラーを意訳すると、「movieにはoriginal_titleという属性がない可能性があるので、下手したらエラーになりますよ」と伝えてくれています。たしかにmovie.nameにしていたときはエラーになります。

TypeScriptの型を利用することでmovieに存在する属性を定義して、それ以外を利用した場合に事前にエディタでエラーを表示することができます。こうすることで画面を実際にみてエラーが起きていたから直さないといけないという時間がかかる対応をしなくてもすみます。

実際にAPIのレスポンスの戻り値の型と私たちが使うMovieの型を定義してみましょう

src/App.tsx
import { useEffect, useState } from "react";
import "./App.css";

// 追加
type Movie = {
  id: string;
  original_title: string;
  poster_path: string;
  overview: string;
};

// 追加
type MovieJson = {
  adult: boolean;
  backdrop_path: string | null;
  genre_ids: number[];
  id: string;
  original_language: string;
  original_title: string;
  overview: string;
  popularity: number;
  poster_path: string | null;
  release_date: string;
  title: string;
  video: boolean;
  vote_average: number;
  vote_count: number;
};

function App() {
  const fetchMovieList = async () => {
    const response = await fetch(
      `https://api.themoviedb.org/3/movie/popular?language=ja&page=1`,
      {
        headers: {
          Authorization: `Bearer ${import.meta.env.VITE_TMDB_API_KEY}`,
        },
      }
    );
    const data = await response.json();
    // 修正
    setMovieList(
      data.results.map((movie: Movie) => ({
        id: movie.id,
        original_title: movie.original_title,
        poster_path: movie.poster_path,
        overview: movie.overview,
      }))
    );
  };

  const [keyword, setKeyword] = useState("");
  const [movieList, setMovieList] = useState<Movie[]>([]); // 修正

  useEffect(() => {
    fetchMovieList();
  }, []);

  return (
    <div>
      <div>{keyword}</div>
      <input type="text" onChange={(e) => setKeyword(e.target.value)} />
      {movieList
        .filter((movie) => movie.original_title.includes(keyword))
        .map((movie) => (
          <div key={movie.id}>
            <h2>{movie.original_title}</h2>
            <img
              src={`https://image.tmdb.org/t/p/w500${movie.poster_path}`}
              alt={movie.original_title}
            />
            <p>{movie.overview}</p>
          </div>
        ))}
    </div>
  );
}

export default App;

まずはMovie型を用意してuseStateの型に設定しました。
こうすることでmovieListにはMovieの構造だけを含むリストしかはいらないことを宣言しています。
変な構造のオブジェクトがリストに入った場合エディタ上でエラーが表示されます。

type Movie = {
  id: string;
  original_title: string;
  poster_path: string;
  overview: string;
};

  const [movieList, setMovieList] = useState<Movie[]>([]);

movieListの更新ではMovieの構造になるようにしています。

    setMovieList(
      data.results.map((movie: MovieJson) => ({
        id: movie.id,
        original_title: movie.original_title,
        poster_path: movie.poster_path,
        overview: movie.overview,
      }))

APIからのレスポンスには私たちのアプリに不要な属性がたくさん含まれているので、必要なものだけを集めてきてオブジェクトを作成しています。

MovieJsonはAPIから返ってくるレスポンスの構造を型で表現したものです。

type MovieJson = {
  adult: boolean;
  backdrop_path: string | null;
  genre_ids: number[];
  id: string;
  original_language: string;
  original_title: string;
  overview: string;
  popularity: number;
  poster_path: string | null;
  release_date: string;
  title: string;
  video: boolean;
  vote_average: number;
  vote_count: number;
};

APIのレスポンスも型にしてあげることで、Movie型のオブジェクトを作るときにdata.resultから存在しない属性を取得しようとしたときにエディタ上でエラーに気づけます。

表示の部分でmovie.の部分を入力してみるとエディタで補完がでるようになります。
この中にでているものはmovieから呼び出せるものになっています。

image.png

こうすることで先程の私たちのようにmovie.nameを利用してアプリケーション全体をクラッシュさせることを画面を見なくても事前に防ぐことができるようになります。

TypeScriptを使うことで開発効率が上がり、より堅牢なアプリケーションを作れるようになるので積極的に利用していきましょう。

6. キーワード検索をしよう

このチャプターで学べること

  • useEffectの依存配列について
  • キーワード検索の方法

現在は初期表示した映画のリストからの検索にしか対応していませんが、世の中には様々な映画があります。
手元の映画でなく、映画APIが持っているデータの中から検索ができるように変更をしていきます。

ここで利用できるのがuseEffectの依存配列です。
先程解説を飛ばしてしまいましたので、ここで詳しく仕組みを解説していきます。

image.png

依存配列にステート(状態)を設定しておくと、ステートの値が(setKeywordで)更新された瞬間、useEffectを再実行してくれます。もし設定しないと初回時に1度だけしか実行されません。

こうすることでユーザーが入力するたびにキーワードが更新されてキーワードに関係する映画が取得されるようになります。

キーワードで取得するAPIはこちらになります。

先ほどとは叩くAPIのURLが異なるのでそこを意識して実装してみましょう

src/App.tsx
import { useEffect, useState } from "react";
import "./App.css";

type Movie = {
  id: string;
  original_title: string;
  poster_path: string;
  overview: string;
};

type MovieJson = {
  adult: boolean;
  backdrop_path: string | null;
  genre_ids: number[];
  id: string;
  original_language: string;
  original_title: string;
  overview: string;
  popularity: number;
  poster_path: string | null;
  release_date: string;
  title: string;
  video: boolean;
  vote_average: number;
  vote_count: number;
};

function App() {
  // 修正
  const fetchMovieList = async () => {
    const API_KEY = import.meta.env.VITE_TMDB_API_KEY;
    let url = "";
    if (keyword) {
      url = `https://api.themoviedb.org/3/search/movie?query=${keyword}&include_adult=false&language=ja&page=1`;
    } else {
      url = "https://api.themoviedb.org/3/movie/popular?language=ja&page=1";
    }
    const response = await fetch(url, {
      headers: {
        Authorization: `Bearer ${API_KEY}`,
      },
    });
    const data = await response.json();
    const result = data.results;
    const movieList = result.map((movie: MovieJson) => ({
      id: movie.id,
      original_title: movie.title,
      poster_path: movie.poster_path,
    }));
    setMovieList(movieList);
  };

  const [keyword, setKeyword] = useState("");
  const [movieList, setMovieList] = useState<Movie[]>([]);

  useEffect(() => {
    fetchMovieList();
  }, [keyword]); // 修正

  return (
    <div>
      <div>{keyword}</div>
      <input type="text" onChange={(e) => setKeyword(e.target.value)} />
      {movieList
        .filter((movie) => movie.original_title.includes(keyword))
        .map((movie) => (
          <div key={movie.id}>
            <h2>{movie.original_title}</h2>
            <img
              src={`https://image.tmdb.org/t/p/w500${movie.poster_path}`}
              alt={movie.original_title}
            />
            <p>{movie.overview}</p>
          </div>
        ))}
    </div>
  );
}

export default App;

fetchMovieListで叩くAPIのURLをキーワードがある/なしで分けるようにしました

    if (keyword) {
      url = `https://api.themoviedb.org/3/search/movie?query=${keyword}&include_adult=false&language=ja&page=1`;
    } else {
      url = "https://api.themoviedb.org/3/movie/popular?language=ja&page=1";
    }

画面表示前に初回で実行されたときはkeywordの初期値は空文字なので、これまで叩いていたURLを叩きます。
入力フォームに値が入力されると、そのキーワードを含む映画をAPIを使って検索します。

image.png

「君」と調べると知らない映画ですが確かにタイトルに「君」が入る映画が表示されるようになりました

image.png

「君の」までいれると「君の名は」がでてきました。

6. ルーティングを設定しよう

このチャプターで学べること

  • react-routerを利用したルーティング
  • コンポーネントについて
  • URLからパラメータを取得する方法

ここからはそれぞれの映画をクリックしたら映画の詳細を表示するページに遷移できるようにreact-routerを設定していきます。

react-routerはルーティングライブラリで画面を遷移して別ページを作りたいときに必要なライブラリです。

それではライブラリをインストールしてみましょう。npmコマンドを使えば簡単に外部ライブラリをインストールすることができます。

npm install react-router

それではルーティングの設定をしていきます。src/main.tsxを開いてください

src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

このファイルはReactアプリ全体の設定をしているファイルだと思ってください。
例えばいままで実装を進めてきたApp.tsxはこのファイルで読み込まれて利用されています。

この<App />というのがコンポーネントと呼びます。いままで私たちはApp.tsxでAppコンポーネントを実装していました。

image.png

コンポーネントとはUIを構成するための部品だと思ってください。
この部品をレゴブロックのように組み合わせて1つの画面を作ることができます。

image.png

コンポーネントには関数コンポーネントクラスコンポーネントがあります。
App.tsxは関数コンポーネントです。コンポーネントは再利用が可能です。

試しに今回作成する映画の詳細を表示するコンポーネント(ページ)であるMovieDetailコンポーネントを作成してみましょう

touch src/MovieDetail.tsx
src/MovieDetail.tsx
function MovieDetail() {
  return <div>映画詳細ページ</div>;
}

export default MovieDetail;

main.tsxで試しにMovieDetailコンポーネントを表示してみましょう

/src/main/tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import MovieDetail from "./MovieDetail.tsx";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <MovieDetail />
  </StrictMode>
);

画面にはMovieDetailコンポーネントの内容が表示されるようになりました

image.png

話を戻してルーティングに関する設定をmain.tsxに行いましょう

src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import { createBrowserRouter, RouterProvider } from "react-router";
import MovieDetail from "./MovieDetail.tsx";
import App from "./App";

const router = createBrowserRouter([
  { path: "/", Component: App },
  { path: "/movies/:movieId", Component: MovieDetail },
]);

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <RouterProvider router={router} />
  </StrictMode>
);

まずはルーティングのためのライブラリから提供されているコンポーネントを利用します。

    <RouterProvider router={router} />

routerはルーティングの設定を渡してあげる必要があるので準備します。

const router = createBrowserRouter([
  { path: "/", Component: App },
  { path: "/movies/:movieId", Component: MovieDetail },
]);

http://localhost:5173ならトップページなのでAppコンポーネントを表示
http://localhost:5173/movies/:movieeIdなら映画詳細画面なのでMovieDetailコンポーネントを表示します

idは先程APIから取得したmovie.idを渡してあげます。
このIdを使って再度APIを叩くことでより細かい映画の情報を取得することが可能です。

それでは実際に詳細ページにアクセスしてみます。
http://localhost:5173/movies/hogeにアクセスしてみましょう。いまはidはなんでもよいので適当な文字列(hoge)をいれました

image.png

しっかり画面が表示できたのでルーティングは問題なくできていそうです。
このあとこのページではuseEffectを利用してidから映画の情報を取得することになります。
なのでURLの:movieIdの部分を取得できるように実装してみましょう

src/MovieDetail.tsx
import { useParams } from "react-router";

function MovieDetail() {
  const { movieId } = useParams();
  return <div>{movieId}映画詳細ページ</div>;
}

export default MovieDetail;

URLからIDを取得するにはuseParamsというreact-routerが提供している関数を使えば簡単にできます。
注意としてはルーティングで設定した名前(:movieId)と同じ名前で取得しないといけません

  { path: "/movies/:movieId", Component: MovieDetail }, // :movieId


  const { movieId } = useParams(); // movieId

{ movieId }は、JavaScriptの「分割代入(destructuring assignment)」という機能です。オブジェクトから特定のプロパティを取り出して、変数として使えるようにする便利な構文です。

const params = useParams();
const movieId = params.movieId;

これと同じことを一行で行うことができます。
画面をみてみましょう

image.png

movieIdであるhogeが表示されているのでURLから取得できていることがわかります。

7. 映画詳細ページの実装

このチャプターで学べること

  • APIで映画詳細を取得する
  • &&演算子の利用

movieIdが取得できるようになったので以下のAPIを叩いて映画の詳細を取得して表示してみましょう

これまでのAPIとはレスポンスの構造が変わってくるので、注意して実装していきます。

src/MovieDetail.tsx
import { useEffect, useState } from "react";
import { useParams } from "react-router";

type Movie = {
  id: string;
  original_title: string;
  poster_path: string;
  overview: string;
  year: number;
  rating: number;
  runtime: number;
  score: number;
  genres: string[];
};

type MovieDetailJson = {
  adult: boolean;
  backdrop_path: string | null;
  belongs_to_collection: null;
  budget: number;
  genres: { id: number; name: string }[];
  homepage: string;
  id: string;
  imdb_id: string;
  origin_country: string[];
  original_language: string;
  original_title: string;
  overview: string;
  popularity: number;
  poster_path: string;
  production_companies: {
    id: number;
    logo_path: string;
    name: string;
    origin_country: string;
  }[];
  production_countries: {
    iso_3166_1: string;
    name: string;
  }[];
  release_date: string;
  revenue: number;
  runtime: number;
  spoken_languages: {
    english_name: string;
    iso_639_1: string;
    name: string;
  }[];
  status: string;
  tagline: string;
  title: string;
  video: boolean;
  vote_average: number;
  vote_count: number;
};

function MovieDetail() {
  const { movieId } = useParams();
  const [movie, setMovie] = useState<Movie | null>(null);
  useEffect(() => {
    fetchMovieDetail();
  }, []);

  const fetchMovieDetail = async () => {
    const API_KEY = import.meta.env.VITE_TMDB_API_KEY;
    const response = await fetch(
      `https://api.themoviedb.org/3/movie/${movieId}?language=ja&page=1&append_to_response=credits`,
      {
        headers: {
          Authorization: `Bearer ${API_KEY}`,
        },
      }
    );
    const data = (await response.json()) as MovieDetailJson;
    setMovie({
      id: data.id,
      original_title: data.title,
      poster_path: data.poster_path,
      year: Number(data.release_date.split("-")[0]),
      rating: data.vote_average,
      runtime: data.runtime,
      score: data.vote_count,
      overview: data.overview,
      genres: data.genres.map((genre) => genre.name),
    });
  };
  return (
    <div>
      {movie && (
        <div>
          <h2>{movie.original_title}</h2>
          <img
            src={`https://image.tmdb.org/t/p/w500${movie.poster_path}`}
            alt={movie.original_title}
          />
          <p>{movie.overview}</p>
          <p>{movie.year}</p>
          <p>{movie.rating}</p>
          <p>{movie.runtime}</p>
          <p>{movie.score}</p>
          <p>{movie.genres}</p>
        </div>
      )}
    </div>
  );
}

export default MovieDetail;

このページではより詳細な情報を表示したいのでMovie型に新しい属性を追加しました

type Movie = {
  id: number;
  original_title: string;
  poster_path: string;
  overview: string;
  year: number;
  rating: number;
  runtime: number;
  score: number;
  genres: string[];
};

そしてAPIのレスポンスの構造もいままでとは違うので定義しました

type MovieDetailJson = {
  adult: boolean;
  backdrop_path: string | null;
  belongs_to_collection: null;
  budget: number;
  genres: { id: number; name: string }[];
  homepage: string;
  id: number;
  imdb_id: string;
  origin_country: string[];
  original_language: string;
  original_title: string;
  overview: string;
  popularity: number;
  poster_path: string;
  production_companies: {
    id: number;
    logo_path: string;
    name: string;
    origin_country: string;
  }[];
  production_countries: {
    iso_3166_1: string;
    name: string;
  }[];
  release_date: string;
  revenue: number;
  runtime: number;
  spoken_languages: {
    english_name: string;
    iso_639_1: string;
    name: string;
  }[];
  status: string;
  tagline: string;
  title: string;
  video: boolean;
  vote_average: number;
  vote_count: number;
};

このAPIの構造はドキュメントをみて定義しています。

  const [movie, setMovie] = useState<Movie | null>(null);

movieをuseStateで定義します。
movieはmovieIdから検索されるのでhogeなど適当な値をいれたら映画は見つかりません。
このときはmovieはnullになるので型ではMovienull型が入ると宣言してあげて初期値はnullにします。

  useEffect(() => {
    fetchMovieDetail();
  }, []);

  const fetchMovieDetail = async () => {
    const API_KEY = import.meta.env.VITE_TMDB_API_KEY;
    const response = await fetch(
      `https://api.themoviedb.org/3/movie/${movieId}?language=ja&page=1&append_to_response=credits`,
      {
        headers: {
          Authorization: `Bearer ${API_KEY}`,
        },
      }
    );
    const data = (await response.json()) as MovieDetailJson;
    setMovie({
      id: data.id,
      original_title: data.title,
      poster_path: data.poster_path,
      year: Number(data.release_date.split("-")[0]),
      rating: data.vote_average,
      runtime: data.runtime,
      score: data.vote_count,
      overview: data.overview,
      genres: data.genres.map((genre) => genre.name),
    });
  };

useEffectでの呼び出しは前回とほとんど同じです。
今回は検索などがないので依存配列は空(つまり最初に1度だけ実行)になっています。

      {movie && (
        <div>
          <h2>{movie.original_title}</h2>
          <img
            src={`https://image.tmdb.org/t/p/w500${movie.poster_path}`}
            alt={movie.original_title}
          />
          <p>{movie.overview}</p>
          <p>{movie.year}</p>
          <p>{movie.rating}</p>
          <p>{movie.runtime}</p>
          <p>{movie.score}</p>
          <p>{movie.genres}</p>
        </div>
      )}

表示はみなれない&&がでてきています。
これはmovieがnullでなければ左を表示するという意味を持っています。

TypeScriptではこのように&&を使って表示しないとエディタでエラーになります。

image.png

これはTypeScript導入で説明した話と同じようなエラーです。
movieにはnullが初期値として入っており、movieIdで映画が見つからなかった場合nullのままです。

nullに対して.nameをしてしまうとnullにはnameという属性がないため画面がクラッシュしてしまいます。
これをエディタではエラーとして表示してくれています。

なのでmovieがnullでなければ表示することで絶対にnameにアクセスできるよう&&を利用しました。

実際に映画の詳細が画面に表示されるかをチェックします。TMDBで映画詳細ページを開きます。

URLの372058が映画のIDとなります。

image.png

「君の名は」のIDがわかったので、http://localhost:5173/movies/372058にアクセスしてみましょう

image.png

「君の名は」の映画の詳細な情報が取得できました。

8. 共通レイアウトの作成

ここまでで一通り必要な機能ができたのでページに共通のデザインとしてヘッダー部分を実装します。
ヘッダーはコンポーネントを作成して共通レイアウトとしてどのページでも表示されるようにしましょう

まずはコンポーネントを作成します

touch src/Header.tsx
src/Header.tsx
function Header({ children }: { children: React.ReactNode }) {
  return (
    <div>
      <header>
        <h1>MOVIEFLIX</h1>
      </header>
      <main>{children}</main>
    </div>
  );
}

export default Header;

ここでchildrenというのがでてきましたが、一旦解説は後回しにしてこのコンポーネントを利用します。
共通のレイアウトなのでmain.tsxでHeaderコンポーネントを利用します。

src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import { createBrowserRouter, RouterProvider } from "react-router";
import MovieDetail from "./MovieDetail.tsx";
import App from "./App";
import Header from "./Header.tsx";

const router = createBrowserRouter([
  { path: "/", Component: App },
  { path: "/movies/:movieId", Component: MovieDetail },
]);

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <Header>
      <RouterProvider router={router} />
    </Header>
  </StrictMode>
);

image.png

image.png

MOVIEFLIXというヘッダーがそれぞれのページで表示されるようになりました
それではchildrenについて解説していきます。

image.png

Reactにおける children は、コンポーネントのタグで囲まれた内容を指す特別なプロパティです。簡単に言うと、「コンポーネントの中身」のことです。

<Header>ここに書いた内容がHeaderコンポーネントの「children」になります</Header>

Headerコンポーネントではこの中身の部分を受け取って表示しています。

function Header({ children }: { children: React.ReactNode }) {
  return (
    <div>
      <header>
        <h1>MOVIEFLIX</h1>
      </header>
      <main>{children}</main>
    </div>
  );
}

つまりmain.tsx<RouterProvider router={router} />をchildrenとして受けとっています。
この仕組みによって常にRouterProviderの下で表示されるコンポーネントにはヘッダーがつくようになります。

9. スタイリング

このチャプターで学べること

  • CSSでのスタイリング
  • lucide-reactを使ったアイコン表示
  • ?演算子
  • Linkタグ

ここまでで一通り機能ができたので最後はデザイン部分を行っていきます。
今回はCSSを用意したので、そちらをコピペしてクラスを付与してスタイルを当てていきます。CSSについての解説は割愛しております。

今回画面にアイコンを一部利用しておりますので、以下のライブラリを入れます。

npm i lucide-react

必要なファイルも一通り作成しましょう

touch src/MovieDetail.css

Headerだけは一緒にCSSをあてて行きましょう。
それ以外はまとめて載せていきますのでみながらスタイリングしてみてください。

src/App.css
body {
  background: hsl(0, 0%, 9%);
  margin: 0;
  max-width: 100vw;
  width: 100vw;
  overflow-x: hidden;
}

.app-header {
  background: #181818;
  opacity: 0.8;
  padding-left: 2rem;
  height: 50px;
  position: sticky;
  top: 0;
 display: flex;
  z-index: 9999;
}

.app-title {
  color: #e50914;
  font-size: 30px;
  font-weight: 900;
  margin: 0;
}

.app-search-wrap {
  display: flex;
  justify-content: flex-start;
  align-items: center;
  margin: 2.5rem 0 2rem 2rem;
}

.hero-section-content {
  position: relative;
}

.app-search {
  width: 340px;
  font-size: 1.1rem;
  padding: 14px 20px;
  border-radius: 8px;
  border: none;
  outline: none;
  background: #232323;
  color: #fff;
  box-shadow: 0 2px 16px #0006;
  font-weight: 500;
  letter-spacing: 0.5px;
  transition: box-shadow 0.2s, background 0.2s;
}

.app-search:focus {
  background: #333;
  box-shadow: 0 4px 32px #000a;
}

.movie-row-section {
  width: 100vw;
  max-width: 100vw;
  box-sizing: border-box;
  margin-top: 1rem;
}

.movie-row-title {
  color: #fff;
  font-size: 1.3rem;
  font-weight: 700;
  margin: 0 0 1.2rem 2vw;
  letter-spacing: 1px;
}
.movie-row-scroll {
  display: flex;
  flex-direction: row;
  gap: 1.6rem;
  overflow-x: auto;
  padding: 0 0 1.5rem 2vw;
  scrollbar-width: none;
}
.movie-row-scroll::-webkit-scrollbar {
  display: none;
}

.movie-card {
  display: block;
  position: relative;
  min-width: 180px;
  width: 180px;
  height: 270px;
  border-radius: 14px;
  overflow: hidden;
  background: #232323;
  box-shadow: 0 2px 18px rgba(0, 0, 0, 0.22);
  cursor: pointer;
  transition: transform 0.18s, box-shadow 0.18s;
}
.movie-card:hover {
  transform: scale(1.07);
  box-shadow: 0 8px 32px rgba(229, 9, 20, 0.18);
  z-index: 2;
}
.movie-card__imgwrap {
  width: 100%;
  max-width: 100%;
  overflow: hidden;
  box-sizing: border-box;
  width: 100%;
  height: 100%;
  position: relative;
}
.movie-card__image {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  border-radius: 14px;
  transition: filter 0.18s;
}
.movie-card:hover .movie-card__image {
  filter: brightness(0.7) blur(1px);
}
.movie-card__overlay {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: flex-end;
  justify-content: flex-start;
  background: linear-gradient(0deg, rgba(0, 0, 0, 0.85) 60%, transparent 100%);
  opacity: 0;
  transition: opacity 0.18s;
  padding: 1.1rem 1rem 1.3rem 1rem;
  pointer-events: none;
}
.movie-card:hover .movie-card__overlay {
  opacity: 1;
}
.movie-card__title {
  color: #fff;
  font-size: 1.09rem;
  font-weight: 700;
  letter-spacing: 0.5px;
  margin: 0;
  text-shadow: 0 2px 12px rgba(0, 0, 0, 0.32);
}

@media (max-width: 700px) {
  .app-header {
    padding: 1.2rem 0 1.2rem 0;
  }
  .app-title {
    font-size: 2rem;
    margin-bottom: 1rem;
  }
  .app-search {
    width: 94vw;
    font-size: 1rem;
  }
  .movie-row-title {
    font-size: 1.07rem;
    margin-bottom: 0.7rem;
  }
  .movie-row-scroll {
    gap: 0.8rem;
    padding-left: 1vw;
  }
  .movie-card {
    min-width: 120px;
    width: 120px;
    height: 180px;
    border-radius: 8px;
  }
  .movie-card__image {
    border-radius: 8px;
  }
  .movie-card__title {
    font-size: 0.95rem;
  }
  .movie-card__overlay {
    padding: 0.5rem 0.5rem 0.8rem 0.5rem;
  }
}

@media (prefers-reduced-motion: no-preference) {
  a:nth-of-type(2) .logo {
    animation: logo-spin infinite 20s linear;
  }
}

.card {
  padding: 2em;
}

.read-the-docs {
  color: #888;
}

.hero-section {
  position: relative;
  width: 100vw;
  max-width: 100vw;
  min-height: 480px;
  background: #222;
  color: #fff;
  display: flex;
  align-items: flex-end;
  justify-content: flex-start;
  overflow: hidden;
  box-sizing: border-box;
}
.hero-section-bg {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
  object-position: center;
  filter: brightness(0.55);
  z-index: 0;
}
.hero-section-gradient {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: linear-gradient(180deg, #181818 5%, #111 90%);
  opacity: 0.2;
  z-index: 1;
}
.hero-section-content {
  position: relative;
  z-index: 2;
  padding: 56px 0 56px 60px;
  max-width: 700px;
  display: flex;
  flex-direction: column;
  gap: 24px;
}
.hero-section-title {
  font-size: 3rem;
  font-weight: 800;
  margin: 0 0 10px 0;
  letter-spacing: 1px;
}
.hero-section-badges {
  display: flex;
  gap: 10px;
  margin-bottom: 8px;
}
.hero-section-badge {
  background: #232323;
  color: #fff;
  border-radius: 6px;
  padding: 2px 12px;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.5px;
}
.hero-section-overview {
  font-size: 1.1rem;
  color: #e4e4e4;
  margin-bottom: 20px;
  text-shadow: 0 2px 16px #000a;
}
.hero-section-actions {
  display: flex;
  gap: 14px;
}
.hero-section-btn {
  font-size: 1rem;
  font-weight: 700;
  border-radius: 6px;
  padding: 12px 28px;
  border: none;
  cursor: pointer;
  background: #fff;
  color: #111;
  display: flex;
  align-items: center;
  gap: 8px;
  transition: background 0.2s, color 0.2s;
}
.hero-section-btn-primary {
  background: #e50914;
  color: #fff;
}
.hero-section-btn-primary:hover {
  background: #b0060f;
  color: #fff;
}
.hero-section-btn-secondary {
  background: #232323;
  color: #fff;
}
.hero-section-btn-secondary:hover {
  background: #444;
  color: #fff;
}
src/Header.tsx
function Header({ children }: { children: React.ReactNode }) {
  return (
    <div className="app-bg">
      <header className="app-header">
        <h1 className="app-title">MOVIEFLIX</h1>
      </header>
      <main>{children}</main>
    </div>
  );
}

export default Header;

クラスを付与することでスタイルを当てていきます。
ReactではclassNameにクラスを設定することが可能です。

        <h1 className="app-title">MOVIEFLIX</h1>
.app-title {
  color: #e50914;
  font-size: 30px;
  font-weight: 900;
  margin: 0;
}

これでいい感じにヘッダーができました

image.png

この調子ですべてのスタイリングを行ってください
HTMLの構造などは変わっているところもありますが機能面については同じです。

App.tsx
import { useEffect, useState } from "react";
import "./App.css";

type Movie = {
  id: number;
  original_title: string;
  poster_path: string;
  overview: string;
};

type MovieJson = {
  adult: boolean;
  backdrop_path: string;
  genre_ids: number[];
  id: number;
  original_language: string;
  original_title: string;
  overview: string;
  popularity: number;
  poster_path: string;
  release_date: string;
  title: string;
  video: boolean;
  vote_average: number;
  vote_count: number;
};

function App() {
  const fetchMovieList = async () => {
    const API_KEY = import.meta.env.VITE_TMDB_API_KEY;
    let url = "";
    if (keyword) {
      url = `https://api.themoviedb.org/3/search/movie?query=${encodeURIComponent(
        keyword
      )}&include_adult=false&language=ja&page=1`;
    } else {
      url = "https://api.themoviedb.org/3/movie/popular?language=ja&page=1";
    }
    const response = await fetch(url, {
      headers: {
        Authorization: `Bearer ${API_KEY}`,
      },
    });
    const data = await response.json();
    const result = data.results;
    const movieList = result.map((movie: MovieJson) => ({
      id: movie.id,
      original_title: movie.title,
      poster_path: movie.poster_path,
    }));
    setMovieList(movieList);
  };

  const [keyword, setKeyword] = useState("");
  const [movieList, setMovieList] = useState<Movie[]>([]);

  useEffect(() => {
    fetchMovieList();
  }, [keyword]);

  // HeroSection用のダミーデータ(君の名は)
  const heroTitle = "君の名は";
  const heroYear = 2016;
  const heroOverview =
    "1ヵ月後に1000年ぶりの彗星が訪れる日本。東京で暮らす平凡な男子高校生・瀧と、山深い村で都会の生活に憧れながら憂鬱な日々を送る女子高校生・三葉。つながりのない2人は、互いが入れ替わる不思議な夢を見る。";
  const heroImage =
    "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/yLglTwyFOUZt5fNKm0PWL1PK5gm.jpg";

  return (
    <div>
      <section className="hero-section">
        {heroImage && (
          <>
            <img className="hero-section-bg" src={heroImage} alt={heroTitle} />
            <div className="hero-section-gradient" />
          </>
        )}
        <div className="hero-section-content">
          <h1 className="hero-section-title">{heroTitle}</h1>
          <div className="hero-section-badges">
            <span className="hero-section-badge">{heroYear}</span>
          </div>
          {heroOverview && (
            <div className="hero-section-overview">{heroOverview}</div>
          )}
          <div className="hero-section-actions">
            <button className="hero-section-btn hero-section-btn-primary">
              ▶ Play
            </button>
            <button className="hero-section-btn hero-section-btn-secondary">
              More Info
            </button>
          </div>
        </div>
      </section>
      <section className="movie-row-section">
        <h2 className="movie-row-title">
          {keyword ? `「${keyword}」の検索結果` : "人気映画"}
        </h2>
        <div className="movie-row-scroll">
          {movieList.map((movie) => (
            <a
              key={movie.id}
              href={`/movies/${movie.id}`}
              className="movie-card"
            >
              <div className="movie-card__imgwrap">
                <img
                  src={
                    movie.poster_path
                      ? `https://image.tmdb.org/t/p/w300_and_h450_bestv2${movie.poster_path}`
                      : "https://via.placeholder.com/300x450?text=No+Image"
                  }
                  alt={movie.original_title}
                  className="movie-card__image"
                />
                <div className="movie-card__overlay">
                  <h3 className="movie-card__title">{movie.original_title}</h3>
                </div>
              </div>
            </a>
          ))}
        </div>
      </section>
      <div className="app-search-wrap">
        <input
          type="text"
          className="app-search"
          placeholder="映画タイトルで検索..."
          value={keyword}
          onChange={(e) => setKeyword(e.target.value)}
        />
      </div>
    </div>
  );
}

export default App;

映画は最初人気の映画20本が表示されていますが、検索をすると検索結果が表示されるのでタイトル部分はkeywordがあるかないかで変えるようにしました

        <h2 className="movie-row-title">
          {keyword ? `「${keyword}」の検索結果` : "人気映画"}
        </h2>

?演算子はkeywordがある(つまりtrue)のとき、左を評価して、空文字(つまりfalse)のとき右を評価します。このようにするとkeywordで画面の表示を切り替えられます。&&と?は覚えておきましょう

またもう一つみなれないタグが出てきました

        {heroImage && (
          <>
            <img className="hero-section-bg" src={heroImage} alt={heroTitle} />
            <div className="hero-section-gradient" />
          </>
        )}

これはフラグメントとよばれるグループ化をするタグのようなものです。

Reactでは条件式の結果として複数の要素を返す場合、それらは必ず単一の親要素でラップする必要があります。しかし、余分な<div>などを追加すると、DOM構造が不必要に複雑になりCSSにも影響します。このようなときに意味を持たないけどまとめることができるフラグメントというものを利用できます。

詳細ページのスタイリングも行いましょう

src/MovieDetail.css
.movie-detail-root {
  min-height: 100vh;
  background: #111;
  color: #fff;
  position: relative;
}
.movie-detail-backdrop {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 70vh;
  z-index: 0;
  background-size: cover;
  background-position: center;
  opacity: 0.4;
}
.movie-detail-backdrop-gradient {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 70vh;
  z-index: 1;
  background: linear-gradient(to top, #111 60%, transparent 100%);
}
.movie-detail-container {
  position: relative;
  z-index: 2;
  width: 100vw;
  max-width: 100vw;
  margin: 0;
  padding: 48px 16px 0 16px;
  box-sizing: border-box;
  padding: 0 100px;
}
.movie-detail-backlink {
  display: inline-flex;
  align-items: center;
  color: #fff8;
  margin-bottom: 24px;
  text-decoration: none;
  transition: color 0.2s;
}
.movie-detail-backlink:hover {
  color: #fff;
}
.movie-detail-backlink-icon {
  margin-right: 8px;
}
.movie-detail-grid {
  display: grid;
  grid-template-columns: 300px 1fr;
  gap: 40px;
  align-items: start;
}
.movie-detail-poster-wrap {
  position: relative;
  aspect-ratio: 2/3;
  width: 300px;
  border-radius: 12px;
  overflow: hidden;
  box-shadow: 0 8px 32px #000a;
}
.movie-detail-poster-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.movie-detail-loading {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}

.movie-detail-info {
  display: flex;
  flex-direction: column;
  gap: 24px;
}

.movie-detail-details {
  display: flex;
  flex-direction: column;
  gap: 24px;
}

.movie-detail-overview {
  color: #ccc;
  line-height: 1.7;
}

.movie-detail-section {
  border-top: 1px solid #222;
  padding-top: 24px;
  margin-top: 24px;
}

.movie-detail-section-title {
  font-size: 18px;
  font-weight: 600;
  margin-bottom: 8px;
}

.movie-detail-cast-list {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.movie-detail-badges {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 12px;
}
.badge-icon-svg {
  margin-right: 2px;
  vertical-align: middle;
}
.badge-star {
  color: #ffb400;
  fill: #ffb400;
}
.movie-detail-overview {
  color: #ccc;
  line-height: 1.7;
}
.movie-detail-genres {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.movie-detail-actions {
  display: flex;
  gap: 16px;
  padding-top: 8px;
}
.movie-detail-btn {
  font-size: 16px;
  border-radius: 6px;
  padding: 10px 22px;
  font-weight: 600;
  display: flex;
  align-items: center;
  cursor: pointer;
  border: none;
  background: #444;
  color: #fff;
  transition: background 0.2s, color 0.2s;
}
.movie-detail-btn-primary {
  background: #e11d48;
  color: #fff;
}
.movie-detail-btn:hover {
  background: #666;
  color: #fff;
}
.movie-detail-btn-primary:hover {
  background: #be123c;
  color: #fff;
}
.movie-detail-btn-icon {
  margin-right: 8px;
  vertical-align: middle;
}

.badge-outline {
  display: inline-block;
  border: 1px solid #444;
  border-radius: 6px;
  padding: 2px 10px;
  font-size: 13px;
  color: #fff;
  background: transparent;
}
.badge-genre {
  display: inline-block;
  background: #222;
  border-radius: 6px;
  padding: 2px 10px;
  font-size: 13px;
  color: #fff;
}
.movie-detail-btn {
  font-size: 16px;
  border-radius: 6px;
  padding: 10px 22px;
  font-weight: 600;
  display: flex;
  align-items: center;
  cursor: pointer;
  border: none;
  background: #444;
  color: #fff;
  transition: background 0.2s, color 0.2s;
}
.movie-detail-btn-primary {
  background: #e11d48;
  color: #fff;
}
.movie-detail-btn:hover {
  background: #666;
  color: #fff;
}
.movie-detail-btn-primary:hover {
  background: #be123c;
  color: #fff;
}

.movie-detail-actions {
  display: flex;
  gap: 16px;
  padding-top: 8px;
}

.movie-detail-btn-icon {
  margin-right: 8px;
  vertical-align: middle;
}
.movie-detail-rating {
  background: #292929;
  color: #ffb400;
  font-weight: 600;
}
.movie-detail-score {
  background: none;
  color: #ffb400;
  font-weight: 700;
  font-size: 1.1em;
  padding: 0;
}

.movie-detail-genres {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
  margin-bottom: 8px;
}
.movie-detail-genre {
  background: #232323;
  color: #eee;
  border-radius: 8px;
  padding: 4px 14px;
  font-size: 0.99rem;
  margin-bottom: 2px;
}

.movie-detail-overview {
  font-size: 1.13rem;
  line-height: 1.7;
  color: #e0e0e0;
  margin-bottom: 18px;
}

.movie-detail-actions {
  display: flex;
  gap: 12px;
  margin-bottom: 18px;
}
.movie-detail-btn {
  background: #232323;
  color: #fff;
  border: none;
  border-radius: 8px;
  padding: 11px 28px;
  font-size: 1.07rem;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.16s, color 0.16s, box-shadow 0.18s;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
}
.movie-detail-btn-primary {
  background: #e50914;
  color: #fff;
  box-shadow: 0 2px 12px rgba(229, 9, 20, 0.22);
}
.movie-detail-btn:hover {
  background: #333;
}
.movie-detail-btn-primary:hover {
  background: #b0060f;
}

.movie-detail-cast-block {
  margin-bottom: 6px;
}
.movie-detail-cast-label {
  color: #bbb;
  font-size: 1.02rem;
  margin-bottom: 4px;
}
.movie-detail-cast-list {
  display: flex;
  flex-wrap: wrap;
  gap: 7px;
}
.movie-detail-cast-tag {
  background: #222;
  color: #fff;
  border-radius: 7px;
  padding: 3px 12px;
  font-size: 0.97rem;
}

.movie-detail-director-block {
  margin-bottom: 2px;
}
.movie-detail-director-label {
  color: #bbb;
  font-size: 1.02rem;
  margin-bottom: 3px;
}
.movie-detail-director-name {
  color: #fff;
  font-size: 1.09rem;
}

@media (max-width: 900px) {
  .movie-detail-main {
    flex-direction: column;
    align-items: center;
    padding: 0 0 36px 0;
    max-width: 97vw;
  }
  .movie-detail-poster-col {
    padding: 28px 0 12px 0;
    min-width: 0;
    width: 100%;
  }
  .movie-detail-info-col {
    padding: 22px 12px 32px 12px;
    gap: 12px;
  }
  .movie-detail-poster {
    width: 80vw;
    max-width: 340px;
    height: auto;
  }
}
MovieDetail.tsx
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router";
import "./MovieDetail.css";
import { ArrowLeft, Clock, Star } from "lucide-react";

type MovieDetailJson = {
  adult: boolean;
  backdrop_path: string | null;
  belongs_to_collection: null;
  budget: number;
  genres: { id: number; name: string }[];
  homepage: string;
  id: string;
  imdb_id: string;
  origin_country: string[];
  original_language: string;
  original_title: string;
  overview: string;
  popularity: number;
  poster_path: string;
  production_companies: {
    id: number;
    logo_path: string | null;
    name: string;
    origin_country: string;
  }[];
  production_countries: {
    iso_3166_1: string;
    name: string;
  }[];
  release_date: string;
  revenue: number;
  runtime: number;
  spoken_languages: {
    english_name: string;
    iso_639_1: string;
    name: string;
  }[];
  status: string;
  tagline: string;
  title: string;
  video: boolean;
  vote_average: number;
  vote_count: number;
};

type Movie = {
  id: string;
  original_title: string;
  overview: string;
  poster_path: string;
  year: number;
  rating: number;
  runtime: number;
  score: number;
  genres: string[];
};

function MovieDetail() {
  const { id } = useParams();
  const [movie, setMovie] = useState<Movie | null>(null);

  const fetchMovieDetail = async () => {
    const response = await fetch(
      `https://api.themoviedb.org/3/movie/${id}?language=ja`,
      {
        headers: {
          Authorization: `Bearer ${import.meta.env.VITE_TMDB_API_KEY}`,
        },
      }
    );

    const data = (await response.json()) as MovieDetailJson;
    setMovie({
      id: data.id,
      original_title: data.original_title,
      overview: data.overview,
      poster_path: data.poster_path,
      year: Number(data.release_date.split("-")[0]),
      rating: data.vote_average,
      runtime: data.runtime,
      score: data.vote_count,
      genres: data.genres.map(
        (genre: { id: number; name: string }) => genre.name
      ),
    });
  };

  useEffect(() => {
    fetchMovieDetail();
  }, []);

  return (
    <div className="movie-detail-root">
      {movie && (
        <>
          <div
            className="movie-detail-backdrop"
            style={{
              backgroundImage: `url(${
                "https://image.tmdb.org/t/p/w500" + movie.poster_path
              })`,
            }}
          />
          <div className="movie-detail-backdrop-gradient" />
          <div className="movie-detail-container">
            <Link to="/" className="movie-detail-backlink">
              <ArrowLeft className="movie-detail-backlink-icon" size={20} />
              Back to home
            </Link>
            <div className="movie-detail-grid">
              <div className="movie-detail-poster-wrap">
                <img
                  src={"https://image.tmdb.org/t/p/w500" + movie.poster_path}
                  alt={movie.original_title}
                  className="movie-detail-poster-img"
                />
              </div>
              <div className="movie-detail-details">
                <h1 className="movie-detail-title">{movie.original_title}</h1>
                <div className="movie-detail-badges">
                  <span className="badge-outline">{movie.year}</span>
                  <span className="badge-outline">PG-13</span>
                  <span className="badge-outline">
                    <Clock className="badge-icon-svg" size={14} />
                    {movie.runtime}</span>
                  <span className="badge-outline">
                    <Star className="badge-icon-svg badge-star" size={14} />
                    {(movie.rating / 10).toFixed(1)}
                  </span>
                </div>
                <p className="movie-detail-overview">{movie.overview}</p>
                <div className="movie-detail-genres">
                  {movie.genres.map((g) => (
                    <span key={g} className="badge-genre">
                      {g}
                    </span>
                  ))}
                </div>
                <div className="movie-detail-actions">
                  <button className="movie-detail-btn movie-detail-btn-primary">
                    ▶ Watch Now
                  </button>
                  <button className="movie-detail-btn">
                    + Add to My List
                  </button>
                </div>
              </div>
            </div>
          </div>
        </>
      )}
    </div>
  );
}

export default MovieDetail;

MovieDetail.tsxではlucide-reactを利用してアイコンを表示しています。

import { ArrowLeft, Clock, Play, Plus, Star } from "lucide-react";

<ArrowLeft className="movie-detail-backlink-icon" size={20} />

また戻るボタンを作りましたがaタグでなく<Link>タグを使っています。
aタグを使うとページ全体が再読込されてしまいヘッダーなど共通部分まで再計算されてしまいパフォーマンスが悪いので、Linkを利用すると必要な部分だけをレンダリングするので最適化されます

          <div
            className="movie-detail-backdrop"
            style={{
              backgroundImage: `url(${
                "https://image.tmdb.org/t/p/w500" + movie.poster_path
              })`,
            }}
          />

今回始めてstyleという属性を使いました。styleを使うことでCSSを直接書くことができます。
CSSではポスター画像を背景に設定するためmovie.poster_pathを使う必要があります。
MovieDetail.cssでは変数をみることができないので、styleを使って直接スタイリングをしています。Styleはカーリーブレスを二重で使うので覚えておきましょう

                  <span className="badge-outline">
                    <Star className="badge-icon-svg badge-star" size={14} />
                    {(movie.rating / 10).toFixed(1)}
                  </span>

レーティングの部分はmovie.ratingを表示すると6.484となってしまうので、

(6.484 / 10) = 0.6484
0.6484.toFixed(1) = "0.6"

として0.6を表示するようにしました。
実際に画面を確認するといい感じになっているはずです。

image.png

image.png

10. リファクタリング

このチャプターで学べること

  • コンポーネント化
  • Props

最後にトップページの映画をコンポーネント化してみます。

touch src/MovieCard.tsx
src/MovieCard.tsx
import { Link } from "react-router";
import "./App.css";

type Movie = {
  id: string;
  poster_path: string;
  original_title: string;
};

type Props = {
  movie: Movie;
};

const MovieCard = (props: Props) => {
  const { movie } = props;

  return (
    <Link to={`/movies/${movie.id}`} key={movie.id} className="movie-card">
      <div className="movie-card__imgwrap">
        <img
          src={`https://image.tmdb.org/t/p/w300_and_h450_bestv2${movie.poster_path}`}
          alt={movie.original_title}
          className="movie-card__image"
        />
        <div className="movie-card__overlay">
          <h3 className="movie-card__title">{movie.original_title}</h3>
        </div>
      </div>
    </Link>
  );
};

export default MovieCard;

App.tsxのmapしていた部分をコンポーネントにしただけですが、このコンポーネントはMovieを受けとっています
(つまりid, postaer_path,original_titleのオブジェクトを受けっています)

このコンポーネントの外から値を受け取って利用するのをPropsとよびます。

image.png

App.tsxで作成したコンポーネントを利用しましょう

src/App.tsx
import { useEffect, useState } from "react";
import MovieCard from "./MovieCard";
import "./App.css";

type Movie = {
  id: number;
  original_title: string;
  poster_path: string;
  overview: string;
};

type MovieJson = {
  adult: boolean;
  backdrop_path: string;
  genre_ids: number[];
  id: number;
  original_language: string;
  original_title: string;
  overview: string;
  popularity: number;
  poster_path: string;
  release_date: string;
  title: string;
  video: boolean;
  vote_average: number;
  vote_count: number;
};

function App() {
  const fetchMovieList = async () => {
    const API_KEY = import.meta.env.VITE_TMDB_API_KEY;
    let url = "";
    if (keyword) {
      url = `https://api.themoviedb.org/3/search/movie?query=${encodeURIComponent(
        keyword
      )}&include_adult=false&language=ja&page=1`;
    } else {
      url = "https://api.themoviedb.org/3/movie/popular?language=ja&page=1";
    }
    const response = await fetch(url, {
      headers: {
        Authorization: `Bearer ${API_KEY}`,
      },
    });
    const data = await response.json();
    const result = data.results;
    const movieList = result.map((movie: MovieJson) => ({
      id: movie.id,
      original_title: movie.title,
      poster_path: movie.poster_path,
    }));
    setMovieList(movieList);
  };

  const [keyword, setKeyword] = useState("");
  const [movieList, setMovieList] = useState<Movie[]>([]);

  useEffect(() => {
    fetchMovieList();
  }, [keyword]);

  // HeroSection用のダミーデータ(君の名は)
  const heroTitle = "君の名は";
  const heroYear = 2016;
  const heroOverview =
    "1ヵ月後に1000年ぶりの彗星が訪れる日本。東京で暮らす平凡な男子高校生・瀧と、山深い村で都会の生活に憧れながら憂鬱な日々を送る女子高校生・三葉。つながりのない2人は、互いが入れ替わる不思議な夢を見る。";
  const heroImage =
    "https://media.themoviedb.org/t/p/w300_and_h450_bestv2/yLglTwyFOUZt5fNKm0PWL1PK5gm.jpg";

  return (
    <div>
      <section className="hero-section">
        {heroImage && (
          <>
            <img className="hero-section-bg" src={heroImage} alt={heroTitle} />
            <div className="hero-section-gradient" />
          </>
        )}
        <div className="hero-section-content">
          <h1 className="hero-section-title">{heroTitle}</h1>
          <div className="hero-section-badges">
            <span className="hero-section-badge">{heroYear}</span>
          </div>
          {heroOverview && (
            <div className="hero-section-overview">{heroOverview}</div>
          )}
          <div className="hero-section-actions">
            <button className="hero-section-btn hero-section-btn-primary">
              ▶ Play
            </button>
            <button className="hero-section-btn hero-section-btn-secondary">
              More Info
            </button>
          </div>
        </div>
      </section>
      <section className="movie-row-section">
        <h2 className="movie-row-title">
          {keyword ? `「${keyword}」の検索結果` : "人気映画"}
        </h2>
        <div className="movie-row-scroll">
          {movieList.map((movie) => (
           {/* 修正 */}
            <MovieCard key={movie.id} movie={movie} />
          ))}
        </div>
      </section>
      <div className="app-search-wrap">
        <input
          type="text"
          className="app-search"
          placeholder="映画タイトルで検索..."
          value={keyword}
          onChange={(e) => setKeyword(e.target.value)}
        />
      </div>
    </div>
  );
}

export default App;

以下のようにコンポーネントに置き換えました

          {movieList.map((movie) => (
            <MovieCard key={movie.id} movie={movie} />
          ))}

ちなみにimgタグのsrcやaltなどもPropsの一部と言えます。
こうすることでMovieCardコンポーネントを他でも簡単に利用することができます。

ステップアップ課題

  1. typeを別ファイルにまとめてください
  2. Playボタンを押したら「未実装です」とアラートを出してください
  3. Firebaseを利用してデプロイをしてスマホからでもサイトにアクセスできるようにしてください

おわりに

いかがでしたでしょうか?
今回はReactの基本を解説しながらJavaScriptの難しい非同期なども含めて理解できました。
この内容だけでReactで頻繁に使うものをほとんど抑えられているはずです。

詳しく解説した動画を投稿しているのでよかったらみてみてください!

JISOUのメンバー募集中!

プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページからお気軽にカウンセリングをお申し込みください!
▼▼▼

図解ハンズオンたくさん投稿しています!

本チュートリアルのレビュアーの皆様

  • risa様
  • tokec様
  • k-kaijima様
  • 山本様
  • banana様
  • 河野様
    次回のハンズオンのレビュアーはXにて募集します。
146
175
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
146
175

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?