🔍

【React Native】debounceを使ったリアルタイム検索機能の最適化

に公開

はじめに

この記事では、React Nativeでユーザーが文字を入力するたびにリアルタイムで検索結果を表示するインクリメンタルサーチの機能を実装します。
本記事では、以下の内容を解説します。

  • debounce機能による検索最適化
  • 日本語(漢字・ひらがな・カタカナ)対応の検索ロジック

https://zenn.dev/aya1357/articles/c3a65b18ea5531

プロジェクトの作成

# 新しいExpoプロジェクトを作成
npx create-expo-app@latest SearchApp --template blank-typescript
cd SearchApp

# Expo Routerを追加
(defaultのtemplateを使用する場合は既にインストール済のため、不要です。)
npx expo install expo-router react-native-safe-area-context react-native-screens

# debounce用ライブラリを追加
npm install use-debounce

# 開発サーバー起動
npx expo start

https://reactnative.dev/docs/environment-setup

セットアップ

expo-routerの設定

package.json
{
  "main": "expo-router/entry"
}

ファイル構造

app/
├── _layout.tsx
└── index.tsx
data/
└── cities.ts
hooks/
└── useSearch.ts

レイアウトファイルの作成

app/_layout.tsx
import { Stack } from 'expo-router';

export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name='index' options={{ title: '検索アプリ' }} />
    </Stack>
  );
}

App.tsx削除

# 既存のApp.tsxを削除
rm App.tsx

データファイルの作成

data/cities.ts
export type CityData = {
  prefecture: string;
  prefecture_kana: string;
  city: string;
  city_kana: string;
  ward?: string;
  ward_kana?: string;
}

export const citiesData: CityData[] = [
  {
    prefecture: '北海道',
    prefecture_kana: 'ホッカイドウ',
    city: '札幌市',
    city_kana: 'サッポロシ',
  },
  {
    prefecture: '北海道',
    prefecture_kana: 'ホッカイドウ',
    city: '札幌市',
    city_kana: 'サッポロシ',
    ward: '中央区',
    ward_kana: 'チュウオウク',
  },
  {
    prefecture: '北海道',
    prefecture_kana: 'ホッカイドウ',
    city: '札幌市',
    city_kana: 'サッポロシ',
    ward: '北区',
    ward_kana: 'キタク',
  },
  {
    prefecture: '東京都',
    prefecture_kana: 'トウキョウト',
    city: '千代田区',
    city_kana: 'チヨダク',
  },
  {
    prefecture: '東京都',
    prefecture_kana: 'トウキョウト',
    city: '中央区',
    city_kana: 'チュウオウク',
  },
  {
    prefecture: '東京都',
    prefecture_kana: 'トウキョウト',
    city: '港区',
    city_kana: 'ミナトク',
  },
  {
    prefecture: '東京都',
    prefecture_kana: 'トウキョウト',
    city: '新宿区',
    city_kana: 'シンジュクク',
  },
  {
    prefecture: '東京都',
    prefecture_kana: 'トウキョウト',
    city: '渋谷区',
    city_kana: 'シブヤク',
  },
  {
    prefecture: '大阪府',
    prefecture_kana: 'オオサカフ',
    city: '大阪市',
    city_kana: 'オオサカシ',
  },
  {
    prefecture: '神奈川県',
    prefecture_kana: 'カナガワケン',
    city: '横浜市',
    city_kana: 'ヨコハマシ',
  },
];

検索機能の作成

hooks/useSearch.ts
import { useState, useCallback } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import { citiesData } from '../data/cities';

type CityItem = {
  id: string;
  name: string;
  kana: string;
}

export const useSearch = () => {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<CityItem[]>([]);
  const [loading, setLoading] = useState(false);

  // debounce検索(300ms遅延)
  const handleSearch = useDebouncedCallback(
    async (searchTerm: string) => {
      if (!searchTerm.trim()) {
        setResults([]);
        setLoading(false);
        return;
      }

      setLoading(true);
      try {
        const searchResults = await searchCities(searchTerm);
        setResults(searchResults);
      } catch (error) {
        console.error('Search error:', error);
        setResults([]);
      } finally {
        setLoading(false);
      }
    },
    300
  );

  const search = useCallback((term: string) => {
    setQuery(term);
    handleSearch(term);
  }, [handleSearch]);

  return { query, results, loading, search };
};

// 市区町村検索
const searchCities = async (query: string): Promise<CityItem[]> => {
  await new Promise(resolve => setTimeout(resolve, 300));

  return citiesData
    .filter(cityData => {
      const targets = [
        cityData.prefecture,
        cityData.prefecture_kana,
        cityData.city,
        cityData.city_kana,
        cityData.ward,
        cityData.ward_kana,
        convertKanaToHiragana(cityData.prefecture_kana),
        convertKanaToHiragana(cityData.city_kana),
        cityData.ward_kana ? convertKanaToHiragana(cityData.ward_kana) : null,
      ];

      return targets.some(target =>
        target && target.includes(query)
      );
    })
    .map((cityData, index) => ({
      id: index.toString(),
      name: [cityData.prefecture, cityData.city, cityData.ward].join(''),
      kana: [cityData.prefecture_kana, cityData.city_kana, cityData.ward_kana].join(''),
    }));
};

// カタカナをひらがなに変換
const convertKanaToHiragana = (kana: string): string => {
  return kana.replace(/[\u30a1-\u30f6]/g, (match) => {
    const charCode = match.charCodeAt(0) - 0x60;
    return String.fromCharCode(charCode);
  });
};

検索画面の作成

app/index.tsx
import {
  View,
  Text,
  TextInput,
  FlatList,
  TouchableOpacity,
  ActivityIndicator,
} from 'react-native';
import { useSearch } from '../hooks/useSearch';

export default function SearchScreen() {
  const { query, results, loading, search } = useSearch();

  return (
    <View style={{ flex: 1, padding: 16, backgroundColor: '#fff' }}>
      <TextInput
        style={{
          height: 44,
          borderWidth: 1,
          borderColor: '#ddd',
          borderRadius: 8,
          paddingHorizontal: 16,
          marginBottom: 16,
        }}
        placeholder='市区町村を検索'
        value={query}
        onChangeText={search}
        autoFocus
      />

      {loading && <ActivityIndicator style={{ marginVertical: 16 }} />}

      <FlatList
        data={results}
        keyExtractor={(item) => item.id}
        renderItem={({ item }) => (
          <TouchableOpacity
            style={{
              padding: 16,
              borderBottomWidth: 1,
            }}
          >
            <Text>{item.name}</Text>
            <Text>{item.kana}</Text>
          </TouchableOpacity>
        )}
        ListEmptyComponent={
          !loading && query ? (
            <Text style={{ textAlign: 'center', color: '#666', marginTop: 32 }}>
              検索結果がありません
            </Text>
          ) : null
        }
      />
    </View>
  );
}

動作確認

Image from Gyazo

debounceによる最適化の詳細解説

debounceとは?

debounceとは、関数の実行頻度を制限する手法です。ユーザーが高速で入力した際に、入力を停止してから特定の時間が経過した後にのみ処理を実行します。

なぜdebounceが必要なのか?

インクリメンタルサーチで最も注意すべきは、過剰なAPI呼び出しです。

// debounceなしの場合
const handleSearch = (text: string) => {
  // 文字入力のたびに即座に検索実行
  searchAPI(text);
};

この実装では、ユーザーが「品川区」と入力する際に以下のようになります:

入力: 「し」→ API呼び出し 1回目
入力: 「しな」→ API呼び出し 2回目  
入力: 「しなが」→ API呼び出し 3回目
入力: 「しながわ」→ API呼び出し 4回目
入力: 「しながわく」→ API呼び出し 5回目

問題:
一文字入力するたびにサーバーとの通信が発生してしまいます。少数のユーザーなら影響は軽微ですが、数千人、数万人が同時に使用した場合に負荷が高くなり、アプリ全体のパフォーマンスが低下してしまいます。
debounceは、こうした連続する処理を制御するテクニックです。ユーザーが入力を一時停止したタイミングでのみ検索を実行することで、無駄な通信を大幅に削減できます。

debounceによる解決策

const handleSearch = useDebouncedCallback(
  async (searchTerm: string) => {
    // 300ms待機後に検索実行
    const results = await searchAPI(searchTerm);
    setResults(results);
  },
  300 // 遅延時間(ミリ秒)
);

debounceの動作:

  1. ユーザーが文字を入力
  2. 300ms のタイマーが開始
  3. タイマー終了前に新しい入力があればタイマーをリセット
  4. 300ms 経過後、最後の入力内容で検索実行

効果の比較:
debounceなしの場合: 「しながわく」→ 5回のAPI呼び出し
debounce有りの場合: 「しながわく」→ 1回のAPI呼び出し

この仕組みにより、ユーザーが連続して入力している間は検索を待機し、入力が落ち着いてから実際の検索を行うことができます。

debounceのメリット・デメリット

✅ メリット:

  • サーバー負荷軽減: APIコール数を大幅削減
  • ユーザー体験向上: 無駄な処理でUIがブロックされない
  • コスト削減: API利用料金の節約

⚠️ デメリット:

  • 遅延感: 即座に反応しない
  • 実装複雑化: 状態管理が少し複雑になる
  • デバッグ困難: タイミングの問題が発生する可能性

まとめ

React Nativeは英語のドキュメントが多いため、日々悪戦苦闘中です。
もっと良いやり方があるか模索していきたい所です。

Discussion