こんにちは、Watanabe jinです。
ReactがAIも相まって選択されるケースが増えて学びたいと思っている人も多いかと思います。
私も過去にたくさんのReact教材をやりましたが、本当にやってよかったと思える教材はほぼありませんでした。
最初に選ぶ教材によってはReactの学習スピードは圧倒的に変わりますし、Reactの教材はJavaScriptの非同期処理やmapなどを踏まえて説明する必要があります。
このチュートリアルはそんな駆け出しエンジニアだった私に「これ1本だけやっておけば基礎は終わり」と言えるような完全版を目指して作成しました。JavaScriptがなんとなくという方でも最後まで絶対走りきれるように丁寧に解説しています。
最後までチュートリアルを行うと映画アプリが完成します。
こちらの教材にはより詳しく解説した動画もセットでご用意しています。
テキスト教材でわからない細かい箇所があれば動画も活用ください
- Reactを初めてやる
- JavaScriptが不安
- フロントエンド開発に興味がある
- 個人開発をしてみたい
- アプリを作りながら学びたい
このチュートリアルはHTMLとCSSの経験があれば行うことが可能です
このチャプターで学べること
- React開発環境の基本設定
- Reactの基本的な動作原理
- 開発効率を高める仕組み
Reactの環境構築について解説していきます。
ここで学んでほしいのは手順通りに環境構築をして再現性をもってReactを始められるようにすることです。
まずはNode.js
の環境を用意しましょう。
Node.jsとはJavaScriptを実行するための開発環境です。JavaScriptをウェブブラウザだけでなく、パソコン上で直接動かせるようにするプログラムのことです。
ReactはJavaScriptのライブラリなので、JavaScriptを実行できる環境が必要です。
インストールは以下のサイトから行えます。
もしわからない方がいたらQiitaでインストール方法を調べるとたくさん記事が出てきますので、ここでは省略します。
インストールが終わったらインストールできているかを確認しましょう
ここでエラーがでていなければNode.jsが正しくインストールされて使える状態です。
次にVite
を使ってReact環境を構築します。
Viteは最新のフロントエンド開発ツールで、特に高速な開発環境を提供するビルドツールです。Viteを使ってReactを開発する理由は大きく2つあります。
1. 生のReactコードは直接実行できない
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:
[email protected]
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を開くと以下の画面が表示されれば起動できています。
npmというコマンドがでてきたので解説しておきます。
このコマンドはプロジェクトに必要なすべてのパッケージ(部品)をインターネットからダウンロードしてインストールします。パッケージのリストはディレクトリにある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"
}
}
ここのdepencies
とdevDepencies
にかかれているものをダウンロードして、node_modules
というディレクトリにいれています。試しにnode_modulesをみると以下のようにたくさんのディレクトリがあります。
このようにReact開発に必要なものを事前に定義してダウンロードできるので、開発に必要な部品をわざわざ1つ1つダウンロードして環境を作る必要がなく誰でも同じ環境がinstallコマンドで行えます。
このコマンドは、開発用のローカルサーバーを起動して、アプリケーションを実行します。
またサーバーが起動するとコードの変更を監視し、変更があれば即座に反映するホットリロードが行えます。これはViteで起動しているメリットです。
試しにコードを変更してみましょう。(ここではよくわからなくても大丈夫です)
src/App.tsx
をいかに変更してください
src/App.tsx
import './App.css'
function App() {
return (
div>
h1>Hello Worldh1>
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)
そして画面をみるとコード変更して保存した瞬間に反映されています。
この速さがViteでReactを開発するメリットなのです!
このチャプターで学べること
- JSXの書き方の理解
- JavaScriptのmapの考え方
- カーリーブレス
ここから本格的にReact開発をしていきます。
このチャプターではダミーの映画データを画面に表示できるようにします。
まずはsrc/App.tsx
を開きましょう。
App.tsx
import './App.css'
function App() {
return (
div>
h1>Hello Worldh1>
div>
)
}
export default App
tsx
は以下の構成で書くことができます。
function App() {
// JavaScriptのコードを書く
return (
/* HTMLで画面の見た目部分を書く */
)
}
export default App
ざっくりこれくらいの認識で進めて行けば問題はありません。
例えば先程の修正はHTMLでHello World
を表示するためにreturnの中に書いたものでした
return (
div>
h1>Hello Worldh1>
div>
)
では試しにJavaScriptのコードを書いてみます。アラートを実装してみましょう
src/App.tsx
import './App.css'
function App() {
alert('JavaScriptを実行')
return (
div>
h1>Hello Worldh1>
div>
)
}
export default App
http://localhost:5173を開くとアラートが表示されました
JavaScriptが内部で実行されたあとに、画面表示の処理が行われます。
このコードはApp.tsxと同じ階層にあるApp.css
を使えるようにする設定です。(CSSを設定するファイルです)
試しに以下の内容にしてみましょう
アラートは邪魔なので消しておきます。
src/App.tsx
import './App.css'
function App() {
return (
div>
h1>Hello Worldh1>
div>
)
}
export default App
画面を開くとh1要素が赤文字になりました。しっかりCSSファイルが読み込まれていることがわかります。
※ CSSが適応されているのを確認したらCSSをもとに戻してこの先を進めてください
それでは次にダミーの映画情報をオブジェクト(データをまとめる箱のようなもの)形式でまとめた配列を用意しましょう
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 Worldh1>
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
表示はできましたが、同じようなコードを何度も書くのはスマートではありません
そこで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から
...
というHTMLを生成
生成されたHTMLの要素を全て集めて表示する
こうすることで繰り返しのコードをスマートに書くことができます。
コードの中には{}
がでてきました。カーリーブレスと呼びます。
HTMLの中でJavaScriptの値を利用する際には必要です。
deafultMovieListはJavaScriptの変数なので利用するために{}
を使っています。
{defaultMovieList.map((movie) => (
(省略)
}
最後にkey
という見慣れないワードが出てきました
このKeyは初心者の段階では深く理解する必要はないので、「mapを利用したときの外側のタグに一意な値(IDなど)をkeyとして設定しておく」くらいの理解で大丈夫です。
このチャプターで学べること
- 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
文字を入力することができます。ここまではHTMLの基本的な話です。
ここからはReactの機能を使って検索機能の実装をしますがまずは図解で流れを理解しましょう
- ユーザーはインプットフォームに「君の名は」と入力します
- 入力された値はkeywordという変数に保存されます
- 表示する映画はdefaultMovieListの中でキーワードをタイトルに含むものだけにします
「君」であれば「君の名は」と「君に届け」が残りますが、「君の名は」は「君の名は」1つだけが残ります。
このフィルタリングした映画のリストをmovieListという変数に保存しておきます
- 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;
キーワードという変数を用意します。
入力した値をkeywordに代入して保存しておきましょう
input type="text" onChange={(e) => (keyword = e.target.value)} />
div>{keyword}div>
inputにはonChange
というイベントハンドラが設定されています。
イベントハンドラはユーザーの操作をきっかけに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が保存できているかを確認するために表示もしてみます
それでは画面を表示してキーワードを入力してみましょう
入力した値が画面に表示されそうですが、なぜか表示されません。
実はこれ動かないコードの例です。
Reactで値を保存しておきたいときにはuseStateというHooksを利用します。
HooksとはReactの機能を簡単に利用するための仕組みです。
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を更新する関数を使って更新するようにしました。
実際に画面を見てみましょう
キーワードを入力するたびに画面に表示されるようになりました
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;
画面をみて実際に検索をしてみると映画がフィルタリングされます。
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を利用してデータを取得していきます。
このチャプターで学べること
- 非同期処理の仕組み
- useEffect
- APIの利用の仕方
今回は映画データの取得にTMDB APIを利用します。
アカウント登録は各自でしていただき、登録が終わった状態から解説します。
右上のアイコンから「設定」をクリック
左メニューの「API」をクリックしてAPIキーを要求するの「click here」をクリック
個人利用化を聞かれるので「Yes」をクリック
Is the intended use of our API for personal use?
フォームを埋めていきます。
アプリケーションURLにはダミーのものを入れて、アプリケーション概要に書いておきましょう
個人情報をいれたら、チェックボックスにチェックをいれて「Subscribe」をクリックします
「APIリードアクセストークン」をメモしておきます。
このトークンを利用することでAPIを利用することが可能です。
次に環境変数でアクセストークンを設定します。
環境変数とは、アプリケーションの設定値をコードの外部で管理する仕組みです。APIキーやデータベースの接続情報など、環境によって変わる可能性のある値や、セキュリティ上公開したくない情報を管理するのに使われます。
環境変数の設定には.env
ファイルが利用できます。
.env
VITE_TMDB_API_KEY=あなたのアクセストークン
Viteでは.envで設定した環境変数を読み込むことができます。
const apiKey = import.meta.env.VITE_TMDB_API_KEY;
それでは実際に人気映画を10件取得するAPIを叩いてみます。
今回叩くAPIのドキュメントをみてみましょう
右側の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 () => {
(省略)
};
ここで初心者がつまづきやすい非同期処理について詳しく解説していきます。
まずは「同期処理」と「非同期処理」の違いから理解していきましょう。
カレーとサラダを作ることを想像してください
同期処理は順番に作業をする方法です。カレーを作り終わってからサラダを作成します。
つまりカレーを煮込んでいる間はずっと鍋を見つめているのが「同期処理」です。
それに対しておそらく皆さんがやっているのは「非同期処理」です。
鍋を煮込んでいる間にサラダを作ることで時間の節約をしています。
これをよりプログラミング的に説明すると、自分が作業する主要作業を「メインスレッド」と呼びます。
同期処理ではメインスレッドのみですべての作業を行うため直列的になってしまいます。
しかし、煮込みの時間は別の作業をしたいと考えるので煮込みの作業になったタイミングでメインスレッドから優先度を落として裏側で並列して煮込みを行いつつ、メインスレッドではサラダを作るということをします。
今回の映画APIを叩いてデータ取得するのにも時間がかかるので非同期にしてあげることで、メインスレッドではユーザーはアプリを操作することができます。
同期処理にしてしまうと映画が取得できるまでアプリを操作できなくなります。
今回はすぐにデータが取得できるので困ることは少ないですが、もしデータ取得に長い時間がかかる場合ユーザー体験が悪くなってしまいます。
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」に取得したデータが表示されます。
データがちゃんと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) => (
それでは画面を見てみましょう
画面は真っ白でデベロッパーツールには以下のエラーが出ています。
Uncaught TypeError: Cannot read properties of undefined (reading 'includes')
at App.tsx:31:39
at Array.filter ()
at App (App.tsx:31:10)
このエラーは
.filter((movie) => movie.name.includes(keyword)
movieにnameという属性がなく(つまりnull)、ないものに対してincludesという関数を呼び出そうとしているのでエラーになっています。
ここで重要になってくるのがいまmovieListにはどんな値が入っているのかです。
先程の取得したデータをみてみると、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}
/>
いい感じに表示ができています。
このチャプターで学べること
- 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をいれたことでエディタがエラーになりそうな箇所を教えてくれています)
エラーを意訳すると、「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] = useStateMovie[]>([]); // 修正
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] = useStateMovie[]>([]);
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から呼び出せるものになっています。
こうすることで先程の私たちのようにmovie.name
を利用してアプリケーション全体をクラッシュさせることを画面を見なくても事前に防ぐことができるようになります。
TypeScriptを使うことで開発効率が上がり、より堅牢なアプリケーションを作れるようになるので積極的に利用していきましょう。
このチャプターで学べること
- useEffectの依存配列について
- キーワード検索の方法
現在は初期表示した映画のリストからの検索にしか対応していませんが、世の中には様々な映画があります。
手元の映画でなく、映画APIが持っているデータの中から検索ができるように変更をしていきます。
ここで利用できるのがuseEffect
の依存配列です。
先程解説を飛ばしてしまいましたので、ここで詳しく仕組みを解説していきます。
依存配列にステート(状態)を設定しておくと、ステートの値が(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] = useStateMovie[]>([]);
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を使って検索します。
「君」と調べると知らない映画ですが確かにタイトルに「君」が入る映画が表示されるようになりました
「君の」までいれると「君の名は」がでてきました。
このチャプターで学べること
- react-routerを利用したルーティング
- コンポーネントについて
- URLからパラメータを取得する方法
ここからはそれぞれの映画をクリックしたら映画の詳細を表示するページに遷移できるようにreact-router
を設定していきます。
react-routerはルーティングライブラリで画面を遷移して別ページを作りたいときに必要なライブラリです。
それではライブラリをインストールしてみましょう。npmコマンドを使えば簡単に外部ライブラリをインストールすることができます。
それではルーティングの設定をしていきます。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.tsxでAppコンポーネントを実装していました。
コンポーネントとはUIを構成するための部品だと思ってください。
この部品をレゴブロックのように組み合わせて1つの画面を作ることができます。
コンポーネントには関数コンポーネントとクラスコンポーネントがあります。
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コンポーネントの内容が表示されるようになりました
話を戻してルーティングに関する設定を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)をいれました
しっかり画面が表示できたのでルーティングは問題なくできていそうです。
このあとこのページでは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;
これと同じことを一行で行うことができます。
画面をみてみましょう
movieIdであるhogeが表示されているのでURLから取得できていることがわかります。
このチャプターで学べること
- 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] = useStateMovie | 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] = useStateMovie | null>(null);
movieをuseStateで定義します。
movieはmovieIdから検索されるのでhogeなど適当な値をいれたら映画は見つかりません。
このときはmovieはnullになるので型では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),
});
};
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ではこのように&&を使って表示しないとエディタでエラーになります。
これはTypeScript導入で説明した話と同じようなエラーです。
movieにはnullが初期値として入っており、movieIdで映画が見つからなかった場合nullのままです。
nullに対して.name
をしてしまうとnullにはnameという属性がないため画面がクラッシュしてしまいます。
これをエディタではエラーとして表示してくれています。
なのでmovieがnullでなければ表示することで絶対にname
にアクセスできるよう&&
を利用しました。
実際に映画の詳細が画面に表示されるかをチェックします。TMDBで映画詳細ページを開きます。
URLの372058
が映画のIDとなります。
「君の名は」のIDがわかったので、http://localhost:5173/movies/372058にアクセスしてみましょう
「君の名は」の映画の詳細な情報が取得できました。
ここまでで一通り必要な機能ができたのでページに共通のデザインとしてヘッダー部分を実装します。
ヘッダーはコンポーネントを作成して共通レイアウトとしてどのページでも表示されるようにしましょう
まずはコンポーネントを作成します
src/Header.tsx
function Header({ children }: { children: React.ReactNode }) {
return (
div>
header>
h1>MOVIEFLIXh1>
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>
);
MOVIEFLIXというヘッダーがそれぞれのページで表示されるようになりました
それではchildren
について解説していきます。
Reactにおける children は、コンポーネントのタグで囲まれた内容を指す特別なプロパティです。簡単に言うと、「コンポーネントの中身」のことです。
Header>ここに書いた内容がHeaderコンポーネントの「children」になりますHeader>
Headerコンポーネントではこの中身の部分を受け取って表示しています。
function Header({ children }: { children: React.ReactNode }) {
return (
div>
header>
h1>MOVIEFLIXh1>
header>
main>{children}main>
div>
);
}
つまりmain.tsx
の
をchildrenとして受けとっています。
この仕組みによって常にRouterProviderの下で表示されるコンポーネントにはヘッダーがつくようになります。
このチャプターで学べること
- CSSでのスタイリング
- lucide-reactを使ったアイコン表示
- ?演算子
- Linkタグ
ここまでで一通り機能ができたので最後はデザイン部分を行っていきます。
今回はCSSを用意したので、そちらをコピペしてクラスを付与してスタイルを当てていきます。CSSについての解説は割愛しております。
今回画面にアイコンを一部利用しておりますので、以下のライブラリを入れます。
必要なファイルも一通り作成しましょう
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">MOVIEFLIXh1>
header>
main>{children}main>
div>
);
}
export default Header;
クラスを付与することでスタイルを当てていきます。
ReactではclassName
にクラスを設定することが可能です。
h1 className="app-title">MOVIEFLIXh1>
.app-title {
color: #e50914;
font-size: 30px;
font-weight: 900;
margin: 0;
}
これでいい感じにヘッダーができました
この調子ですべてのスタイリングを行ってください
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] = useStateMovie[]>([]);
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では条件式の結果として複数の要素を返す場合、それらは必ず単一の親要素でラップする必要があります。しかし、余分な
詳細ページのスタイリングも行いましょう
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] = useStateMovie | 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="https://qiita.com/" 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-13span>
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タグでなくタグを使っています。
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を表示するようにしました。
実際に画面を確認するといい感じになっているはずです。
このチャプターで学べること
- コンポーネント化
- Props
最後にトップページの映画をコンポーネント化してみます。
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とよびます。
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] = useStateMovie[]>([]);
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コンポーネントを他でも簡単に利用することができます。
- typeを別ファイルにまとめてください
- Playボタンを押したら「未実装です」とアラートを出してください
- Firebaseを利用してデプロイをしてスマホからでもサイトにアクセスできるようにしてください
いかがでしたでしょうか?
今回はReactの基本を解説しながらJavaScriptの難しい非同期なども含めて理解できました。
この内容だけでReactで頻繁に使うものをほとんど抑えられているはずです。
詳しく解説した動画を投稿しているのでよかったらみてみてください!
プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページからお気軽にカウンセリングをお申し込みください!
▼▼▼
- risa様
- tokec様
- k-kaijima様
- 山本様
- banana様
- 河野様
次回のハンズオンのレビュアーはXにて募集します。
Views: 0