【TechGrowUp】 https://techgrowup.net エンジニアを強くする Sun, 31 May 2026 14:19:42 +0000 ja hourly 1 https://wordpress.org/?v=7.0.2 https://techgrowup.net/wp-content/uploads/2021/05/hp-icon-150x150.png 【TechGrowUp】 https://techgrowup.net 32 32 FlutterでGoogle Places APIが403になる原因と対策|アプリ制限付きAPIキーはSDK経由が安全 https://techgrowup.net/flutter-google-places-api-403-app-restriction/ https://techgrowup.net/flutter-google-places-api-403-app-restriction/?noamp=mobile#respond Sun, 31 May 2026 14:19:41 +0000 https://techgrowup.net/?p=2923 はじめに

FlutterアプリでGoogle Places APIを使って、目的地検索や場所候補の補完を実装しようとしたとき、意外とハマりやすいのが APIキー制限 です。

特に、Google Cloud ConsoleでAPIキーに Androidアプリ制限iOSアプリ制限 を設定したうえで、FlutterのDartコードからPlaces APIをHTTPで直接呼び出すと、403REQUEST_DENIED が返ってくることがあります。

一見すると「FlutterでGoogle Places APIが使えないの?」と思ってしまいますが、原因はFlutterそのものではありません。結論から言うと、Places APIのWeb Serviceを直接叩く場合、Android/iOSアプリ制限付きAPIキーは基本的に使えません。

モバイルアプリでAndroid/iOSアプリ制限付きAPIキーを使いたい場合は、Androidなら Places SDK for Android、iOSなら Places SDK for iOS を使う必要があります。

この記事では、FlutterでGoogle Places APIを使うときに403が返る理由と、Flutterアプリで安全にPlaces検索を実装する方法を整理します。

また、筆者が開発している旅行準備アプリ 「旅じたく」 でも、目的地検索や場所候補の入力体験を改善するためにGoogle Places APIの活用を検討しています。

この記事は単なる一般論ではなく、旅じたくの目的地検索機能を改善するための技術検証 として、FlutterでGoogle Places APIをどう扱うべきかを整理したものです。

旅じたくについて
「旅じたく」は、旅行前の準備をひとつにまとめられる旅行準備アプリです。持ち物、予定、予約メモ、予算、行きたい場所などを旅行ごとに整理できます。
今回扱うGoogle Places APIは、今後「目的地入力」や「行きたい場所」の入力体験を改善するための技術検証として位置づけています。

旅じたくアプリ – App Store
NINJA SYSTEMの「旅じたく」をApp Storeでダウンロードしてください。スクリーンショット、評価とレビュー、ユーザのヒント、「旅じたく」に似たゲームを見ることなどができます。
旅じたく – 旅行準備・持ち物 – Google Play のアプリ
旅行前の持ち物・予定・予算・予約をまとめて整理

この記事でわかること

この記事では、以下の内容を解説します。

  • FlutterからPlaces APIを直接叩くと403になる理由
  • Android/iOSアプリ制限付きAPIキーが使えるケース・使えないケース
  • FlutterでPlaces SDKを使うための選択肢
  • flutter_google_places_sdk を使うサンプル
  • MethodChannelでネイティブSDKを呼び出す実装イメージ
  • 旅行アプリ「旅じたく」での活用イメージ
  • 個人開発アプリでどの方式を選ぶべきか

前提:Google Places APIには複数の使い方がある

Google Places APIと一口に言っても、実際にはいくつかの利用方法があります。

代表的には以下です。

利用方法主な対象呼び出し方
Places API / Places API (New) Web Serviceサーバー・Web ServiceHTTPSリクエスト
Places SDK for AndroidAndroidアプリAndroidネイティブSDK
Places SDK for iOSiOSアプリiOSネイティブSDK
Places Library, Maps JavaScript APIWebJavaScript

Flutterアプリの場合、ついDartのhttpパッケージなどで以下のように呼び出したくなります。

final response = await http.get(
  Uri.parse('https://places.googleapis.com/v1/places:searchText?...'),
);

しかし、この呼び方は Places APIのWeb Serviceを直接叩いている 形です。

つまり、Androidアプリ上で動いていたとしても、Google側から見ると「Android SDK経由の呼び出し」ではなく、ただのHTTPSリクエストです。

ここが今回の落とし穴です。


なぜ403になるのか

Google Cloud Consoleでは、APIキーに対して以下のようなアプリケーション制限を設定できます。

  • HTTPリファラー制限
  • IPアドレス制限
  • Androidアプリ制限
  • iOSアプリ制限

Androidアプリ制限の場合は、パッケージ名と署名証明書のSHA-1フィンガープリントを設定します。

iOSアプリ制限の場合は、Bundle IDを設定します。

一見すると、FlutterアプリでもAndroid/iOSアプリとして動いているので、この制限が使えそうに見えます。

しかし、Places APIのWeb ServiceをDartのHTTP通信で直接呼び出した場合、そのリクエストはネイティブSDK経由ではありません。

そのため、Android/iOSアプリ制限付きAPIキーを使っていると、Google側でそのキーを正しく検証できず、403REQUEST_DENIED になります。


NG例:FlutterからREST APIを直接呼び出す

例えば、以下のようなコードです。

import 'dart:convert';
import 'package:http/http.dart' as http;

class BadPlacesApiClient {
  BadPlacesApiClient(this.apiKey);

  final String apiKey;

  Future<void> searchText(String query) async {
    final uri = Uri.https(
      'places.googleapis.com',
      '/v1/places:searchText',
    );

    final response = await http.post(
      uri,
      headers: {
        'Content-Type': 'application/json',
        'X-Goog-Api-Key': apiKey,
        'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress',
      },
      body: jsonEncode({
        'textQuery': query,
        'languageCode': 'ja',
        'regionCode': 'JP',
      }),
    );

    if (response.statusCode == 403) {
      throw Exception('403 Forbidden: APIキー制限により拒否されている可能性があります');
    }

    print(response.body);
  }
}

このコード自体が必ず悪いわけではありません。

ただし、Android/iOSアプリ制限付きAPIキーを使う構成とは相性が悪い です。

REST APIを直接使うなら、基本的にはサーバー側から呼び出す構成にするのが安全です。


FlutterでGoogle Placesを使う選択肢

FlutterでPlaces検索を実装する場合、現実的な選択肢は大きく4つあります。

方式概要メリットデメリット
DartからREST直叩きFlutter内でHTTP通信実装が簡単アプリ制限キーが使えない。キー漏洩リスクが高い
Flutterプラグイン利用ネイティブSDKをラップしたプラグインを使う実装が比較的簡単。アプリ制限キーを使いやすい非公式プラグインの場合、保守状況に依存
MethodChannel自作Android/iOSのPlaces SDKを自前で呼ぶ制御しやすい。長期運用向きAndroid/iOS両方の実装が必要
Backend ProxyCloudflare WorkersやFirebase Functions等で中継APIキーをアプリに含めなくてよいサーバー実装・運用が必要

個人開発アプリでは、まず 既存のFlutterプラグインを検証 し、要件に合わなければ MethodChannel自作、サーバー側の機能が増えてきたら Backend Proxy を検討する流れが現実的です。


方法1:flutter_google_places_sdkを使う

Flutter向けにGoogle公式のPlaces SDKがあるわけではありませんが、ネイティブSDKをラップしたFlutterプラグインはいくつか存在します。

その一つが flutter_google_places_sdk です。

このプラグインは、Android/iOSそれぞれのネイティブライブラリを利用する方針のため、単純なHTTPリクエスト方式よりもアプリ制限付きAPIキーと相性が良いです。

pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  flutter_google_places_sdk: ^0.4.3

※バージョンは例です。実際に導入する際はpub.devで最新バージョンと変更履歴を確認してください。

検索結果用モデル

class PlaceSuggestion {
  const PlaceSuggestion({
    required this.placeId,
    required this.primaryText,
    required this.secondaryText,
  });

  final String placeId;
  final String primaryText;
  final String secondaryText;
}

Places検索サービス

import 'package:flutter_google_places_sdk/flutter_google_places_sdk.dart';

class PlacesSearchService {
  PlacesSearchService({required String apiKey})
      : _places = FlutterGooglePlacesSdk(apiKey);

  final FlutterGooglePlacesSdk _places;

  Future<List<PlaceSuggestion>> search(String input) async {
    if (input.trim().isEmpty) {
      return const [];
    }

    final result = await _places.findAutocompletePredictions(
      input,
      countries: const ['JP'],
    );

    return result.predictions.map((prediction) {
      return PlaceSuggestion(
        placeId: prediction.placeId,
        primaryText: prediction.primaryText,
        secondaryText: prediction.secondaryText,
      );
    }).toList();
  }
}

UI側の例

import 'package:flutter/material.dart';

class DestinationSearchField extends StatefulWidget {
  const DestinationSearchField({super.key, required this.service});

  final PlacesSearchService service;

  @override
  State<DestinationSearchField> createState() => _DestinationSearchFieldState();
}

class _DestinationSearchFieldState extends State<DestinationSearchField> {
  final _controller = TextEditingController();
  List<PlaceSuggestion> _suggestions = const [];
  bool _loading = false;

  Future<void> _onChanged(String value) async {
    setState(() => _loading = true);

    try {
      final suggestions = await widget.service.search(value);
      if (!mounted) return;
      setState(() => _suggestions = suggestions);
    } catch (e) {
      if (!mounted) return;
      setState(() => _suggestions = const []);
      debugPrint('Places search failed: $e');
    } finally {
      if (mounted) {
        setState(() => _loading = false);
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        TextField(
          controller: _controller,
          decoration: InputDecoration(
            labelText: '目的地',
            hintText: '例:札幌、京都駅、東京タワー',
            suffixIcon: _loading
                ? const Padding(
                    padding: EdgeInsets.all(12),
                    child: SizedBox(
                      width: 16,
                      height: 16,
                      child: CircularProgressIndicator(strokeWidth: 2),
                    ),
                  )
                : null,
          ),
          onChanged: _onChanged,
        ),
        const SizedBox(height: 8),
        ..._suggestions.map(
          (suggestion) => ListTile(
            title: Text(suggestion.primaryText),
            subtitle: Text(suggestion.secondaryText),
            onTap: () {
              _controller.text = suggestion.primaryText;
              setState(() => _suggestions = const []);

              // ここでplaceIdを保存しておくと、あとから詳細情報を取得しやすい
              debugPrint('Selected placeId: ${suggestion.placeId}');
            },
          ),
        ),
      ],
    );
  }
}

注意点

この方法は実装が簡単ですが、プラグインはGoogle公式ではないため、以下は確認しておくべきです。

  • メンテナンス頻度
  • Android/iOSの対応状況
  • Places API (New) への対応状況
  • 必要な機能が揃っているか
  • ライセンス
  • FlutterやネイティブSDKのバージョン追従

個人開発のMVPや小規模アプリでは十分現実的ですが、長期的に細かい制御をしたい場合はMethodChannel自作も候補になります。


方法2:MethodChannelでネイティブSDKを呼ぶ

既存プラグインに依存したくない場合は、FlutterからMethodChannelでAndroid/iOSのPlaces SDKを呼び出す方法があります。

構成は以下のようになります。

Flutter
  ↓ MethodChannel
Android: Places SDK for Android
iOS: Places SDK for iOS

Google Places

Flutter側は共通のインターフェースを持ち、Android/iOS側でそれぞれネイティブSDKを実装します。


Flutter側:MethodChannelクライアント

import 'package:flutter/services.dart';

class NativePlacesService {
  static const _channel = MethodChannel('jp.ninja.tripmemo/places');

  Future<List<PlaceSuggestion>> findAutocompletePredictions(String query) async {
    if (query.trim().isEmpty) {
      return const [];
    }

    final result = await _channel.invokeMethod<List<dynamic>>(
      'findAutocompletePredictions',
      {
        'query': query,
        'country': 'JP',
      },
    );

    if (result == null) {
      return const [];
    }

    return result.map((item) {
      final map = Map<String, dynamic>.from(item as Map);
      return PlaceSuggestion(
        placeId: map['placeId'] as String? ?? '',
        primaryText: map['primaryText'] as String? ?? '',
        secondaryText: map['secondaryText'] as String? ?? '',
      );
    }).toList();
  }
}

Android側:Kotlin実装イメージ

android/app/build.gradle.kts にPlaces SDKを追加します。

dependencies {
    implementation("com.google.android.libraries.places:places:5.1.1")
}

※バージョンは例です。実際には最新のPlaces SDK for Androidのバージョンを確認してください。

MainActivity.kt の例です。

package jp.ninja.tripmemo

import android.os.Bundle
import com.google.android.libraries.places.api.Places
import com.google.android.libraries.places.api.model.AutocompletePrediction
import com.google.android.libraries.places.api.net.FindAutocompletePredictionsRequest
import com.google.android.libraries.places.api.net.PlacesClient
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity : FlutterActivity() {
    private val channelName = "jp.ninja.tripmemo/places"
    private lateinit var placesClient: PlacesClient

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val apiKey = BuildConfig.PLACES_API_KEY
        if (!Places.isInitialized()) {
            Places.initializeWithNewPlacesApiEnabled(applicationContext, apiKey)
        }
        placesClient = Places.createClient(this)
    }

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
            .setMethodCallHandler { call, result ->
                when (call.method) {
                    "findAutocompletePredictions" -> {
                        val query = call.argument<String>("query").orEmpty()
                        val country = call.argument<String>("country") ?: "JP"
                        findAutocompletePredictions(query, country, result)
                    }
                    else -> result.notImplemented()
                }
            }
    }

    private fun findAutocompletePredictions(
        query: String,
        country: String,
        result: MethodChannel.Result
    ) {
        val request = FindAutocompletePredictionsRequest.builder()
            .setQuery(query)
            .setCountries(country)
            .build()

        placesClient.findAutocompletePredictions(request)
            .addOnSuccessListener { response ->
                val predictions = response.autocompletePredictions.map { prediction ->
                    mapPrediction(prediction)
                }
                result.success(predictions)
            }
            .addOnFailureListener { exception ->
                result.error(
                    "PLACES_ERROR",
                    exception.message,
                    null,
                )
            }
    }

    private fun mapPrediction(prediction: AutocompletePrediction): Map<String, String> {
        return mapOf(
            "placeId" to prediction.placeId,
            "primaryText" to prediction.getPrimaryText(null).toString(),
            "secondaryText" to prediction.getSecondaryText(null).toString(),
            "fullText" to prediction.getFullText(null).toString(),
        )
    }
}

※SDKバージョンによってメソッド名や戻り値の型が変わる可能性があります。実装時は利用しているPlaces SDKのリファレンスに合わせて調整してください。


Android側:APIキー管理

本番ではAPIキーをソースコードに直書きしないようにします。

例えば、local.properties に以下のように定義します。

PLACES_API_KEY=YOUR_API_KEY

Gradle側で BuildConfig.PLACES_API_KEY として参照できるようにします。

android {
    defaultConfig {
        buildConfigField(
            "String",
            "PLACES_API_KEY",
            "\"${project.findProperty("PLACES_API_KEY") ?: ""}\""
        )
    }
}

実際にはGoogle公式が案内しているSecrets Gradle Pluginの利用も検討できます。


iOS側:Swift実装イメージ

iOS側では、Places SDK for iOSを導入し、AppDelegateなどでAPIキーを設定します。

import UIKit
import Flutter
import GooglePlaces

@main
@objc class AppDelegate: FlutterAppDelegate {
  private let channelName = "jp.ninja.tripmemo/places"

  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    GMSPlacesClient.provideAPIKey("YOUR_API_KEY")

    let controller = window?.rootViewController as! FlutterViewController
    let channel = FlutterMethodChannel(
      name: channelName,
      binaryMessenger: controller.binaryMessenger
    )

    channel.setMethodCallHandler { [weak self] call, result in
      switch call.method {
      case "findAutocompletePredictions":
        guard
          let args = call.arguments as? [String: Any],
          let query = args["query"] as? String
        else {
          result(FlutterError(code: "INVALID_ARGUMENT", message: "query is required", details: nil))
          return
        }

        let country = args["country"] as? String ?? "JP"
        self?.findAutocompletePredictions(query: query, country: country, result: result)

      default:
        result(FlutterMethodNotImplemented)
      }
    }

    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }

  private func findAutocompletePredictions(
    query: String,
    country: String,
    result: @escaping FlutterResult
  ) {
    let token = GMSAutocompleteSessionToken()
    let filter = GMSAutocompleteFilter()
    filter.countries = [country]

    let request = GMSAutocompleteRequest(query: query)
    request.sessionToken = token
    request.filter = filter

    GMSPlacesClient.shared().fetchAutocompleteSuggestions(from: request) { suggestions, error in
      if let error = error {
        result(FlutterError(code: "PLACES_ERROR", message: error.localizedDescription, details: nil))
        return
      }

      let values = suggestions?.compactMap { suggestion -> [String: String]? in
        guard let place = suggestion.placeSuggestion else {
          return nil
        }

        return [
          "placeId": place.placeID,
          "primaryText": place.attributedPrimaryText.string,
          "secondaryText": place.attributedSecondaryText?.string ?? "",
          "fullText": place.attributedFullText.string,
        ]
      } ?? []

      result(values)
    }
  }
}

※iOS SDKもバージョンによってAPIが変わる可能性があります。特にPlaces SDK for iOSとPlaces Swift SDKではAPIの書き方が異なるため、導入バージョンに合わせて調整してください。


方法3:Backend Proxy方式はどうか

もう一つの選択肢として、Flutterアプリから直接Google Placesを呼ばず、Cloudflare WorkersやFirebase Functionsなどの自前バックエンドを経由する方法もあります。

Flutter

Backend / Cloudflare Workers / Firebase Functions

Google Places API

この方式のメリットは、Google APIキーをアプリに含めなくてよいことです。

一方で、以下のような設計が必要になります。

  • 自前APIの認証
  • レート制限
  • 不正利用対策
  • キャッシュ
  • ログ監視
  • Google APIへのリクエスト制限

個人開発アプリでは、最初からBackend Proxyを用意すると実装量が増えます。

ただし、将来的にAIによる旅程生成やサーバー側のランキング処理、ユーザーごとの検索履歴管理などを入れるなら、Backend Proxy方式も有力です。


旅じたくではどう使うか

ここからは、実際に筆者が開発している旅行準備アプリ 「旅じたく」 を例に、Google Places APIをどう活用できるかを考えてみます。

旅じたくは、旅行前の準備をまとめて整理できるアプリです。

旅行ごとに、以下のような情報を管理できます。

  • 持ち物
  • 予定
  • 予約メモ
  • 予算
  • 行きたい場所
  • 関連リンク
  • 旅行メモ

旅行準備では、情報がいろいろな場所に散らばりがちです。

例えば、宿泊予約はメール、行きたい場所はGoogle Maps、予算はメモ、持ち物は別のチェックリスト、といった形です。

旅じたくでは、こうした旅行前の情報を 旅行単位でまとめて管理する ことを目指しています。

その中で、Google Places APIが特に活きるのが 目的地検索行きたい場所の入力 です。


目的地入力でGoogle Places APIを使うメリット

旅じたくでは、旅行作成時に目的地を入力します。

ここでGoogle Places APIを使うと、以下のような体験改善ができます。

  • 「札幌」「京都駅」「成田空港」などの候補を補完できる
  • placeId を保存して、後から住所や緯度経度を取得できる
  • 地図表示や天気取得と連携しやすくなる
  • 入力ゆれを減らせる
  • 行きたい場所機能にも応用できる
  • 旅行テンプレート機能にも活用できる

例えば、ユーザーが「京都」と入力したときに、以下のような候補を表示できます。

京都駅
京都府京都市下京区

清水寺
京都府京都市東山区

嵐山
京都府京都市右京区

ユーザーが候補を選択したら、アプリ内では表示名だけでなく placeId も保存しておくと便利です。

class TripDestination {
  const TripDestination({
    required this.name,
    required this.placeId,
    this.address,
    this.latitude,
    this.longitude,
  });

  final String name;
  final String placeId;
  final String? address;
  final double? latitude;
  final double? longitude;
}

最初のMVPでは、Autocompleteで選択した placeId と表示名だけ保存し、必要になったタイミングでPlace Detailsを取得する形でも十分です。


旅じたくでの実装イメージ

旅じたくで目的地検索を入れるなら、まずは以下のような流れがよさそうです。

旅行作成画面

目的地入力

Google Places Autocompleteで候補表示

ユーザーが候補を選択

旅行データにplaceIdと表示名を保存

必要に応じてPlace Detailsで住所・緯度経度を取得

地図・天気・行きたい場所機能へ連携

アプリ内のデータとしては、最初からすべての詳細情報を保存する必要はありません。

まずは以下のような最小構成で十分です。

class TripPlace {
  const TripPlace({
    required this.placeId,
    required this.name,
    this.address,
  });

  final String placeId;
  final String name;
  final String? address;
}

後から地図表示や天気取得を強化したくなったタイミングで、緯度経度などを追加していくのが扱いやすいです。

class TripPlaceDetail {
  const TripPlaceDetail({
    required this.placeId,
    required this.name,
    required this.latitude,
    required this.longitude,
    this.address,
  });

  final String placeId;
  final String name;
  final double latitude;
  final double longitude;
  final String? address;
}

こうしておくと、旅じたくの中で以下のような拡張がしやすくなります。

  • 旅行先の天気表示
  • 行きたい場所の地図表示
  • 現在地から目的地までの導線
  • 旅行中モードで次の目的地を表示
  • 旅程テンプレートでスポット候補を扱う
  • 旅行前の準備状況と目的地情報の連携

単なる「場所検索」ではなく、旅行準備アプリ全体の使いやすさにつながる機能になります。


実装時の注意点

Google Places APIは便利ですが、実装時にはいくつか注意点があります。

1. APIキーは分ける

Android、iOS、Web、サーバーで同じAPIキーを使い回さない方が安全です。

例えば以下のように分けます。

places-android-key
places-ios-key
places-server-key

それぞれに適切なアプリケーション制限とAPI制限を設定します。

2. API制限を必ず設定する

APIキーには、利用するAPIだけを許可するAPI制限を設定します。

Placesで使うキーなら、Places関連のAPIだけを許可します。

APIキーが漏れたときに、他のGoogle Cloud APIまで使われるリスクを下げるためです。

3. Autocompleteはデバウンスする

Autocompleteはユーザーが文字を入力するたびにリクエストが発生しやすい機能です。

そのため、入力のたびに即リクエストするのではなく、300〜500ms程度のデバウンスを入れるのがおすすめです。

Timer? _debounce;

void onSearchTextChanged(String value) {
  _debounce?.cancel();
  _debounce = Timer(const Duration(milliseconds: 400), () {
    _search(value);
  });
}

4. 空文字や短すぎる文字では検索しない

1文字入力されるたびに検索すると、不要なリクエストが増えます。

if (query.trim().length < 2) {
  return const [];
}

5. セッショントークンを意識する

Places Autocompleteでは、入力候補の取得からユーザーの選択、詳細取得までを1つのセッションとして扱うために、セッショントークンを使えます。

料金やリクエスト管理にも関わるため、AutocompleteとPlace Detailsを組み合わせる場合は、セッショントークンの扱いを確認しておくとよいです。

6. 取得するフィールドを絞る

Place Detailsを取得する場合は、必要なフィールドだけを指定します。

例えば、アプリで必要なのが名前・住所・緯度経度だけなら、それ以外の情報を無理に取得しないようにします。


個人開発アプリならどの方式がよいか

個人開発アプリでFlutterからGoogle Places APIを使うなら、個人的には以下の順番で検討するのがよいと思います。

まずはFlutterプラグインを検証する

最初からMethodChannelを自作すると、Android/iOSそれぞれの実装が必要になります。

そのため、まずは flutter_google_places_sdk のようなネイティブSDKラッパー系プラグインで、目的の機能が実現できるか確認するのが現実的です。

プラグインで足りなければMethodChannelを検討する

プラグインの対応状況やAPIの自由度に不満が出てきたら、MethodChannelで自前実装するのがよいです。

特に、以下のような場合は自作の価値があります。

  • Android/iOSで細かく挙動を合わせたい
  • 最新のPlaces SDKにすぐ追従したい
  • 必要なフィールドや検索条件を細かく制御したい
  • アプリの中核機能として長期的に運用したい

サーバー側機能が増えたらBackend Proxyを検討する

AIによる旅程生成や、サーバー側でのスポット推薦、検索履歴の分析などを行う場合は、Backend Proxy方式も候補になります。

ただし、単純な目的地検索だけなら、最初からサーバーを挟む必要はないケースも多いと思います。


まとめ

FlutterでGoogle Places APIを使うとき、DartからREST APIを直接呼び出すだけなら実装は簡単です。

しかし、その場合はAndroid/iOSアプリ制限付きAPIキーとの相性が悪く、403になることがあります。

モバイルアプリで安全にPlacesを使いたい場合は、以下のどれかを選ぶのが現実的です。

  • ネイティブSDKを利用するFlutterプラグインを使う
  • MethodChannelでPlaces SDK for Android / iOSを呼び出す
  • 自前Backendを経由してPlaces APIを呼び出す

個人開発アプリでは、まずFlutterプラグインで小さく検証し、必要に応じてMethodChannel化するのがバランスの良い選択だと思います。

「旅じたく」のような旅行準備アプリでは、目的地検索、行きたい場所、地図表示、天気連携など、Places APIを活用できる場面は多くあります。

ただし、Google Places APIは便利な反面、APIキー制限や課金設計を誤るとトラブルになりやすい部分でもあります。

FlutterでPlaces検索を実装する場合は、最初に どの呼び出し方式なら、どのAPIキー制限が使えるのか を整理しておくのがおすすめです。


旅じたくの紹介

最後に少しだけ、今回の実装検討の背景になっているアプリを紹介します。旅じたく は、旅行前の準備をまとめて整理できるアプリです。旅行ごとに、持ち物、予定、予約メモ、予算、行きたい場所、関連リンクなどを管理できます。

旅行の準備は、メモアプリ、カレンダー、地図、予約メールなどに情報が散らばりがちです。旅じたくでは、それらを旅行単位でまとめて管理できるようにすることを目指しています。

今後は、Google Places APIを活用した目的地検索や場所候補の補完によって、旅行作成時の入力体験も改善していく予定です。

旅行前の準備を少しでもラクにしたい方は、ぜひ使ってみてください。

旅じたくアプリ – App Store
NINJA SYSTEMの「旅じたく」をApp Storeでダウンロードしてください。スクリーンショット、評価とレビュー、ユーザのヒント、「旅じたく」に似たゲームを見ることなどができます。
旅じたく – 旅行準備・持ち物 – Google Play のアプリ
旅行前の持ち物・予定・予算・予約をまとめて整理

補足:おすすめの実装方針

筆者なら、旅行系アプリのMVPでは次の順番で進めます。

  1. flutter_google_places_sdk でAutocompleteの動作検証
  2. Android/iOSでアプリ制限付きAPIキーが正しく使えるか確認
  3. 目的地名とplaceIdだけ保存
  4. 必要になったらPlace Detailsで住所・緯度経度を取得
  5. プラグインの制約が出たらMethodChannelへ移行
  6. サーバー側処理が増えたらBackend Proxyを検討

最初から大きく作りすぎず、まずは目的地入力の体験改善に絞って導入するのが良いと思います。旅行アプリでは、目的地入力のしやすさがそのまま旅行作成のしやすさにつながります。小さな改善ですが、ユーザー体験への効果はかなり大きいはずです。

]]>
https://techgrowup.net/flutter-google-places-api-403-app-restriction/feed/ 0
QRコード詐欺とは?スマホでできるフィッシング対策とURL確認のコツ https://techgrowup.net/qr-code-scam-quishing-url-check/ https://techgrowup.net/qr-code-scam-quishing-url-check/?noamp=mobile#respond Sat, 23 May 2026 16:11:26 +0000 https://techgrowup.net/?p=2913 QRコードは、飲食店のメニュー、決済、イベント受付、チラシ、荷物確認など、日常のさまざまな場面で使われるようになりました。スマホのカメラを向けるだけでWebサイトを開けるため、とても便利です。

一方で、QRコードを悪用して偽サイトへ誘導する「QRコード詐欺」や「クイッシング」と呼ばれる手口にも注意が必要です。クイッシングは、QRコードとフィッシングを組み合わせた言葉で、QRコードから偽サイトへ誘導し、ID・パスワードやクレジットカード情報などを盗もうとする手口です。

QRコードは見た目だけではリンク先が分かりません。この記事では、QRコード詐欺の仕組み、よくある手口、スマホでできる対策、URL確認のコツを解説します。

QRコード詐欺・クイッシングとは

QRコード詐欺とは、QRコードを使って偽サイトや不正なページへ誘導するフィッシング詐欺の一種です。

たとえば、銀行、クレジットカード会社、決済サービス、配送業者、ECサイトなどを装ったページに誘導され、ログイン情報やカード情報の入力を求められるケースがあります。見た目が本物に似ていても、URLのドメインが公式サイトと違う場合があります。

IPAも、QRコードを悪用したフィッシング詐欺を「クイッシング」として紹介しており、QRコードから偽サイトに誘導され、ID・パスワードやクレジットカード情報を詐取される可能性があると説明しています。

重要なのは、QRコードそのものが危険なのではなく、QRコードの先にあるURLやWebサイトが危険な場合があるという点です。QRコードは便利ですが、「読み取った後にどこへ移動するのか」を確認する習慣が大切です。

QRコード詐欺が厄介な理由

QRコード詐欺が厄介なのは、読み取る前にリンク先を判断しづらいことです。通常のURLであれば、文字列を見て「公式サイトと違う」「ドメインが怪しい」と気づけることがあります。しかしQRコードは、画像として表示されるため、見た目だけではリンク先が分かりません。

また、QRコードはメールやPDF、チラシ、ポスターなどに画像として埋め込まれます。メール本文に直接URLを書かず、QRコードとして表示することで、リンク検査をすり抜けようとする手口もあります。IPAも、QRコードは画像であるため、メール内URLを検査するフィルタリング機能をすり抜ける可能性があると説明しています。

さらに、物理的な場所に貼られたQRコードが差し替えられる可能性もあります。駐車場、飲食店、公共スペース、イベント会場などで、正規のQRコードの上から偽のQRコードシールが貼られるケースも考えられます。スマホで気軽に読み取れるからこそ、ひと手間の確認が重要です。

よくあるQRコード詐欺の手口

QRコード詐欺には、いくつかのパターンがあります。

まず注意したいのは、駐車場や店舗、イベント会場などに貼られたQRコードです。支払いページや案内ページを装い、偽の決済サイトへ誘導される可能性があります。特に、QRコードがシールで上貼りされている場合や、周囲の掲示物と雰囲気が違う場合は注意が必要です。

次に、宅配業者やECサイトを装った手口です。「荷物の確認」「再配達手続き」「注文内容の確認」などを口実にQRコードを読み取らせ、偽サイトへ誘導するケースがあります。そこから住所、電話番号、ログイン情報、カード情報などの入力を求められることがあります。

また、メールやPDFにQRコードを掲載し、スマホで読み取らせる手口もあります。PCで受け取ったメールをスマホで読み取らせることで、会社や学校のセキュリティ対策を回避しようとするケースもあります。QRコードを読み取った先でログインや決済を求められた場合は、一度立ち止まることが大切です。

QRコードを開く前にURLを確認したい方へ

「あんしんQRコードチェッカー」は、QRコードを読み取ったあとにURLを確認してから開けるアプリです。怪しいURLをすぐ開かず、ワンクッション置いて確認できます。

怪しいQRコードを見分けるチェックポイント

QRコードを読み取ったら、まずURLを確認しましょう。特に銀行、クレジットカード、決済サービス、ECサイト、配送業者などを名乗るページでは、公式サイトのドメインと一致しているかを見ることが重要です。

たとえば、公式サイトに見えても、ドメインの一部が不自然だったり、余計な単語が入っていたり、見慣れないサブドメインになっていたりする場合があります。短縮URLも、実際のリンク先が分かりづらいため注意が必要です。

また、読み取り後すぐに個人情報、ID・パスワード、クレジットカード情報の入力を求められる場合は慎重に判断しましょう。FTCも、QRコードの先が本物そっくりの偽サイトである可能性や、入力した情報が盗まれる可能性について注意喚起しています。

物理的なQRコードの場合は、シールで上から貼られていないか、掲示物に違和感がないかも確認しましょう。不安がある場合は、その場で店舗や管理者に確認するのが安全です。

スマホでできるフィッシング対策

スマホでできる基本対策は、「QRコードを読み取ったらすぐ開かない」ことです。標準カメラでもURLが表示されることはありますが、勢いでタップしてしまうと、そのままブラウザで開いてしまいます。

銀行、決済サービス、ECサイト、配送業者などの重要なサービスは、QRコードやメール内リンクからではなく、公式アプリやブックマークからアクセスする方が安全です。特にログインや決済を求められた場合は、読み取ったURLをそのまま信用せず、公式ルートから入り直すことをおすすめします。

また、スマホのOSやブラウザを最新の状態に保つことも大切です。古いOSやアプリを使い続けると、既知の脆弱性を悪用されるリスクが高まります。

QRコードは便利な仕組みですが、リンク先を確認する習慣がないと、偽サイトに誘導されても気づきにくい場合があります。URLを見てから開く、必要ならコピーして確認する、怪しい場合は開かない。この基本動作を意識するだけでもリスクを下げられます。

「あんしんQRコードチェッカー」でURLを確認してから開く

QRコードを読み取ったあと、すぐにURLを開くのではなく、一度リンク先を確認したい場合は、QRGuardも選択肢のひとつです。

あんしんQRコードチェッカーは、QRコードを読み取ったあとにURLを表示し、開く前に確認できるアプリです。読み取ったURLをコピーしたり、履歴から見返したりすることもできます。QRコードを読み取るたびに、ワンクッション置いてURLを見る習慣を作りたい場合に役立ちます。

ただし、あんしんQRコードチェッカーはすべての危険URLを自動判定するセキュリティソフトではありません。最終的には、ユーザー自身がURLやページ内容を確認する必要があります。

QRコードは便利ですが、リンク先が見えにくいという特徴があります。読み取ったらすぐ開くのではなく、まずURLを確認する。このひと手間が、スマホでできる基本的なフィッシング対策になります。

QRコードを開く前にURLを確認したい方へ

「あんしんQRコードチェッカー」は、QRコードを読み取ったあとにURLを確認してから開けるアプリです。怪しいURLをすぐ開かず、ワンクッション置いて確認できます。

]]>
https://techgrowup.net/qr-code-scam-quishing-url-check/feed/ 0
QRコードを開く前にリンク先を確認できるアプリ「あんしんQRチェッカー」を作りました https://techgrowup.net/qr-code-url-check-app/ https://techgrowup.net/qr-code-url-check-app/?noamp=mobile#respond Thu, 21 May 2026 14:25:52 +0000 https://techgrowup.net/?p=2908 QRコードはとても便利ですが、読み取ったあとにそのままリンクを開くのが少し不安な場面があります。

たとえば、以下のようなケースです。

  • チラシやポスターに載っているQRコード
  • メールや資料に貼られているQRコード
  • 見慣れない短縮URLにつながるQRコード
  • 開く前にリンク先のドメインを確認したいQRコード
  • QRコードの中身がURLなのかテキストなのか確認したい場合

最近はQRコードを読み取る機会が増えていますが、QRコード自体は見た目だけでは中身が分かりません。

そこで、QRコードやバーコードを読み取ったあと、開く前に内容を確認できるアプリとして 「あんしんQRチェッカー」 を作りました。

あんしんQRチェッカーとは

あんしんQRチェッカー は、QRコードやバーコードを読み取り、開く前に内容をプレビューできるシンプルな読み取りアプリです。

読み取った内容がURLの場合は、リンク先のドメインを表示します。

そのため、すぐにブラウザで開くのではなく、内容を確認してから開くかどうかを判断できます。

あんしんQRチェッカーアプリ – App Store
NINJA SYSTEMの「あんしんQRチェッカー」をApp Storeでダウンロードしてください。スクリーンショット、評価とレビュー、ユーザのヒント、「あんしんQRチェッカー」に似たゲームを見ることなどができます。
あんしんQRチェッカー – Google Play のアプリ
QR・バーコードを開く前に確認できるリーダー。

主な機能

あんしんQRチェッカーでは、主に以下の機能を利用できます。

  • QRコードの読み取り
  • バーコードの読み取り
  • 画像からのQRコード読み取り
  • URLやテキストを開く前にプレビュー
  • リンク先ドメインの表示
  • 短縮URLなど、注意して確認したい形式のお知らせ
  • 読み取り履歴の保存と削除
  • アプリ内の言語切り替え

QRコードリーダーとしての基本機能に加えて、「開く前に確認する」ことを重視しています。

QRコードは便利だが、中身が見えない

QRコードは、URLやテキストなどの情報を簡単に読み取れる便利な仕組みです。

一方で、QRコードは見た目だけでは中身が分かりません。

読み取ったあとにそのままブラウザを開くタイプのQRリーダーもありますが、個人的には「一度内容を確認してから開きたい」と感じる場面がありました。

特に、短縮URLや見慣れないドメインの場合は、いきなり開くよりも先に確認できた方が安心です。

開く前にURLやドメインを確認できる

あんしんQRチェッカーでは、QRコードを読み取ったあと、まず確認画面を表示します。

URLの場合は、リンク先のドメインも表示されます。

たとえば、QRコードを読み取った結果がWebサイトのURLだった場合でも、すぐにブラウザを開くのではなく、以下のように確認できます。

  • 読み取ったURL
  • リンク先のドメイン
  • 内容の種類
  • 開く / コピーするなどの操作

これにより、「このQRコードを開いてもよさそうか」を自分で確認してから操作できます。

画像内のQRコードも読み取れる

カメラで直接読み取るだけでなく、画像からのQRコード読み取りにも対応しています。

たとえば、スクリーンショットや保存済み画像に含まれるQRコードを読み取りたい場合に利用できます。

スマートフォン上で受け取った画像や資料内のQRコードを確認したいときにも便利です。

読み取り履歴をあとから確認できる

読み取った内容は、履歴としてあとから確認できます。

一度読み取ったURLやテキストを再確認したい場合に便利です。

また、不要になった履歴は削除できます。

履歴は端末内に保存されるため、必要に応じて自分で管理できます。

プライバシーに配慮した設計

あんしんQRチェッカーでは、プライバシーにも配慮しています。

  • カメラ映像は端末内のみで処理
  • カメラ映像を保存・送信しない
  • 読み取ったデータを外部サーバーに送信しない
  • 履歴は端末内に保存
  • 履歴はいつでも削除可能

QRコードの読み取りは日常的に使う機能なので、なるべくシンプルで分かりやすく、安心して使える設計を意識しました。

こんな方におすすめ

あんしんQRチェッカーは、以下のような方におすすめです。

  • QRコードを開く前に内容を確認したい方
  • 知らないリンクや短縮URLを慎重に扱いたい方
  • リンク先のドメインを確認してから開きたい方
  • QRコードだけでなくバーコードも読み取りたい方
  • 画像内のQRコードを読み取りたい方
  • 読み取り履歴を端末内で管理したい方
  • シンプルで見やすいQRリーダーを使いたい方

利用時の注意点

本アプリは、リンク先の内容や安全性を保証するものではありません。

あくまで、QRコードやバーコードの内容を開く前に確認しやすくするためのアプリです。

表示された内容を確認したうえで、ご自身の判断で利用してください。

また、QRコードやバーコードの読み取りにはカメラの使用許可が必要です。

ダウンロード

あんしんQRチェッカーは、iOS / Android の両方で利用できます。

あんしんQRチェッカーアプリ – App Store
NINJA SYSTEMの「あんしんQRチェッカー」をApp Storeでダウンロードしてください。スクリーンショット、評価とレビュー、ユーザのヒント、「あんしんQRチェッカー」に似たゲームを見ることなどができます。
あんしんQRチェッカー – Google Play のアプリ
QR・バーコードを開く前に確認できるリーダー。

まとめ

QRコードは便利ですが、読み取った先を開く前に内容を確認したい場面もあります。

あんしんQRチェッカー は、QRコードやバーコードを読み取ったあと、URLやテキストを確認してから開けるシンプルなアプリです。

普段からQRコードをよく使う方や、知らないQRコードをそのまま開くのが不安な方は、ぜひ使ってみてください。

]]>
https://techgrowup.net/qr-code-url-check-app/feed/ 0
実際に行ってきました!大阪万博2025の日記 https://techgrowup.net/expo-2025-osaka-0526-review/ https://techgrowup.net/expo-2025-osaka-0526-review/?noamp=mobile#respond Sat, 31 May 2025 06:32:49 +0000 https://techgrowup.net/?p=2852 日記

5月25日に観光を兼ねて大阪に前泊しました。翌日の万博に備え、梅田エリアのホテルに宿泊しました。
5月26日は万博の入場を11時に予約していたので、朝10時頃に梅田駅からOsaka Metro中央線のコスモスクエア駅経由で夢洲駅へ向かいました。

10時40分ごろに夢洲駅に到着しましたが、東側ゲートはすでに大混雑。早めに入れるだろうと期待していましたが、実際は1時間近く待たされ、入場は11時30分頃となりました。

入場後、万博の会場構成を把握していなかったため、まずは中央に位置する「大屋根リング」近くまで歩きました。途中、東ゲート付近にはNTT、パナソニック、三菱などの民間企業のパビリオンが並び、大屋根リングの内側には各国パビリオンやイベントエリアが配置されていました。

万博公式アプリの「会場マップ」がとても便利で、自分の現在地や各パビリオンの位置がリアルタイムで確認できました。以下のサイト上でも利用できるのでこちらでも可能です。

https://www.expovisitors.expo2025.or.jp/map?_gl=1f3lu2o_gcl_au*MTM0OTAxODYxMC4xNzQ1NjQ0NjM3

人気の国別パビリオンはどこも長蛇の列だったため、まずは比較的空いていた「コモンズA」に入りました。ここではサモアやイエメンなど、普段馴染みのない国々の展示を楽しめました。

次に大屋根リングに登りましたが、当初は1周するつもりが、あまりの人混みと想像以上の大きさに途中で断念しました。しかし上から眺める会場全景は非常に美しく、ぜひおすすめしたいスポットです。

リングを降りた場所にバングラデシュ館があったので訪れました。日本と似た国旗に親近感が湧きました。またドラゴンボールのイラストなどが展示されており、日本のアニメ文化の浸透を感じました。隣接していたセネガル館も訪れましたが、さらに隣のエジプト館は1時間待ちだったため諦めました。この時点で13時ごろでした。

予約していた三菱館の時間が迫ったため、会場の反対側の東ゲート付近まで急ぎました。徒歩で15分ほどかかり、万博の敷地が想像以上に広いことを実感しました。
三菱館では写真撮影禁止でしたが、深海から宇宙までを旅する物語仕立ての展示がありました。特にプラネタリウム形式の映像展示が印象的で、ISSへの物資輸送や衛星打ち上げの情報に加えて、火星探査機の展示が興味深かったです。

その後、カタール館とアラブ首長国連邦館を訪問しました。日本にはない砂漠文化に触れることができ、非常に新鮮な体験でした。ここで15時を過ぎていたため、ポルトガル料理の「ビファナ弁当」を購入しました。サンドバーガーのような食感で、日本ではなかなか味わえない美味しさでした。

最後に、予約していた「空飛ぶ車パビリオン」へ行きました。実際の空飛ぶ車の座席に座って記念撮影ができ、映像シミュレーションで空中飛行体験もできました。個人的にはヘリコプターとの違いを実感できませんでしたが、未来の可能性を感じることができました。

帰りの飛行機の時間が迫っていたため、近くのトルコ館とモザンビーク館を訪問してから会場を後にしました。

全体として非常に楽しく、再訪したいと思える万博体験でした。特に夜の景色を見逃したことが心残りで、次回は新幹線で夜までゆっくり過ごす予定です。予約システムの混雑もありましたが、10月のリベンジで再挑戦したいと思います。

]]>
https://techgrowup.net/expo-2025-osaka-0526-review/feed/ 0
Custom Hooks完全ガイド──Reactでロジックを再利用しコンポーネントを超DRY https://techgrowup.net/react-custom-hooks/ https://techgrowup.net/react-custom-hooks/?noamp=mobile#respond Mon, 05 May 2025 11:00:00 +0000 https://techgrowup.net/?p=2846 はじめに

React 公式チュートリアルでも強調される「コンポーネントは UI を、Hooks はロジックを記述する」という原則。しかし現場のコードベースでは、似たような副作用や状態初期化ロジックを複数コンポーネント間でコピペしてしまうことが珍しくありません。Custom Hook を導入すると、共通処理を 1 箇所でテスト・保守でき、UI コンポーネントは宣言的に保たれます。本記事では公式ドキュメント https://react.dev/learn/reusing-logic-with-custom-hooks をベースに、基礎から高度なパターン、テスト、パフォーマンス、型安全まで徹底解説します。

Custom Hooks の基本構文

import { useState, useEffect } from 'react';

export function useClock() {
  const [time, setTime] = useState(() => new Date());

  useEffect(() => {
    const id = setInterval(() => setTime(new Date()), 1000);
    return () => clearInterval(id);
  }, []);

  return time;
}
  • 関数名use から始める
  • ビルトイン Hook は通常どおり使用可能
  • 依存配列は呼び出し元コンポーネントではなく、Hook 内部で完結

よくあるアンチパターン: カスタムフックの中で条件付きフック呼び出し

function useWrong(flag) {
  if (flag) {
    // これはルール違反: フックはトップレベルで呼ぶ必要がある
    useEffect(() => console.log('Bad'), []);
  }
}

React のルールオブフックスに違反し、レンダー順序が崩れる可能性があります。必ずトップレベルで呼び、条件分岐は内部で処理を早期リターンするなどで対応しましょう.

事例1: API フェッチロジックの共通化

ネットワーク通信は多くの画面で発生するため、抽象化すると保守負荷が激減します。

import { useState, useEffect } from 'react';

export function useFetch(url, options) {
  const [status, setStatus] = useState('idle');  // idle | loading | success | error
  const [data, setData]   = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    let ignore = false;
    const controller = new AbortController();

    async function start() {
      setStatus('loading');
      try {
        const res = await fetch(url, { ...options, signal: controller.signal });
        if (!res.ok) throw new Error(res.statusText);
        const json = await res.json();
        if (!ignore) {
          setData(json);
          setStatus('success');
        }
      } catch (e) {
        if (!ignore) {
          setError(e);
          setStatus('error');
        }
      }
    }
    start();

    return () => {
      ignore = true;
      controller.abort();
    };
  }, [url, JSON.stringify(options)]);

  return { status, data, error };
}

ポイント解説

  • AbortController でコンポーネントのアンマウント時にリクエストを中断
  • 依存配列に options がオブジェクトの場合は JSON.stringify で比較簡略化
  • status を文字列列挙にすることで UI が状態ごとに切り替えやすくなる

事例2: フォーム入力とバリデーション

React Hook Form や Formik を使わずに軽量に済ませたい場合、以下のような useForm フックを自作できます。

export function useForm(initial) {
  const [values, setValues] = useState(initial);
  const [errors, setErrors] = useState({});

  const onChange = (e) => {
    const { name, value } = e.target;
    setValues(v => ({ ...v, [name]: value }));
  };

  const validate = (rules) => {
    const newErrors = {};
    for (const key in rules) {
      const error = rules[key](values[key]);
      if (error) newErrors[key] = error;
    }
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const reset = () => setValues(initial);

  return { values, errors, onChange, validate, reset };
}

使用例

function Signup() {
  const { values, errors, onChange, validate } = useForm({ email: '', pw: '' });

  const handleSubmit = () => {
    if (validate({
      email: v => /@/.test(v) ? '' : 'メールが不正',
      pw: v => v.length >= 8 ? '' : '8文字以上必要'
    })) {
      // 送信
    }
  };
  return (/* JSX */);
}

パフォーマンスとメモ化のベストプラクティス

Custom Hook 内で useMemouseCallback を使う場面:

  1. 計算コストが高いロジックをキャッシュ
  2. 子に渡すコールバックを安定参照に
  3. オブジェクトや配列を返す場合—参照が変わると下位ツリーが再レンダー
function usePagination(total, perPage) {
  const pageCount = useMemo(() => Math.ceil(total / perPage), [total, perPage]);
  return pageCount;
}

TypeScriptとの組み合わせ

Custom Hook の戻り値がオブジェクトの場合、ジェネリクスで型の再利用性を高められます。

function useToggle<S extends string>(on: S, off: S) {
  const [state, setState] = useState<S>(off);
  const toggle = () => setState(s => s === on ? off : on);
  return { state, toggle };
}

const { state } = useToggle<'on' | 'off'>('on', 'off');

テスト戦略

@testing-library/react-hooks を用いると、Hook 単体をテストできます。

import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

test('カウンタが増える', () => {
  const { result } = renderHook(() => useCounter());
  act(() => result.current.inc());
  expect(result.current.count).toBe(1);
});

高度: イベントリスナーを扱う useEventListener フック

DOM イベントを登録・解除するロジックはほぼすべての SPA で必要です。以下の汎用フックをプロジェクトに追加すると、スクロール監視やキー入力検知を簡単に組み込めます。

import { useEffect, useRef } from 'react';

export function useEventListener(type, handler, target = window) {
  const saved = useRef();

  useEffect(() => {
    saved.current = handler;
  }, [handler]);

  useEffect(() => {
    const el = target?.current || target;
    if (!el?.addEventListener) return;
    const listener = (e) => saved.current(e);
    el.addEventListener(type, listener);
    return () => el.removeEventListener(type, listener);
  }, [type, target]);
}

利用例: Escape キーでモーダルを閉じる

function Modal({ onClose }) {
  useEventListener('keydown', e => {
    if (e.key === 'Escape') onClose();
  });
  /* ... */
}

React 18 Concurrent Rendering と Custom Hooks

中断と再実行

並列レンダーではコンポーネントが「途中で止まって再開」される可能性があります。Custom Hook 内で 副作用をレンダーフェーズに書くとバグの温床 になります。

  • NG: useMemo(() => performSideEffect(), [])
  • OK : useEffect 内で副作用を実行し、クリーンアップを返す

スケルトン UI の設計

useSuspenseQuery などデータフェッチ Hook を利用するときは、サスペンス発火タイミングを想定しローディングプレースホルダを計画的に配置しましょう。

パフォーマンス計測: React Profiler で Hook のコストを可視化

  1. React DevTools の Profiler タブを開き「Record」
  2. 画面操作を実行
  3. Flamegraph または Ranked view で再レンダー時間を検査
  4. Custom Hook 導入前後で差分を比較

重い計算が Commit フェーズに長く表示される場合は useMemo、頻繁なレンダーが Render フェーズに点在する場合は useCallbackReact.memo との併用を検討します。

他ライブラリの Custom Hook パターン

  • TanStack Query: useQuery, useMutation
  • Redux Toolkit: useSelector, useDispatch
  • Zustand: useStore(selector)

カスタム Hook のドキュメントとディスカバビリティ

大規模チームでは「どんな Hook があるか分からない」問題が発生します。

  • 命名規約:機能+動詞 (useFetchUser, useDebouncedValue)
  • Storybook Docs: Hook 用ストーリを作り動作を可視化
  • JSDoc/TSDoc と例コードを README に添付

落とし穴と対策一覧

症状原因対処
無限ループ依存配列にオブジェクトリテラルuseMemo で固定 or JSON.stringify
stale data依存配列抜け漏れESLint exhaustive-deps
メモリリークuseEffect 内でクリーンアップ忘れ必ず return で解除
フックのネスト深すぎ抽象化不足/責務過多Hook を小さく分割

いまさら聞けない React ルールオブフックス

  1. トップレベルでのみ呼ぶ
  2. 関数コンポーネント or Custom Hook 内で呼ぶ

エラーハンドリング: useSafeAsync フック

function useSafeAsync(fn, deps = []) {
  const mounted = useRef(true);
  useEffect(() => {
    mounted.current = true;
    fn().catch(console.error);
    return () => (mounted.current = false);
  }, deps);
  const safeSet = updater => {
    if (mounted.current) updater();
  };
  return safeSet;
}

フックファクトリパターン

function createUseApi(base) {
  return function useApi(path) {
    return useFetch(base + path);
  };
}
const useGitHub = createUseApi('https://api.github.com');

まとめ

この記事では Custom Hook の意義から実装、型安全、テスト、パフォーマンス、外部 API 連携、アクセシビリティまで幅広く網羅しました。これらのノウハウを活かし、あなたの React プロジェクトを 再利用性が高く、バグが少なく、パフォーマンスに優れた コードベースへ進化させてください。

]]>
https://techgrowup.net/react-custom-hooks/feed/ 0
useContext超大全──React Context APIでグローバル状態をスマートに共有する https://techgrowup.net/react-usecontext/ https://techgrowup.net/react-usecontext/?noamp=mobile#respond Thu, 01 May 2025 23:00:00 +0000 https://techgrowup.net/?p=2841 はじめに

React アプリが成長するにつれて、コンポーネント間で共通の値を渡す「props ドリリング」が増え、保守性が下がる課題に直面します。Context API と useContext フックは、この問題を標準機能だけで解決できる強力な仕組みです。本連載記事では公式ドキュメント https://react.dev/reference/react/useContext をベースに、基礎・設計・最適化・型安全・周辺ライブラリ まで 2 万文字超で徹底的に解説します。第一部では基本概念と最低限動くコード、スコープ設計、props ドリリングの解消フローをじっくり掘り下げます。

Context API の全体像

  • React.createContext で「箱」を作成
  • Provider が値を注入
  • useContext で最寄りの Provider から値を取得
  • Provider をネストすれば “局所グローバル” を作れる

図式化すると次のようになります。

<Context.Provider value={A}>
  └─ <ChildA />  ← useContext ⇒ A
      └─ <Context.Provider value={B}>
            └─ <ChildB />  ← useContext ⇒ B

外側と内側で値が上書きされるため、テーマやロケールなど「場面に応じて変わる設定」を自然に切り替えられます。

props ドリリング問題とContextの必要性

propsドリリングとは?

親→子→孫→ひ孫…と同じPropsをリレーして渡す状態。深いツリーでは可読性が悪化し、途中の中間コンポーネントが無関係のPropsを抱える。

Contextで解決

Providerをルート付近に置き、任意の深さで直接useContext。不要な中継Propsが消え、各コンポーネントが必要な値だけを購読できます。

具体例

function App() {
  const user = { id: 1, name: 'Taro' };
  return <Page user={user} />;
}
function Page({ user }) {             /* ← 中継だけ */
  return <Sidebar user={user} />;
}
function Sidebar({ user }) {          /* ← 中継だけ */
  return <UserProfile user={user} />;
}
function UserProfile({ user }) {
  return <p>{user.name}</p>;          /* ← 本当に必要なのはここだけ */
}

Context を導入すると、中継 Props をすべて削除し、下記のようにシンプル化できます。

const UserContext = createContext(null);
function App() {
  const user = { id: 1, name: 'Taro' };
  return (
    <UserContext.Provider value={user}>
      <Page />
    </UserContext.Provider>
  );
}
function UserProfile() {
  const user = useContext(UserContext);
  return <p>{user.name}</p>;
}

Context の作成と Provider 実装

createContext の引数

const ThemeContext = React.createContext('light'); // デフォルト値

1 引数めは フォールバック値。Provider がツリーになかった場合に限り返されるため、開発中の警告用や Storybook のダミー値として活用できます。

Provider に渡す value

<ThemeContext.Provider value="dark">
  <Toolbar />
</ThemeContext.Provider>

ポイント

  • Provider が再レンダーされると value の参照比較で下位ツリー全部が再レンダー
  • value がオブジェクトなら useMemo で安定化必須
const memoValue = useMemo(() => ({ theme, toggle }), [theme]);

useContext の基本挙動

const value = useContext(ThemeContext);
  • 最も近い Provider の value を返す
  • Provider が見つからない場合は createContext 時のデフォルト値
  • 複数回呼んでも React がキャッシュするためコストは低い

注意: useContext は必ず React のレンダーフェーズ内で呼ぶ。コールバックや条件分岐で早期 return する位置に入れるとルール違反。

スコープ設計:いつ Provider を分割すべきか

ケース1 Provider複数 Provider
テーマ、ロケールなど不変に近い設定
頻繁に変わる値(カーソル座標等)
複数ドメインデータ(Auth と Cart)

複数の値が同時に更新されるわけではない場合、それぞれ独立した Provider に分けることで再レンダー範囲を局所化できます。

サンプル:Todo アプリでの Context 分離

1. アプリ共通設定

const SettingsContext = createContext({
  showCompleted: true,
  toggleShow: () => {}
});

2. ログインユーザー

const AuthContext = createContext({
  user: null,
  login: () => {},
  logout: () => {}
});

3. Todo リスト

todo はサイズが大きく頻繁に変わるため、Context より useReducer + Context の複合パターンが適切です(第二部で詳細解説)。

パフォーマンス最適化と再レンダー制御

Context API は便利ですが、Provider の value が変わるたびに配下すべての useContext 呼び出しが再レンダー されます。大規模ツリーでは深刻なパフォーマンス低下につながるため、次の 4 つの対策を組み合わせましょう。

1. useMemo で value オブジェクトを安定化

const SettingsProvider = ({ children }) => {
  const [showCompleted, setShowCompleted] = useState(true);
  const ctx = useMemo(
    () => ({ showCompleted, toggle: () => setShowCompleted(v => !v) }),
    [showCompleted]    // プリミティブのみ依存
  );
  return <SettingsContext.Provider value={ctx}>{children}</SettingsContext.Provider>;
};

オブジェクト内の参照が変わらなければ再レンダーは下位に波及しません。

2. Context を分割する

更新頻度 が異なる値を 1 つの Provider に詰め込むと、滅多に変わらない設定が毎回レンダーのトリガーになります。

  • AuthContextThemeContext を個別 Provider にする
  • パフォーマンスクリティカルな配列や Map は専用 Context へ切り出す

3. Context Selector パターン

ライブラリ use-context-selector を使うと、特定プロパティだけを購読 でき、不要な再レンダーを劇的に減らせます。

import { createContext, useContextSelector } from 'use-context-selector';

const CountContext = createContext({ count: 0, inc: () => {} });

function CounterLabel() {
  const count = useContextSelector(CountContext, v => v.count);
  return <span>{count}</span>;
}

Provider の値がオブジェクト再生成されても count が変わらない限り CounterLabel は再レンダーされません。

4. memo + useContext は要注意

React.memo でラップしても、Context 更新は強制的に渡ってくるため 再レンダー抑止はできません。必要に応じ「コンテナ–プレゼンテーション分割」を行い、Context を読む部分をコンテナ、表示だけ行う部分を memo 化すると効果的です。

useReducer と Context を組み合わせたグローバルストア

小規模アプリなら Redux などを導入せずに、useReducer + Context で十分なグローバルストアを構築できます。

const TodoContext = createContext(null);

function todoReducer(state, action) {
  switch (action.type) {
    case 'add':
      return [...state, action.payload];
    case 'toggle':
      return state.map(t =>
        t.id === action.id ? { ...t, done: !t.done } : t
      );
    default:
      return state;
  }
}

export function TodoProvider({ children }) {
  const [todos, dispatch] = useReducer(todoReducer, []);
  const ctx = useMemo(() => ({ todos, dispatch }), [todos]);
  return <TodoContext.Provider value={ctx}>{children}</TodoContext.Provider>;
}

呼び出し側は dispatch({ type: 'add', payload }) で状態を更新し、todos を購読できます。Redux に比べてボイラープレートが少ないのが利点ですが、ミドルウェア機構開発ツール が欲しくなった時点で Redux Toolkit へ移行する判断基準となります。

型安全カスタムフックの作成(TypeScript)

export const SettingsContext =
  createContext<SettingsCtx | undefined>(undefined);

export function useSettings(): SettingsCtx {
  const ctx = useContext(SettingsContext);
  if (!ctx) {
    throw new Error('useSettings must be inside <SettingsProvider>');
  }
  return ctx;
}
  • undefined をコンテキスト型に含め、Provider 外使用をコンパイル時に検出
  • 呼び出し側は const { theme } = useSettings(); とシンプル

React 18 コンカレント特有の落とし穴

並列レンダーでは 「旧ツリー」と「新ツリー」が同時に存在 します。Context の値もレンダー単位でスナップショットされるため、更新が重なった場合は “数フレームだけ古いテーマで描画” のような一貫性揺らぎが起こり得ます。
対策:

  1. Transition 内で Provider を更新し、旧ツリーが素早く置き換わるようにする
  2. 非同期値(サーバフェッチ結果など)useSyncExternalStore で同期を取る

Suspense for Data Fetching と Context

React 18 の サスペンス対応データフェッチライブラリ(TanStack Query, SWR v2 等)は、Context でキャッシュを共有する設計が主流です。

<QueryClientProvider client={client}>  // Provider
  <App />
</QueryClientProvider>

内部的に Provider の値が変わらないよう慎重に実装されているため、大量のデータを扱っても再レンダーが抑えられます。

テストと Storybook パターン

単体テスト

  • Provider をラップしたテストユーティリティを作成
  • renderHook でカスタムフックのみをテスト
const wrapper = ({ children }) => <AuthProvider>{children}</AuthProvider>;
const { result } = renderHook(() => useAuth(), { wrapper });

Storybook

  • decorators で Provider を追加し、Context に基づく UI を再現
  • 複数バリエーションを Template で切り替え、locale や theme を擬似表示

よくあるエラーと解決策

エラー原因対処
Cannot read property 'xyz' of nullProvider がツリーに含まれていないカスタムフックでundefinedチェック
遅延評価したい値が毎回変わるProvider の value を毎レンダー生成useMemo で固定 or 値を分割Providerへ
子が不必要に再レンダーvalue がオブジェクトリテラルuseMemo + useCallback で参照安定化

まとめ

Context の基礎とスコープ設計、パフォーマンス最適化と useReducer 連携・型安全化・並列レンダーの留意点まで解説しました。ここまでを理解すれば、中小規模の React アプリなら Redux 等を導入せずとも十分スケーラブルな状態共有が可能です。

]]>
https://techgrowup.net/react-usecontext/feed/ 0
useMemo徹底ガイド──Reactアプリの再計算コストを劇的に減らす最適化テクニック https://techgrowup.net/react-usememo/ https://techgrowup.net/react-usememo/?noamp=mobile#respond Wed, 30 Apr 2025 23:00:00 +0000 https://techgrowup.net/?p=2838 useMemoとは何か

useMemo は指定した計算式をメモ化(キャッシュ)し、依存配列が変わらない限り再実行をスキップするReactフックです。たとえば数千件のリストを高コストなフィルタリングやソートで描画する場合、入力が変わらなければ同じ結果を再計算するのはムダです。useMemo を使えば計算を一度だけ行い、次回レンダーではキャッシュ済みの値を返すため描画が高速化します。

import { useMemo } from 'react';

function ExpensiveList({ items, query }) {
  const filtered = useMemo(() => {
    return items.filter(item => item.name.includes(query));
  }, [items, query]); // itemsかqueryが変わったときだけ再計算

  return filtered.map(item => <li key={item.id}>{item.name}</li>);
}

基本シグネチャ

const memoizedValue = useMemo(() => compute(expensive), [deps]);
  • 第1引数:メモ化したい関数(副作用を含まない純粋関数が望ましい)。
  • 第2引数:依存配列。ここに列挙した値が変わった場合のみ計算し直す。

依存配列を空配列 [] にするとマウント時に一回だけ実行。undefined を渡すと毎レンダー実行=メモ化無効なので注意。

useMemoが効く典型シナリオ

1. データ変換・重い計算

  • 数学的シミュレーションの結果
  • JSONデータのディープコピーや正規化
  • Markdown → HTML 変換など CPUコストが高い処理

2. フィルタ・ソート・検索

リストの要素数が数百〜数千を超える場合、Array.prototype.filtersort を毎レンダー走らせるとフレーム落ちの原因になります。

3. JSX生成コスト削減

巨大リストを map で JSX に展開するとき、その戻り値をメモ化すると描画コストを下げられます(React 18の並列レンダーでも効果的)。

useCallbackとの違い

観点useMemouseCallback
目的をメモ化関数参照をメモ化
戻り値計算結果そのものメモ化済みの関数
主要用途高コストの計算結果を保持子コンポーネントへ渡すハンドラの参照固定

内部実装では useCallback(fn, deps)useMemo(() => fn, deps) と同等です。しかし意味づけが異なるため、値なら useMemo、関数なら useCallback を使うと読みやすくなります。

実践シナリオ別コード例

例1:検索フィルタリング

function SearchableTable({ rows }) {
  const [keyword, setKeyword] = useState('');
  const visibleRows = useMemo(() => {
    const lower = keyword.toLowerCase();
    return rows.filter(r => r.name.toLowerCase().includes(lower));
  }, [rows, keyword]);
  return (
    <>
      <input value={keyword} onChange={e => setKeyword(e.target.value)} />
      <ul>{visibleRows.map(r => <li key={r.id}>{r.name}</li>)}</ul>
    </>
  );
}
  • rows が巨大でも keyword が変わらなければ再計算なし
  • 入力ごとにフィルタされても実用レベルのパフォーマンス

例2:高コスト画像処理

function Preview({ file }) {
  const thumbnail = useMemo(() => {
    return file ? createThumbnail(file) : null; // Canvas処理など重い関数
  }, [file]);
  return thumbnail ? <img src={thumbnail} /> : null;
}

例3:ソート済みキャッシュ

const sorted = useMemo(
  () => [...items].sort((a, b) => a.date - b.date),
  [items]
);

イミュータブルデータならシャローコピーしてソートし、items が同一参照の間は再計算されません。

依存配列設計のコツ

  1. リストやオブジェクトは参照が変わる条件を把握
    • Redux/Context でステートが再生成されると参照が毎回変わる ⇒ 先にuseSelector(shallowEqual)などで固定化
  2. 不要な依存を追加しない
    • 計算結果に影響しない値を含めると再実行が増える
  3. 入れ忘れはバグの温床
    • ESLintのreact-hooks/exhaustive-depsルールを有効化し、自動検出する
// NG: setState関数は安定参照のため依存不要
useMemo(() => compute(state, setState), [state, setState]); 
// OK
useMemo(() => compute(state), [state]);

パフォーマンス測定と検証

Profiler API

  1. React DevTools を開き「Profiler」タブを選択
  2. 録画して操作 ⇒ 再計算が走る場所を特定
  3. useMemo導入後にレンダー時間が短縮されているか確認

console.time計測

const result = useMemo(() => {
  console.time('expensive');
  const v = heavy();
  console.timeEnd('expensive');
  return v;
}, [deps]);

メモ化の前後で時間を比較し、効果を数字で把握します。

よくある落とし穴

症状原因対策
メモ化しているのに速くならない計算自体が軽い/依存が毎回変わるメモ化を外す or 依存を安定化
stale data(古い値を参照)依存配列に必要な変数がないESLintルールで検出+修正
メモリリーク大きな配列・Mapをキャッシュして解放しない必要に応じて useMemo を解除

useMemoを使わない方がいい場合

  • 計算コスト < メモ化管理コスト
  • UIがほぼ静的:再レンダー回数が少ない
  • 依存が頻繁に変わる:キャッシュがほぼ活きない

最適化は「必要になったら測定して導入」が鉄則です。

useMemo と Suspense / Concurrent Features

React 18 の並列レンダーでは、useMemoのキャッシュが中断と再開をまたいで保持されるため、長い計算でもUIスレッドをブロックしにくくなっています。ただしメモ化対象関数は同期的に実行されるため、CPU負荷の高い処理はWeb WorkerやuseTransitionと組み合わせるとさらに快適です。

まとめ

useMemo

  1. 高コスト計算の結果をキャッシュ
  2. 依存配列が変わるまで再利用
  3. 不要な再レンダーを抑制

というシンプルながら強力な最適化手段です。ただし「とりあえず入れる」は逆効果。まずはプロファイリングでボトルネックを特定し、効果が見込める箇所にのみ導入するのがベストプラクティスです。正しい依存配列と計算粒度を意識し、快適なReact UIを届けましょう。

]]>
https://techgrowup.net/react-usememo/feed/ 0
useReducer完全入門──複雑な状態管理をスマートに解決するReactフックの使い方 https://techgrowup.net/react-usereducer/ https://techgrowup.net/react-usereducer/?noamp=mobile#respond Wed, 30 Apr 2025 22:00:00 +0000 https://techgrowup.net/?p=2835 なぜuseReducerが必要か

useStateは直感的で便利ですが、

  • 複数の状態が相互に影響する
  • 更新ロジックが長い if/else で肥大化する
  • 次の状態が前の状態に依存する更新が多い

といった場面では読みづらくなります。こうしたケースでuseReducerを使うと、状態更新ロジックを1カ所に集約でき、コンポーネントがスッキリします。

基本シグネチャ

const [state, dispatch] = useReducer(reducer, initialState, init?)
パラメータ意味
reducer(state, action) ⇒ newState更新ロジックを持つ純粋関数
initialState任意初期値
init(optional) (arg) ⇒ initializedState遅延初期化用

戻り値

  • state:現在の状態
  • dispatch:アクションを送る関数

まずはカウンターで理解

import { useReducer } from 'react';

function reducer(state, action) {
  switch (action.type) {
    case 'increment': return { count: state.count + 1 };
    case 'decrement': return { count: state.count - 1 };
    default: throw new Error();
  }
}

export default function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0 });
  return (
    <>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}></button>
      <button onClick={() => dispatch({ type: 'decrement' })}></button>
    </>
  );
}

ポイント解説

  • reducer純粋関数。副作用を入れない
  • dispatch へは オブジェクトを渡す(type必須)
  • UI 側は state を読むだけ=ロジックが分離され可読性↑

複数フィールドのフォーム例

useStateでそれぞれ管理すると更新がバラけますが、useReducerで一元化可能。

function formReducer(state, action) {
  switch (action.type) {
    case 'change':
      return { ...state, [action.field]: action.value };
    case 'reset':
      return action.initial;
    default:
      return state;
  }
}

const initialForm = { name: '', email: '', password: '' };

function SignupForm() {
  const [form, dispatch] = useReducer(formReducer, initialForm);

  const handleChange = e =>
    dispatch({ type: 'change', field: e.target.name, value: e.target.value });

  return (
    <form>
      {Object.entries(form).map(([key, val]) => (
        <input key={key} name={key} value={val} onChange={handleChange} />
      ))}
      <button onClick={() => dispatch({ type: 'reset', initial: initialForm })}>
        クリア
      </button>
    </form>
  );
}

深掘り

  • ...state で既存をコピー=不変性を守る
  • resetアクションでワンアクション全項目初期化
  • 大量フィールドでも reducer が変わらないので拡張に強い

遅延初期化でパフォーマンス改善

重い計算で初期値を作る場合、第三引数 init を使うとレンダー時に毎回呼ばれません。

function init(countFromLocal) {
  return { count: countFromLocal };
}
const [state, dispatch] = useReducer(reducer, initialArg, init);

useState vs useReducer 使い分け指針

シチュエーション推奨フック
1〜2個の単純な値useState
更新ロジックが直感的useState
多数のフィールド・複雑な分岐useReducer
次の状態が前の状態に強く依存useReducer
Redux 風のアーキテクチャを局所的に実現useReducer

コンポーネント分割との相性

Reducer を別ファイルに切り出し、複数コンポーネントから共通利用するとReduxライクな構成が得られます。

// todoReducer.js
export function todoReducer(state, action) { /* 省略 */ }

// TodoList.jsx
import { todoReducer } from './todoReducer';
const [todos, dispatch] = useReducer(todoReducer, []);

まとめ

useReducer状態更新ロジックを一元管理し、可読性と拡張性を両立させる強力なフックです。

  1. 純粋関数 reducer を定義
  2. dispatch で明示的にアクションを送る
  3. ステートが複雑化したら useReducer へ移行

ロジックが整理されることでバグ発生率が減り、大規模アプリでもスケールしやすいコードベースを実現できます。

]]>
https://techgrowup.net/react-usereducer/feed/ 0
useRef徹底ガイド──DOMアクセスからミューテーション管理まで https://techgrowup.net/react-useref/ https://techgrowup.net/react-useref/?noamp=mobile#respond Wed, 30 Apr 2025 20:00:00 +0000 https://techgrowup.net/?p=2832 useRefとは?

ReactのuseRefフックは、レンダー間で変化しても再レンダーを引き起こさない「箱」を提供します。返されるオブジェクトは{ current: 値 }という形で、コンポーネントのライフサイクル全体を通じて同じ参照を保持します。主な用途は次の二つです。

  1. DOM要素への参照:フォーム入力フォーカスやスクロール位置制御
  2. ミューテーション値の保持:レンダー外で更新されるカウンタや外部ライブラリのインスタンス
import { useRef, useEffect } from 'react';

function TextInputFocus() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current.focus();
  }, []);

  return <input ref={inputRef} placeholder="自動でフォーカス" />;
}

仕組みと特徴

  • useRef(initialValue) はレンダーのたびに 同じオブジェクト を返す
  • ref.current の変更は React の再レンダーをトリガーしない
  • オブジェクト参照自体は固定だが current プロパティは自由に書き換え可能

この振る舞いにより、状態のように保持しつつUIに影響を与えないデータ を格納できます。

DOMリファレンスの実践例

フォームのフォーカス管理

複数ステップのフォームで次の入力要素へフォーカスを移すとき、useRef が活躍します。

function MultiStepForm() {
  const step1Ref = useRef(null);
  const step2Ref = useRef(null);
  const [step, setStep] = useState(1);

  useEffect(() => {
    if (step === 1) step1Ref.current.focus();
    if (step === 2) step2Ref.current.focus();
  }, [step]);

  return (
    <>
      {step === 1 && <input ref={step1Ref} />}
      {step === 2 && <input ref={step2Ref} />}
      <button onClick={() => setStep(s => s + 1)}>次へ</button>
    </>
  );
}

スクロール制御

チャットアプリで新しいメッセージ追加時に最下部までスクロールする例。

function ChatWindow({ messages }) {
  const bottomRef = useRef(null);

  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

  return (
    <div className="chat-area">
      {messages.map(m => <p key={m.id}>{m.text}</p>)}
      <div ref={bottomRef} />
    </div>
  );
}

ミューテーション値の保持

レンダー回数を数える

状態にすると無限ループするような値は useRef で保持。

function RenderCount() {
  const countRef = useRef(0);
  useEffect(() => { countRef.current += 1 });
  return <p>レンダー回数: {countRef.current}</p>;
}

setIntervalのIDを保持

function Timer() {
  const intervalRef = useRef(null);
  const [count, setCount] = useState(0);

  const start = () => {
    if (intervalRef.current === null) {
      intervalRef.current = setInterval(() => setCount(c => c + 1), 1000);
    }
  };

  const stop = () => {
    clearInterval(intervalRef.current);
    intervalRef.current = null;
  };

  useEffect(() => stop, []); // アンマウント時に停止

  return <>
    <p>{count} 秒経過</p>
    <button onClick={start}>開始</button>
    <button onClick={stop}>停止</button>
  </>;
}

forwardRefと組み合わせる

親コンポーネントから子のDOMへ直接アクセスする場合は forwardRef が必要です。

const FancyInput = React.forwardRef((props, ref) => {
  return <input className="fancy" ref={ref} {...props} />;
});

function Parent() {
  const inputRef = useRef(null);
  return <>
    <FancyInput ref={inputRef} />
    <button onClick={() => inputRef.current.focus()}>フォーカス</button>
  </>;
}

useImperativeHandleで公開APIを制御

子コンポーネント側で ref 経由の公開メソッドをカスタマイズできます。

const Canvas = React.forwardRef((props, ref) => {
  const canvasRef = useRef();
  useImperativeHandle(ref, () => ({
    clear: () => canvasRef.current.getContext('2d').clearRect(0,0,300,150)
  }));
  return <canvas ref={canvasRef} width={300} height={150} />;
});

function DrawingApp() {
  const canvasRef = useRef();
  return <>
    <Canvas ref={canvasRef} />
    <button onClick={() => canvasRef.current.clear()}>クリア</button>
  </>;
}

コールバックrefとの比較

種類設定方法特徴
string refref="myRef"レガシー、廃止予定
callback refref={node => { ... }}動的に複数refや条件分岐を管理
useRef + ref属性const r=useRef(); … ref={r}一般的。オブジェクトに一度だけセット

パフォーマンスに与える影響

useRef はレンダーをトリガーしないため、頻繁に変化する値を保持してもコストは低い。ただし current の変更にUIを反映したい場合は、別途 useState と組み合わせる必要がある。

よくある落とし穴

  1. refがnullのままアクセス
    • 初回レンダー直後はまだDOMがセットされておらずnull。useEffect の中で読む。
  2. currentを書き換えても画面が更新されない
    • useRefは状態管理ではない。UIに反映が必要なら useState を使う。
  3. 依存配列にref.currentを入れてしまう
    • ref.current の値が変わっても依存配列は検知しない。監視には別状態が必要。

総括

useRefDOMアクセスミューテーション値の永続化 を実現する万能ポケットです。再レンダーコストを抑えつつ、状態とは異なる形で情報を保持できるため、フォーム・アニメーション・外部ライブラリ統合など多様な場面で活用できます。ポイントは「UI描画に関与しない値」を見極め、状態との役割分担を明確にすることです。

]]>
https://techgrowup.net/react-useref/feed/ 0
ReactのuseCallback徹底ガイド──依存配列・再レンダリング制御・パフォーマンス最適化の基本と応用 https://techgrowup.net/react-usecallback/ https://techgrowup.net/react-usecallback/?noamp=mobile#respond Sun, 27 Apr 2025 23:00:00 +0000 https://techgrowup.net/?p=2827 はじめに

Reactの関数コンポーネントで副作用や再レンダリング制御を扱う際、useCallback フックはしばしば「面倒くさい」「よくわからない」と敬遠されがちです。しかし、大規模アプリのパフォーマンスチューニングや、子コンポーネントへのコールバック関数渡しを正しく行うためには不可欠な存在です。本記事では基礎から依存配列の書き方、内部動作、注意点、具体的な活用シーンまでを解説します。

useCallbackとは?

useCallback は「関数をメモ化(キャッシュ)する」ための React フックです。コンポーネントが再レンダーされるたびに新しい関数インスタンスを生成すると、子コンポーネントに渡したコールバックが”変化”とみなされ、不要な再レンダーにつながります。これを防ぐのが useCallback です。

const memoizedHandler = useCallback(() => {
  console.log('クリックされました');
}, [依存値1, 依存値2]);
  • 第1引数:メモ化したい関数
  • 第2引数:依存配列。ここに入れた値が変わると新しく関数を作り直す

基本シグネチャと戻り値

function useCallback<T extends (...args: any[]) => any>(
  callback: T,
  deps: DependencyList
): T;
  • T:任意の関数型
  • DependencyListany[] 型の配列
  • 戻り値:渡した callback と同じ型の“メモ化された関数”

内部では React が Hook ごとに前回の callback 参照を保持し、deps が完全一致する限り同じ関数を返します。

依存配列の正しい書き方

依存配列を適切に指定しないと、次のような問題が起きます。

誤り症状
依存配列を空にする ([])最新の外部変数を参照できない (stale closure)
依存を省略 (undefined)毎回新しい関数が作られ、効果なし
必要な依存を含めない古い値を使い続ける

例:カウントを依存に含めないと古い値を参照する

function Counter() {
  const [count, setCount] = useState(0);

  const handler = useCallback(() => {
    console.log(count); // 依存にcountを含めないと常に0が表示
  }, []); 

  return <button onClick={handler}>ログ出力</button>;
}

上記では count が変わっても handler 内の count は初回のまま。依存配列には常に最新のcountを正しく指定しましょう。

useCallbackとuseMemoの違い

  • useCallback:関数をメモ化する
  • useMemo:関数の戻り値(任意の値)をメモ化する
const memoizedValue = useMemo(() => computeExpensive(a, b), [a, b]);
const memoizedHandler = useCallback(() => doSomething(a, b), [a, b]);

useCallback(fn, deps)useMemo(() => fn, deps) のエイリアスと考えることもできますが、可読性の観点から関数キャッシュには useCallback を使うのが一般的です。

  1. コンポーネント初回レンダー
  2. useCallback が新しい関数を生成し返却
  3. 次回レンダー
    • deps が同じ → 前回の関数を再利用
    • deps が変わった → 新しい関数を生成
  4. 子コンポーネントへ渡す際、関数参照の変化を検出してレンダー制御できる

この仕組みを利用し、React.memo と組み合わせることで子コンポーネントの再レンダーを抑制できます。

子コンポーネントの最適化例

const Child = React.memo(({ onAction }) => {
  console.log('Child render');
  return <button onClick={onAction}>アクション</button>;
});

function Parent() {
  const [count, setCount] = useState(0);

  const handleAction = useCallback(() => {
    console.log('Action!');
  }, []); // 依存なし → 再レンダー時も同じ参照

  return (
    <>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>増やす</button>
      <Child onAction={handleAction} />
    </>
  );
}
  • Parent が再レンダーされても handleAction は同じ参照
  • Child は Props が変わらないとレンダーされないため高速

使わないほうがいいケース

  • 軽量コンポーネント/関数
    関数の生成コストよりReactのレンダーコストのほうが低い場合
  • 短命な値
    毎回異なる依存配列を生成すると逆効果
  • 過度のメモ化
    コードが複雑になり可読性や保守性が低下

判断基準
パフォーマンス問題が「本当に関数参照の変化」に起因しているかプロファイラで確認しましょう。

複数フックとの連携パターン

useCallback + useEffect

エフェクト内で関数を参照する場合、依存配列に含めないと stale closure が起きます。

function Search({ query }) {
  const [results, setResults] = useState([]);
  const fetchResults = useCallback(async () => {
    const res = await fetch(`/api?q=${query}`);
    setResults(await res.json());
  }, [query]);

  useEffect(() => {
    fetchResults();
  }, [fetchResults]); // fetchResults を正しく依存に含める

  return /* 結果表示 */;
}

useCallback + useMemo

関数による計算結果をさらにメモ化したい場合

const expensiveCompute = useCallback((x) => heavyCalc(x), []);
const memoizedResult = useMemo(() => expensiveCompute(input), [input, expensiveCompute]);

useCallback でメモ化された関数をテストする際は、renderHookact を使うと楽です。

コールバック関数のテスト

useCallback でメモ化された関数をテストする際は、renderHookact を使うと楽です。

import { renderHook, act } from '@testing-library/react-hooks';

test('fetchResults が更新時に変化する', async () => {
  const { result, rerender } = renderHook(
    ({ query }) => useCallback(/*…*/, [query]),
    { initialProps: { query: 'a' } }
  );
  const first = result.current;
  rerender({ query: 'b' });
  expect(result.current).not.toBe(first);
});

パフォーマンス計測とベンチマーク

  • Profiler API で実際のレンダー回数を計測
  • React DevTools のProfilerタブでコールバック関数生成の影響を見る
  • useCallback 無し / 有りでどれだけ再レンダーが減るかを数値で比較

よくある誤解とQ&A

  1. 「depsが空なら一度だけ実行される」
    useCallback では「関数参照が一度だけ生成される」。 useEffect([], …) とは別。
  2. 「関数を渡すだけで最適化される」
    → 子コンポーネントも React.memo などで参照比較しなければ効果なし。
  3. 「常に使えば良い」
    → 過度のメモ化は逆効果。必要な箇所だけに絞りましょう。

まとめ

useCallback は関数インスタンスの再生成を防ぎ、再レンダーを制御する強力なフックです。依存配列の正確な管理、React.memo との併用、過度なメモ化の回避などポイントを押さえれば、アプリ全体のパフォーマンスが向上します。まずはパフォーマンスプロファイリングを行い、本当に必要な箇所だけに導入してみてください。

]]>
https://techgrowup.net/react-usecallback/feed/ 0