ch02 — Router / Pinia / fetch
テクノロジ系 / L09 app-countries — Vue 国図鑑アプリ
ch02 — Router / Pinia / fetch
Vue.js で世界の国を探索するアプリを作ろう
作るもの
「世界の国を調べられる国図鑑Webアプリ」
- 250カ国以上の国旗・人口・地域を一覧表示
- 国名で検索・地域でフィルタ
- カードをクリックして詳細を見る
- お気に入り登録
4日間のロードマップ
| Day | テーマ | ゴール |
|---|---|---|
| ch01 | Vue基礎 | 静的データでVue文法をマスター |
| ch02 | Router/Pinia/fetch | 本物のAPIから国データを取得 |
| ch03 | API設計体験 | 一覧→詳細画面の連携 |
| ch04 | UI完成 | 検索・お気に入り・認証連携 |
サンプルコードの使い方
サンプルコード/ ├── day1/ │ ├── starter/ ← ここから始める (穴あきコード) │ └── answer/ ← 完成形 (ヒントに使う) ├── day2/ ... ├── day3/ ... ├── day4/ ... └── 完成品/ ← ch04 完成形の全コード
# starter を起動する例 cd day1/starter npm install npm run dev
ch02
Vue Router / Pinia / 本物のAPI
ch02 で作るもの
REST Countries API から250カ国を取得して一覧表示
https://restcountries.com/v3.1
| 機能 | 使う技術 |
|---|---|
| 画面遷移 (Home / 詳細) | Vue Router |
| 国データの共有 | Pinia (状態管理) |
| APIからデータ取得 | fetch + async/await |
| ローディング表示 | isLoading ref |
プロジェクトを作り直す
npm create vue@latest vue-atlas # ✔ Add Vue Router? → Yes # ✔ Add Pinia? → Yes # 他は No cd vue-atlas npm install npm run dev
⚠️ <code>npm create vue@latest</code> は Router と Pinia を選択できる。
後から追加すると設定が複雑になるので最初に選ぶ。
REST Countries API の注意点
GET https://restcountries.com/v3.1/all?fields=name,flags,region,population,cca2
⚠️ <code>/all</code> は <strong><code>?fields=</code> が必須</strong>。省略すると 400 エラーになる。
レスポンスの構造:
// name はオブジェクト country.name.common // "Japan" ← これを使う country.name.official // "Japan" // flags はオブジェクト country.flags.png // "https://flagcdn.com/w320/jp.png" country.flags.svg // 国コード (2文字) country.cca2 // "JP"
Pinia store を作る
// src/stores/countries.js
import { defineStore } from "pinia"
import { ref } from "vue"
export const useCountriesStore = defineStore("countries", () => {
const list = ref([])
const isLoading = ref(false)
const error = ref(null)
async function fetchAll() {
isLoading.value = true
error.value = null
try {
const res = await fetch(
"https://restcountries.com/v3.1/all?fields=name,flags,region,population,cca2"
)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
list.value = await res.json()
} catch (e) {
error.value = e.message
} finally {
isLoading.value = false
}
}
return { list, isLoading, error, fetchAll }
})Vue Router を設定する
// src/router.js
import { createRouter, createWebHistory } from "vue-router"
import HomeView from "./views/HomeView.vue"
import DetailView from "./views/DetailView.vue"
export default createRouter({
history: createWebHistory(),
routes: [
{ path: "/", component: HomeView },
{ path: "/country/:cca2", component: DetailView },
],
})| 要素 | 説明 |
|---|---|
<router-view /> | 現在のルートに対応するコンポーネントを表示 |
<router-link to="/"> | <a> の代わり (ページ再読み込みなし) |
useRoute().params.cca2 | URLパラメータを取得 |
HomeView: 一覧画面
<script setup>
import { onMounted } from "vue"
import { useCountriesStore } from "../stores/countries"
import CountryCard from "../components/CountryCard.vue"
const store = useCountriesStore()
onMounted(() => store.fetchAll())
</script>
<template>
<p v-if="store.isLoading">読み込み中...</p>
<p v-else-if="store.error" class="error">{{ store.error }}</p>
<div v-else class="grid">
<CountryCard
v-for="c in store.list"
:key="c.cca2"
:country="c"
/>
</div>
</template>DetailView: 詳細画面
<script setup>
import { ref, onMounted } from "vue"
import { useRoute } from "vue-router"
const route = useRoute()
const country = ref(null)
onMounted(async () => {
const cca2 = route.params.cca2
const res = await fetch(
`https://restcountries.com/v3.1/alpha/${cca2}` +
`?fields=name,flags,region,population,capital`
)
country.value = await res.json()
})
</script>
<template>
<div v-if="country">
<img :src="country.flags.png" :alt="country.name.common" width="200" />
<h1>{{ country.name.common }}</h1>
<p>地域: {{ country.region }}</p>
<p>人口: {{ country.population.toLocaleString() }} 人</p>
<p>首都: {{ country.capital?.[0] }}</p>
</div>
</template>CountryCard を更新する (ch02版)
<!-- CountryCard.vue (ch02では router.push を使う) -->
<script setup>
import { useRouter } from "vue-router"
const props = defineProps({
country: { type: Object, required: true },
})
const router = useRouter()
function handleClick() {
router.push(`/country/${props.country.cca2}`)
}
</script>
<template>
<div class="card" @click="handleClick">
<img :src="country.flags.png" :alt="country.name.common" />
<div class="info">
<h2>{{ country.name.common }}</h2>
<p>{{ country.region }}</p>
<p>{{ country.population.toLocaleString() }} 人</p>
</div>
</div>
</template>ch02 まとめ
| 概念 | 書き方 |
|---|---|
| store 定義 | defineStore("name", () => { ... }) |
| store 使用 | const store = useCountriesStore() |
| データ取得タイミング | onMounted(() => store.fetchAll()) |
| ページ遷移 | router.push("/path") |
| URLパラメータ | useRoute().params.id |
| エラー状態 | try/catch + error ref |
考えてみよう
既定では正解・不正解の判定はしません。自分のペースで「答えを見る」を。採点してほしい場合だけ「採点してほしい」を押してください。
ヒント
- 非同期処理は
async function fetchAll() { ... }の形。中でawait fetch(...)する。 - 「読み込み中」の表現: 開始時に
isLoading.value = true、finallyでfalseに戻す。 - エラーは
try / catch / finallyで扱う。res.okがfalseならthrow new Error(...)し、catch (e)でerror.value = e.message。 - 成功データは
list.value = await res.json()で代入する。 finallyは成功でも失敗でも必ず通るので、isLoadingを戻すのに最適。
解説
状態管理の本質は「ref + async 関数」
slide では Pinia の defineStore で書いているが、状態管理の中身は変わらない:
list/isLoading/errorをrefで持つfetchAllという async 関数で「読み込み中 → 取得 → 完了」の状態遷移を起こす
async function fetchAll() {
isLoading.value = true // ① 読み込み開始
error.value = null
try {
const res = await fetch(API_URL)
if (!res.ok) throw new Error(`HTTP ${res.status}`) // ② 失敗を例外に
list.value = await res.json() // ③ 成功データ
} catch (e) {
error.value = e.message // ④ 失敗を記録
} finally {
isLoading.value = false // ⑤ 必ず戻す
}
}この isLoading を使うと、画面側で v-if="isLoading" →「読み込み中...」、v-else-if="error" → エラー表示、v-else → 一覧、の3状態を出し分けられる。
composable と Pinia の違い
このドリルは Pinia なしの composable(use〜 という関数)で書いた。Pinia の store も、Composition API で書くとほぼこの関数と同じ形になる:
// Pinia 版 (slide の形)
export const useCountriesStore = defineStore('countries', () => {
const list = ref([])
const isLoading = ref(false)
const error = ref(null)
async function fetchAll() { /* このドリルと同じ中身 */ }
return { list, isLoading, error, fetchAll }
})違いは「複数のコンポーネントで同じ状態を共有したいか」だけ:
- composable (
useCountries()): 呼ぶたびに新しい状態が作られる。1 コンポーネント内で完結するならこれで十分。 - Pinia store: アプリ全体で 1 つの状態を共有する。一覧画面と詳細画面で同じ国リストを使い回したいときに効く。
まず composable で「状態 + async」の形を体に入れ、共有が必要になったら Pinia に移す、という順序で理解するとよい。中身のロジックは引っ越しても変わらない。
ヒント
onMounted(async () => { ... })で、表示直後に 1 回だけ取得する。- 取得は失敗しうるので、drill 01 と同じく
try / catchで囲む。失敗時はerror.valueにメッセージを入れる。 - URL は
props.cca2をテンプレートリテラルで埋め込む(/alpha/<cca2>?fields=...の形)。 res.okが false なら例外を投げて catch に流す(drill 01 と同じ)。/alpha/{code}は 1 件でも配列で返ることがある。Array.isArray(data)で配列なら先頭、そうでなければそのまま、と吸収する。- 表示は 3 状態(error / 詳細 / 読み込み中)を
v-if/v-else-if/v-elseで出し分ける。
解説
「ID を受けて 1 件取得する」詳細画面の型
一覧から詳細への遷移は、ほぼ必ずこの形になる:
- 一覧でカードをクリック → 詳細画面に「どの国か」の識別子(
cca2)を渡す - 詳細画面は識別子を使って その 1 件だけ を API から取得する
const props = defineProps({ cca2: { type: String, required: true } })
const country = ref(null)
onMounted(async () => {
const res = await fetch(`https://restcountries.com/v3.1/alpha/${props.cca2}?fields=...`)
country.value = await res.json()
})country が null の間は「読み込み中」、値が入ると詳細を出す。v-if="country" / v-else でこの 2 状態を切り替える。
なぜ props にしたか(slide の useRoute との関係)
slide では識別子を useRoute().params.cca2 で URL から直接取っている:
import { useRoute } from 'vue-router'
const route = useRoute()
const cca2 = route.params.cca2これは Router 前提(/country/:cca2 というルート定義が必要)で、テストするには Router を丸ごと用意しないといけない。
このドリルでは識別子を props で受け取る形に分けた。こうすると:
- コンポーネント単体でテストできる(Router 不要)
- 「どこから cca2 が来るか」(URL でも親からでも)と「cca2 で 1 件取得する」ロジックを分離できる
実アプリでは、Router のルートコンポーネントが useRoute().params.cca2 を読んで、この CountryDetail に props として渡す、という二段構成にすると、取得ロジックがテスタブルなまま Router と繋がる。