🔍

【Next.js + TypeScript】AbortControllerについて

に公開

はじめに

今回は、AbortController を使った非同期リクエストの制御について、簡単な実装を交えながら安全に非同期処理を管理する方法をまとめていきたいと思います。通常の fetch は複数同時リクエストが発生すると、古いリクエストが無駄に残ってしまい、最新入力とずれた結果が表示されることがあります。それを防ぐために AbortController を使うことで、安全に「最新のリクエストだけ」を処理できます。(初心者なので、間違えているところがあれば教えていただけると助かります)

今回の実装の概要

今回の実装では、Next.js + TypeScript を使ってリアルタイム検索UIを作っていきます。入力ごとに /api/usersへリクエストを送り、結果を一覧表示可能にしています。AbortControllerを使い、不要なリクエストをキャンセルして非同期処理を管理し、今回はダミーデータで動作を確認できるようにしています。

実装編

route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET(req: NextRequest) {
  const query = req.nextUrl.searchParams.get('q')?.toLowerCase();
  const users = [
    { id: 1, name: 'Alice' },
    { id: 2, name: 'Bob' },
    { id: 3, name: 'Charlie' },
    { id: 4, name: 'David' },
    { id: 5, name: 'James' },
    { id: 6, name: 'Ethan' },
    { id: 7, name: 'Oliver' },
    { id: 8, name: 'Florian' },
    { id: 9, name: 'Emma' },
  ];
  const filteredUsers = query
    ? users.filter(user => user.name.toLowerCase().includes(query))
    : users;

  return NextResponse.json(filteredUsers);
}

解説

  • NextRequest
    App Router の API Route で使えるリクエストオブジェクトです。req.urlreq.nextUrl.searchParams からクエリを扱えます。

  • NextResponse
    API Route の返り値を作成するためのオブジェクトです。.json() を使うと JSON を返します。

  • クエリ文字列の取得

      const query = req.nextUrl.searchParams.get('q')?.toLowerCase();
    

    URL の ?q=xxx から検索文字列を取得します。例: /api/users?q=al → query = "al"
    .toLowerCase()しているのは 大文字小文字を区別しない検索 をするためです。

  • フィルタリング処理

    const filteredUsers = query
    ? users.filter(user => user.name.toLowerCase().includes(query))
    : users;
    

    部分文字列(substring) の一致を見ています。つまり "al" がそのまま連続して含まれているかどうかを判定しています。
    例: q=al → Alice
    query が空または null の場合 → 全ユーザー返す


SearchInput.tsx
'use client';

import { useState, useRef, useEffect, useCallback } from 'react';

interface User { id: number; name: string; }

export default function SearchModal() {
  const [isOpen, setIsOpen] = useState(false);
  const [query, setQuery] = useState('');
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const controllerRef = useRef<AbortController | null>(null);
  const debounceTimerRef = useRef<number | null>(null);

  const createController = useCallback(() => {
    if (controllerRef.current) {
      controllerRef.current.abort();
    }
    
    const controller = new AbortController();
    controllerRef.current = controller;
    return controller;
  }, []);

  const clearController = useCallback(() => {
    if (controllerRef.current) {
      controllerRef.current.abort();
      controllerRef.current = null;
    }
  }, []);

  const clearDebounce = useCallback(() => {
    if (debounceTimerRef.current) {
      window.clearTimeout(debounceTimerRef.current);
      debounceTimerRef.current = null;
    }
  }, []);

  const fetchUsers = useCallback(async (q: string) => {
    const controller = createController();
    const currentController = controller;

    setError(null);
    setLoading(true);

    try {
      const url = `/api/users?q=${encodeURIComponent(q)}`;
      const res = await fetch(url, {
        signal: controller.signal,
        cache: 'no-store',
      });

      if (!res.ok) throw new Error('通信エラー');

      const data: User[] = await res.json();

      if (controllerRef.current !== currentController) {
        return;
      }

      setUsers(data);
    } catch (err: unknown) {
      if (err instanceof DOMException && err.name === 'AbortError') {
        console.log('fetch aborted');
      } else {
        if (controllerRef.current === currentController) {
          const message = err instanceof Error ? err.message : '不明なエラーが発生しました';
          setError(message);
        }
      }
    } finally {
      if (controllerRef.current === currentController) {
        setLoading(false);
      }
    }
  }, [createController]);

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const val = e.target.value;
    setQuery(val);
    clearDebounce();
    debounceTimerRef.current = window.setTimeout(() => {
      fetchUsers(val);
    }, 300);
  };

  const resetModal = useCallback(() => {
    clearController();
    clearDebounce();
    setError(null);
    setQuery('');
    setUsers([]);
    setLoading(false);
  }, [clearController, clearDebounce]);

  const handleCancel = () => {
    resetModal();
    setIsOpen(false);
  };

  const handleOpen = () => {
    setIsOpen(true);
  };

  useEffect(() => {
    if (isOpen) {
      fetchUsers('');
    }
  }, [isOpen, fetchUsers]);

  useEffect(() => {
    return () => {
      clearController();
      clearDebounce();
    };
  }, [clearController, clearDebounce]);

  return (
    <>
      <button
        type="button"
        onClick={handleOpen}
        className="bg-blue-500 text-white px-4 py-2 rounded"
      >
        検索
      </button>

      {isOpen && (
        <div className="fixed inset-0 bg-black bg-opacity-40 flex items-center justify-center">
          <div className="bg-white p-6 rounded shadow max-w-md w-full">
            <div className="flex gap-2 mb-4">
              <input
                type="text"
                value={query}
                onChange={handleChange}
                placeholder="検索..."
                className="flex-1 border px-3 py-2 rounded"
              />
              <button
                type="button"
                onClick={handleCancel}
                className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600"
              >
                キャンセル
              </button>
            </div>

            {loading && <p className="text-gray-500">読み込み中...</p>}
            {error && <p className="text-red-500">{error}</p>}
            {!loading && users.length === 0 && <p className="text-gray-500">結果がありません</p>}

            <ul className="list-disc pl-5">
              {users.map(u => (
                <li key={u.id}>{u.name}</li>
              ))}
            </ul>
          </div>
        </div>
      )}
    </>
  );
}

解説

  • 全体の概要
    AbortController + debounce による無駄な API 呼び出しの削減を実装してます。
  • useRefについて
    const controllerRef = useRef<AbortController | null>(null);
    const debounceTimerRef = useRef<number | null>(null);
    

controllerRef → 現在の Fetch リクエストの AbortController を保持し、不要になったリクエストをキャンセルします。
debounceTimerRef → 入力遅延(debounce)用のタイマーIDを保持します。

  • fetchUsersの実装について
    入力が変わるたびに最新のリクエストだけを反映し、古いリクエストはキャンセルし、結果は無視するようにしてます。
  • デバウンス処理の実装について
    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
      const val = e.target.value;
      setQuery(val);
      clearDebounce();
    
      debounceTimerRef.current = window.setTimeout(() => {
        fetchUsers(val);
      }, 300);
    };
    
    入力のたびにタイマーをクリアし新しいタイマーを設定し入力が止まって300ms後に API を呼び出します。無駄なAPI呼び出しを減らす実装にしてます。

page.tsx
import SearchInput from './components/SearchInput';

export default function Page() {
  return (
     <main className="pt-10">
      <h1 className="text-2xl font-bold text-center mb-6">AbortControllerのデモ</h1>
      <div className="flex justify-center">
        <SearchInput />
      </div>
    </main>
  );
}

解説
コンポーネントをインポートして main 部分に置いてます。


実行結果

まとめ

AbortController は 「実行中のリクエストを中断する」 ための仕組みで、
ユーザーの入力が連続して変わる状況でも、古いリクエストが UI に影響を与えないようにすることが目的です。

  • 新しいリクエストを送る前に、古いリクエストを abort() して中断できる
  • ネットワーク遅延などで古いレスポンスが「あとから返ってくる」問題を防げる
  • 常に最新の入力に対する結果だけを UI に反映できる

Discussion