採点されません。匿名です。 間違えることは学習の一部です。読むだけでもかまいません。

ch02 — Router / Pinia / fetch

テクノロジ系 / L09 app-countries — Vue 国図鑑アプリ

ch02 — Router / Pinia / fetch

Vue.js で世界の国を探索するアプリを作ろう

作るもの

「世界の国を調べられる国図鑑Webアプリ」

  • 250カ国以上の国旗・人口・地域を一覧表示
  • 国名で検索・地域でフィルタ
  • カードをクリックして詳細を見る
  • お気に入り登録

4日間のロードマップ

Dayテーマゴール
ch01Vue基礎静的データでVue文法をマスター
ch02Router/Pinia/fetch本物のAPIから国データを取得
ch03API設計体験一覧→詳細画面の連携
ch04UI完成検索・お気に入り・認証連携

サンプルコードの使い方

サンプルコード/
├── 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.cca2URLパラメータを取得

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 = truefinallyfalse に戻す。
  • エラーは try / catch / finally で扱う。res.okfalse なら throw new Error(...) し、catch (e)error.value = e.message
  • 成功データは list.value = await res.json() で代入する。
  • finally は成功でも失敗でも必ず通るので、isLoading を戻すのに最適。

解説

状態管理の本質は「ref + async 関数」

slide では Pinia の defineStore で書いているが、状態管理の中身は変わらない:

  • list / isLoading / errorref で持つ
  • 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 なしの composableuse〜 という関数)で書いた。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 件取得する」詳細画面の型

一覧から詳細への遷移は、ほぼ必ずこの形になる:

  1. 一覧でカードをクリック → 詳細画面に「どの国か」の識別子(cca2)を渡す
  2. 詳細画面は識別子を使って その 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()
})

countrynull の間は「読み込み中」、値が入ると詳細を出す。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 と繋がる。