JavaScript | 1 日 120 分 × 7 日アプリ学習:API通信アプリ(NewsAPI.org)

APP JavaScript
スポンサーリンク

3日目のゴールと今日やること

3日目のテーマは
「同じ fetch・async/await・エラーハンドリングの型を保ったまま、“ページネーション付きニュースアプリ”に育てる」
ことです。

1〜2日目で、あなたはすでに

  • キーワード検索で NewsAPI から記事を取得する
  • fetch を async/await で扱う
  • response.ok と NewsAPI 独自のエラー(status: “error”)を分岐する
  • ローディング状態とボタン制御を入れる

という「ニュース API 通信の基本フォーム」を手に入れました。

3日目はここに、

  • ページネーション(次のページ / 前のページ)
  • 現在ページの状態管理
  • fetch の再利用(同じ関数でページだけ変える)
  • 通信失敗時の分岐を“ページ付き”で考える

を足して、「ニュースを読み進められるアプリ」にしていきます。


今日の完成イメージを先に描く

どんなアプリにしたいか

3日目のミニアプリは、こんな動きをします。

  • キーワード・期間・並び順を指定して検索
  • 1 ページあたり 10 件の記事を表示
  • 「次へ」「前へ」ボタンでページを移動
  • ページを変えるたびに同じ条件で API を再取得
  • ローディング中はボタンを無効化
  • 通信失敗時はエラーメッセージを表示し、ページ番号は変えない

つまり、
「検索条件」と「ページ番号」をセットで扱う」
というのが今日の一番のテーマです。


まず「状態」を決める:何を覚えておく必要があるか

ページネーションに必要な状態

ページ付きのニュース検索には、最低限これが必要です。

  • 現在のキーワード
  • 現在の from / to / sortBy
  • 現在のページ番号
  • 1 ページあたりの件数(pageSize)

これをコード上の「状態」として持ちます。

let currentKeyword = "";
let currentFrom = "";
let currentTo = "";
let currentSortBy = "publishedAt";
let currentPage = 1;
const pageSize = 10;
JavaScript

ここで大事なのは、
「fetch のたびにフォームから読み取る」のではなく、
一度検索した条件を状態として保持する

という発想です。


NewsAPI のページネーションの仕組みを理解する

page と pageSize の関係

NewsAPI の /everything は、
次のようなパラメータを受け取れます。

  • pageSize: 1 ページあたり何件か(最大 100)
  • page: 何ページ目か(1 始まり)

例えば、

pageSize=10&page=1 → 1〜10件目  
pageSize=10&page=2 → 11〜20件目  

というイメージです。

buildUrl に page と pageSize を足す

2日目の buildUrl を拡張します。

const API_KEY = "YOUR_API_KEY";
const baseUrl = "https://newsapi.org/v2/everything";

function buildUrl({ keyword, from, to, sortBy, page, pageSize }) {
  const params = new URLSearchParams({
    q: keyword,
    apiKey: API_KEY,
    language: "ja",
    sortBy: sortBy || "publishedAt",
    page: String(page || 1),
    pageSize: String(pageSize || 10)
  });

  if (from) {
    params.set("from", from);
  }
  if (to) {
    params.set("to", to);
  }

  return `${baseUrl}?${params.toString()}`;
}
JavaScript

ここでのポイントは、
「ページ番号も URL の一部である」
ということです。


中心となる async 関数を「ページ対応」にする

fetchNews を「状態を使う関数」に変える

2日目までは、fetchNews にキーワードなどを渡していました。
3日目では、「状態を前提に動く関数」にします。

const keywordInput = document.getElementById("keywordInput");
const fromInput = document.getElementById("fromInput");
const toInput = document.getElementById("toInput");
const sortSelect = document.getElementById("sortSelect");
const searchButton = document.getElementById("searchButton");
const statusDiv = document.getElementById("status");
const resultDiv = document.getElementById("result");

let isLoading = false;
let currentKeyword = "";
let currentFrom = "";
let currentTo = "";
let currentSortBy = "publishedAt";
let currentPage = 1;
const pageSize = 10;

function validateKeyword(keyword) {
  if (!keyword.trim()) return "キーワードを入力してください。";
  if (keyword.length > 50) return "キーワードが長すぎます。50文字以内で入力してください。";
  return null;
}

function showError(message) {
  statusDiv.textContent = message;
  statusDiv.style.color = "red";
}

function showStatus(message) {
  statusDiv.textContent = message;
  statusDiv.style.color = "black";
}

function startLoading() {
  isLoading = true;
  searchButton.disabled = true;
  nextButton.disabled = true;
  prevButton.disabled = true;
}

function endLoading() {
  isLoading = false;
  searchButton.disabled = false;
  updatePaginationButtons();
}
JavaScript

ここで nextButtonprevButton は、後で HTML に追加するページ移動ボタンです。

検索開始時に「状態を更新」する

検索ボタンを押したときは、
フォームの値を「現在の状態」にコピーします。

searchButton.addEventListener("click", () => {
  const keyword = keywordInput.value.trim();
  const from = fromInput.value;
  const to = toInput.value;
  const sortBy = sortSelect.value;

  const error = validateKeyword(keyword);
  if (error) {
    showError(error);
    resultDiv.textContent = "";
    return;
  }

  currentKeyword = keyword;
  currentFrom = from;
  currentTo = to;
  currentSortBy = sortBy;
  currentPage = 1;

  fetchNewsWithCurrentState();
});
JavaScript

ここでのポイントは、
「フォーム → 状態 → fetch」
という流れを作っていることです。

状態を使って API を叩く関数

async function fetchNewsWithCurrentState() {
  if (isLoading) return;

  startLoading();
  showStatus("ニュースを取得中です…");
  resultDiv.textContent = "";

  try {
    const url = buildUrl({
      keyword: currentKeyword,
      from: currentFrom,
      to: currentTo,
      sortBy: currentSortBy,
      page: currentPage,
      pageSize
    });

    const response = await fetch(url);

    if (!response.ok) {
      showError(`サーバーエラーが発生しました。(${response.status})`);
      return;
    }

    const data = await response.json();

    if (data.status === "error") {
      showError(`エラー:${data.message}`);
      return;
    }

    showStatus(`ニュースの取得に成功しました。(ページ ${currentPage})`);
    renderArticles(data);
    updatePaginationInfo(data);

  } catch (error) {
    showError("通信に失敗しました。ネットワークを確認してください。");
    console.error(error);

  } finally {
    endLoading();
  }
}
JavaScript

ここで重要なのは、
「ページが変わっても、fetch の書き方は一切変わらない」
ということです。


ページネーション UI を実装する

HTML にボタンと情報表示を追加する

<div id="pagination">
  <button id="prevButton">前のページ</button>
  <span id="pageInfo"></span>
  <button id="nextButton">次のページ</button>
</div>

JavaScript で要素を取得します。

const prevButton = document.getElementById("prevButton");
const nextButton = document.getElementById("nextButton");
const pageInfo = document.getElementById("pageInfo");
JavaScript

totalResults から「最大ページ数」を計算する

NewsAPI のレスポンスには、
totalResults というプロパティがあります。

{
  "status": "ok",
  "totalResults": 123,
  "articles": [ ... ]
}

これを使って、最大ページ数を計算します。

let totalResults = 0;
let maxPage = 1;

function updatePaginationInfo(data) {
  totalResults = data.totalResults || 0;
  maxPage = Math.max(1, Math.ceil(totalResults / pageSize));

  pageInfo.textContent = `ページ ${currentPage} / ${maxPage}(全 ${totalResults} 件)`;

  updatePaginationButtons();
}

function updatePaginationButtons() {
  prevButton.disabled = isLoading || currentPage <= 1;
  nextButton.disabled = isLoading || currentPage >= maxPage;
}
JavaScript

「次へ」「前へ」ボタンの動き

nextButton.addEventListener("click", () => {
  if (isLoading) return;
  if (currentPage >= maxPage) return;

  currentPage += 1;
  fetchNewsWithCurrentState();
});

prevButton.addEventListener("click", () => {
  if (isLoading) return;
  if (currentPage <= 1) return;

  currentPage -= 1;
  fetchNewsWithCurrentState();
});
JavaScript

ここでのポイントは、

  • ページ番号を変えるのはボタン側
  • API を叩くのは常に fetchNewsWithCurrentState
  • 「状態を変える」と「通信する」を分けて考えている

という構造です。


通信失敗時の分岐を「ページ付き」で考える

失敗したときにページ番号をどうするか

ここが地味に大事なポイントです。

例えば「次のページへ」を押して
currentPage を 2 → 3 にしたあと、
通信が失敗したとします。

このとき、

  • ページ番号を 3 のままにするか
  • 2 に戻すか

という選択があります。

今回は「ユーザーが押した事実を尊重して、ページ番号はそのまま」にしておきます。
理由は、再試行したときに「同じページをもう一度取りに行く」方が自然だからです。

もし「戻したい」場合は、
catch の中で currentPage -= 1 する、という設計もありです。

エラーメッセージを少しだけ丁寧にする

ページ付きのアプリでは、
エラーメッセージにページ番号を含めるのもアリです。

} catch (error) {
  showError(`ページ ${currentPage} の取得に失敗しました。ネットワークを確認してください。`);
  console.error(error);
}
JavaScript

こうすると、
「今どのページで失敗したのか」がユーザーにもわかります。


3日目の完成コード(重要部分をまとめて)

const API_KEY = "YOUR_API_KEY";
const baseUrl = "https://newsapi.org/v2/everything";

const keywordInput = document.getElementById("keywordInput");
const fromInput = document.getElementById("fromInput");
const toInput = document.getElementById("toInput");
const sortSelect = document.getElementById("sortSelect");
const searchButton = document.getElementById("searchButton");
const statusDiv = document.getElementById("status");
const resultDiv = document.getElementById("result");
const prevButton = document.getElementById("prevButton");
const nextButton = document.getElementById("nextButton");
const pageInfo = document.getElementById("pageInfo");

let isLoading = false;
let currentKeyword = "";
let currentFrom = "";
let currentTo = "";
let currentSortBy = "publishedAt";
let currentPage = 1;
const pageSize = 10;
let totalResults = 0;
let maxPage = 1;

function validateKeyword(keyword) {
  if (!keyword.trim()) return "キーワードを入力してください。";
  if (keyword.length > 50) return "キーワードが長すぎます。50文字以内で入力してください。";
  return null;
}

function buildUrl({ keyword, from, to, sortBy, page, pageSize }) {
  const params = new URLSearchParams({
    q: keyword,
    apiKey: API_KEY,
    language: "ja",
    sortBy: sortBy || "publishedAt",
    page: String(page || 1),
    pageSize: String(pageSize || 10)
  });

  if (from) params.set("from", from);
  if (to) params.set("to", to);

  return `${baseUrl}?${params.toString()}`;
}

function showError(message) {
  statusDiv.textContent = message;
  statusDiv.style.color = "red";
}

function showStatus(message) {
  statusDiv.textContent = message;
  statusDiv.style.color = "black";
}

function startLoading() {
  isLoading = true;
  searchButton.disabled = true;
  prevButton.disabled = true;
  nextButton.disabled = true;
}

function endLoading() {
  isLoading = false;
  searchButton.disabled = false;
  updatePaginationButtons();
}

function renderArticles(data) {
  const articles = data.articles;

  if (!articles || articles.length === 0) {
    resultDiv.textContent = "該当するニュースが見つかりませんでした。";
    return;
  }

  let html = "";

  articles.forEach((article) => {
    const title = article.title || "タイトルなし";
    const description = article.description || "説明文はありません。";
    const url = article.url;
    const source = article.source?.name || "不明なソース";

    html += `
      <div class="article">
        <h3>${title}</h3>
        <p>${description}</p>
        <p>提供元:${source}</p>
        <p><a href="${url}" target="_blank" rel="noopener noreferrer">記事を読む</a></p>
      </div>
    `;
  });

  resultDiv.innerHTML = html;
}

function updatePaginationInfo(data) {
  totalResults = data.totalResults || 0;
  maxPage = Math.max(1, Math.ceil(totalResults / pageSize));
  pageInfo.textContent = `ページ ${currentPage} / ${maxPage}(全 ${totalResults} 件)`;
  updatePaginationButtons();
}

function updatePaginationButtons() {
  prevButton.disabled = isLoading || currentPage <= 1;
  nextButton.disabled = isLoading || currentPage >= maxPage;
}

async function fetchNewsWithCurrentState() {
  if (isLoading) return;

  startLoading();
  showStatus("ニュースを取得中です…");
  resultDiv.textContent = "";

  try {
    const url = buildUrl({
      keyword: currentKeyword,
      from: currentFrom,
      to: currentTo,
      sortBy: currentSortBy,
      page: currentPage,
      pageSize
    });

    const response = await fetch(url);

    if (!response.ok) {
      showError(`サーバーエラーが発生しました。(${response.status})`);
      return;
    }

    const data = await response.json();

    if (data.status === "error") {
      showError(`エラー:${data.message}`);
      return;
    }

    showStatus(`ニュースの取得に成功しました。(ページ ${currentPage})`);
    renderArticles(data);
    updatePaginationInfo(data);

  } catch (error) {
    showError(`ページ ${currentPage} の取得に失敗しました。ネットワークを確認してください。`);
    console.error(error);

  } finally {
    endLoading();
  }
}

searchButton.addEventListener("click", () => {
  const keyword = keywordInput.value.trim();
  const from = fromInput.value;
  const to = toInput.value;
  const sortBy = sortSelect.value;

  const error = validateKeyword(keyword);
  if (error) {
    showError(error);
    resultDiv.textContent = "";
    return;
  }

  currentKeyword = keyword;
  currentFrom = from;
  currentTo = to;
  currentSortBy = sortBy;
  currentPage = 1;

  fetchNewsWithCurrentState();
});

nextButton.addEventListener("click", () => {
  if (isLoading) return;
  if (currentPage >= maxPage) return;
  currentPage += 1;
  fetchNewsWithCurrentState();
});

prevButton.addEventListener("click", () => {
  if (isLoading) return;
  if (currentPage <= 1) return;
  currentPage -= 1;
  fetchNewsWithCurrentState();
});
JavaScript

今日いちばん深く理解してほしいこと

3日目で本当に掴んでほしいのは、この感覚です。

  • fetch・async/await・エラーハンドリングの「型」はもう変えない
  • 変わるのは「状態(currentPage など)」と「URL のパラメータ」だけ
  • 状態を中心に設計すると、ページネーションも自然に書ける

天気 API でも、ニュース API でも、
「ページ付き一覧を表示する」というのは超よくあるパターンです。

あなたはもう、
「API から一覧を取って、ページをまたいで読ませるアプリ」を自力で組み立てられる人
のところまで来ています。

タイトルとURLをコピーしました