RORK LABEN
TOOLING — Rorkの開発者向けリポジトリが動き続けています。rork-xcodeが7月16日、rork-deviceが7月15日、rork-plistが7月13日に更新されましたOPUS46 — RorkでClaude Opus 4.6が稼働しています。Rork MaxはClaude Codeを土台にアプリを組み立てる設計ですSIM — ブラウザ上で動くクラウドのiOSシミュレータを備え、実機へのインストールは1クリック、App Storeへの提出は2クリックと案内されていますMAX — Rork MaxはReact Nativeではなく純粋なSwiftを出力します。iPhone・iPad・Apple Watch・Apple TV・Vision Pro、そしてiMessageまでが射程ですNATIVE — HealthKit、ARKitとLiDAR、NFC、Dynamic Island、Live Activities、Metalによる3D、Core MLのオンデバイス推論まで扱えますSEED — RorkはLeft Lane Capitalが主導する1,500万ドルのシードラウンドを実施し、Peak XVとa16z Speedrunが参加しましたTOOLING — Rorkの開発者向けリポジトリが動き続けています。rork-xcodeが7月16日、rork-deviceが7月15日、rork-plistが7月13日に更新されましたOPUS46 — RorkでClaude Opus 4.6が稼働しています。Rork MaxはClaude Codeを土台にアプリを組み立てる設計ですSIM — ブラウザ上で動くクラウドのiOSシミュレータを備え、実機へのインストールは1クリック、App Storeへの提出は2クリックと案内されていますMAX — Rork MaxはReact Nativeではなく純粋なSwiftを出力します。iPhone・iPad・Apple Watch・Apple TV・Vision Pro、そしてiMessageまでが射程ですNATIVE — HealthKit、ARKitとLiDAR、NFC、Dynamic Island、Live Activities、Metalによる3D、Core MLのオンデバイス推論まで扱えますSEED — RorkはLeft Lane Capitalが主導する1,500万ドルのシードラウンドを実施し、Peak XVとa16z Speedrunが参加しました
記事一覧/開発ツール
開発ツール/2026-05-10中級

Rork の検索フィールドが API を連打する問題を debounce と AbortController で止める

検索バーで1文字打つたびに API が走り、古い結果が新しい結果を上書きする。Rork で作ったアプリでこの症状に当たったときの3点セット(debounce・AbortController・空結果の見せ方)を実装手順つきで紹介します。

Rork515React Native209検索UIdebounceAbortController個人開発187

壁紙アプリの中に検索機能を後から足したとき、最初の実装は「TextInput が変わったら fetch する」だけでした。テストでは問題なく動いていたのですが、AdMob のリリースを見据えて実機で長文を打ってみたら、キーストロークごとにサーバーへリクエストが飛び、しかも遅いネットワークだと古いクエリの結果が新しいクエリの結果の後に到着して画面を上書きする現象に当たりました。Rork で UI のたたき台を生成したあと、こういう細部のレースコンディションは自分で詰めないといけません。

累計5,000万DLのアプリ事業で何度も踏んだこの落とし穴を、Rork が出力する標準的な React Native 構造に合わせて直す手順をまとめておきます。debounce だけ入れて満足しがちですが、「古いリクエストを止める」工程を入れないと根本解決にはなりません。

検索バーで起きる3つの問題を切り分ける

「検索が重い」と一括りにせず、まず症状を分解します。私が壁紙アプリの検索を作り直したときは、次の3つが同時に起きていました。

  • 連打問題: 1文字打つたびに fetch が走り、サーバー側のレートリミットに当たる
  • 競合問題: ネットワーク往復のばらつきで、古いクエリの結果が新しいクエリの結果の後に届く
  • 見え方問題: 検索中なのか、空結果なのか、エラーなのかが画面から判別できない

debounce が解決するのは1番目だけです。2番目と3番目は別の対処が必要で、ここを混同したまま手を入れても症状は再発します。

ステップ1: useDebouncedValue で入力値を遅延させる

Rork が生成する app/search.tsx のような画面に、まず入力値の遅延フックを追加します。

// hooks/useDebouncedValue.ts
import { useEffect, useState } from "react";
 
/**
 * 値の変化が一定時間止まってから後段に渡す。
 * delay = 300ms が体感的にちょうどよく、200ms 以下だと連打、500ms 以上だと反応が遅く感じる。
 */
export function useDebouncedValue<T>(value: T, delay = 300): T {
  const [debounced, setDebounced] = useState(value);
 
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
 
  return debounced;
}

そして検索画面側ではこう書きます。

// app/search.tsx
const [query, setQuery] = useState("");
const debouncedQuery = useDebouncedValue(query, 300);
 
// fetch は debouncedQuery が変わったときだけ実行する
useEffect(() => {
  if (debouncedQuery.length === 0) return;
  fetchResults(debouncedQuery);
}, [debouncedQuery]);

期待出力としては、ユーザーが「welcome」と打つと、本来 7回走るはずの fetch が1回(最後の確定値「welcome」のみ)に集約されます。

ここまでが多くの記事で「これで解決」と書かれている内容ですが、実機で遅い回線をエミュレートすると競合問題が顔を出します。

ステップ2: AbortController で古いリクエストを止める

入力が「we」→「wel」→「welcome」と進む途中で、たまたま「we」のリクエストが遅延していると、最後に届いた古い結果で新しい結果が上書きされます。これを防ぐには、新しい fetch を始める前に古い fetch を取り消します。

// hooks/useSearchResults.ts
import { useEffect, useState } from "react";
 
type State<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T[] }
  | { status: "empty" }
  | { status: "error"; message: string };
 
export function useSearchResults<T>(
  query: string,
  endpoint: (q: string, signal: AbortSignal) => Promise<T[]>,
): State<T> {
  const [state, setState] = useState<State<T>>({ status: "idle" });
 
  useEffect(() => {
    if (query.length === 0) {
      setState({ status: "idle" });
      return;
    }
 
    const controller = new AbortController();
    setState({ status: "loading" });
 
    endpoint(query, controller.signal)
      .then((data) => {
        if (controller.signal.aborted) return; // 取り消されたら無視
        setState(data.length === 0 ? { status: "empty" } : { status: "success", data });
      })
      .catch((err: unknown) => {
        if (controller.signal.aborted) return;
        const message = err instanceof Error ? err.message : "通信エラー";
        setState({ status: "error", message });
      });
 
    return () => {
      // 次の query が来た瞬間に古いリクエストを破棄する
      controller.abort();
    };
  }, [query, endpoint]);
 
  return state;
}

ポイントは controller.signal.abortedthen の中でも明示的に確認している ことです。fetch 実装によっては abort してもプロミスが reject されず resolve のまま戻ってくるパターンがあり、そこで古い結果が画面に流れてきます。signal.aborted の早期 return を入れておくと安全です。

エンドポイント側はこう書きます。

// lib/searchApi.ts
export async function searchWallpapers(q: string, signal: AbortSignal) {
  const res = await fetch(`https://api.example.com/search?q=${encodeURIComponent(q)}`, {
    signal,
  });
 
  if (res.status === 429) throw new Error("ちょっと混雑しています。少し待ってから試してください");
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
 
  const json = await res.json();
  return json.items as Array<{ id: string; title: string }>;
}

429 と 5xx を分けて投げているのは、ステップ4の「見え方」でメッセージを分岐させるためです。

ステップ3: 4状態を画面に出し分ける

ローディング・成功・空結果・エラーの4つを判別できるようにすると、ユーザーが「壊れた」と誤解しなくなります。私は壁紙アプリで空結果のメッセージをサボった結果、レビューで「検索しても何も出ない」という★1を何度かもらった経験があります。

// app/search.tsx
import { ActivityIndicator, FlatList, Text, TextInput, View } from "react-native";
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
import { useSearchResults } from "@/hooks/useSearchResults";
import { searchWallpapers } from "@/lib/searchApi";
 
export default function SearchScreen() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebouncedValue(query, 300);
  const state = useSearchResults(debouncedQuery, searchWallpapers);
 
  return (
    <View style={{ flex: 1, padding: 16 }}>
      <TextInput
        value={query}
        onChangeText={setQuery}
        placeholder="壁紙を検索"
        autoCorrect={false}
        autoCapitalize="none"
      />
 
      {state.status === "loading" && <ActivityIndicator style={{ marginTop: 24 }} />}
 
      {state.status === "empty" && (
        <Text style={{ marginTop: 24, color: "#888" }}>
          「{debouncedQuery}」に一致する壁紙は見つかりませんでした別のキーワードをお試しください
        </Text>
      )}
 
      {state.status === "error" && (
        <Text style={{ marginTop: 24, color: "#c00" }}>{state.message}</Text>
      )}
 
      {state.status === "success" && (
        <FlatList
          data={state.data}
          keyExtractor={(item) => item.id}
          renderItem={({ item }) => <Text>{item.title}</Text>}
        />
      )}
    </View>
  );
}

autoCorrect={false}autoCapitalize="none" は地味ですが必須です。これを忘れると iOS の自動補正が後から走って勝手に文字列を書き換え、debounced クエリと表示中のクエリがズレるという別のレースコンディションを呼びます。

ステップ4: 連打されてもサーバーを守る最後の砦

ここまでで体感的な問題はだいたい消えますが、悪意のある連打や同時に何百台からアクセスされた場合に備えて、エンドポイント側にも軽い throttle を入れておくのが堅実です。Cloudflare Workers であれば、IP 単位の Rate Limiting Binding で十分対応できます。

// Cloudflare Worker 例(参考実装)
export default {
  async fetch(req: Request, env: Env) {
    const { success } = await env.RATE_LIMITER.limit({ key: req.headers.get("cf-connecting-ip") ?? "anon" });
    if (!success) return new Response("Too Many Requests", { status: 429 });
    return handleSearch(req, env);
  },
};

クライアント側で 429 を受け取ったときに「少し待ってから試してください」と出せば、ユーザーは混乱しません。サイレントに失敗するのが一番悪いというのは、AdMob で月150万円規模になっていた時期に、レビュー欄から学んだ感覚です。

このパターンと組み合わせると効くもの

  • Rork × TanStack Query データフェッチ&キャッシュ完全ガイド — useSearchResults を TanStack Query に置き換えるとキャッシュも一緒に解決します
  • Rork で作ったアプリの「原因不明クラッシュ」を撲滅する — fetch の reject を握りつぶさない設計と組み合わせるとさらに堅牢になります
  • Rork Maxアプリで実装するエラー耐性の高いAPI通信設計 — リトライ・Circuit Breaker をかぶせる場合の設計

useTransition と組み合わせると検索結果のチラつきがさらに抑えられます。

次の一手

検索画面のソースを開いて、「fetch を呼んでいる箇所が useEffect のクリーンアップ関数で AbortController を呼んでいるか」を1分だけ確認してみてください。私の壁紙アプリでは、ここを直しただけでサーバー負荷が3割ほど落ちました。

宮大工だった祖父が「土台が水平でないと、その上にどれだけ綺麗な柱を立てても歪む」と言っていたのを、検索バーのデバッグをしながら時々思い出します。debounce と AbortController は、見えにくいけれども検索体験の土台です。

シェア

お読みいただきありがとうございます

Rork Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

開発ツール2026-07-04
「続きを読む」を出すべきか、テキストの実測で決める — Rork(Expo)の折りたたみ表示設計
商品説明やレビュー本文を3行で畳み「続きを読む」を出す実装で、短い文にまでトグルが付いてしまう問題を解決します。onTextLayout で実際の行数を測り、はみ出す時だけトグルを出す設計を、iOS と Android の差、展開アニメーション、文字サイズ拡大への配慮まで動くコードでまとめます。
開発ツール2026-06-27
無料プレビューがスクショで丸ごと持ち出される前に — Rork/Expo でスクリーンショットと画面収録を検知して目隠しする設計
Rork/Expo アプリで有料プレビュー画像をスクリーンショットと画面収録から守る実装。expo-screen-capture の限界、isCaptured のネイティブ監視、iOS/Android の差を踏まえた目隠し設計を解説します。
開発ツール2026-06-20
Rork が直せるバグと自分で直すバグを見分ける — エクスポートコードのトリアージ手順
Rork が自力で直すバグと、エクスポートしたReact Native/Expoコードを自分で手当てすべきバグを切り分けるトリアージ手順を、動くコードとともに整理しました。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →