GA4のuser_id同期フローを図解。クッキー同意と認証状態をフックで統合し、予約フィールドとしてのuser_idのセット・アンセット、デニーリスト処理を経てGA4へイベントが流れる。デニーリストブロックやアカウント切替時の罠、テスト責務も示す。

はじめに

GA4(Google Analytics 4)のuser_idは、通常のイベントパラメータとは別枠で扱われる「予約済みの設定フィールド」です。

多くのプロジェクトでは、PII(個人情報)を含むパラメータをイベント送信時にブロックする「denylist」的な仕組みを持っています。

ところがuser_idは、本来その仕組みを迂回して、直接dataLayerにset_user_idunset_user_idをpushする必要がある特殊なフィールドです。

この記事では、認証状態(ログイン・ログアウト・アカウント切替)とCookie同意(コンセント)状態を掛け合わせて、GA4のuser_idを安全に同期する実装パターンを整理します。GTMやClarityなど計測タグ自体を同意状態でどうゲーティングするかは別記事にまとめているので、あわせて参照してください。

着地点はシンプルで、「同意がなければ絶対にセットしない」「アカウント切替時は上書きではなくunset→setの順で送る」という2つの契約を、最小限のコードでどう保証するかという設計判断です。

こんな人におすすめ

  • GA4やGTMでuser_idの設定をこれから実装する方
  • Cookie同意バナーと分析トラッキングの連携に悩んでいる方
  • Reactのカスタムフックで複数の状態ソースを集約する設計パターンを探している方
  • アカウント切替時に分析基盤へ副作用が残らないか不安な方
  • ユニットテストとE2Eテストの責務分担で迷っている方

user_idは「イベントパラメータ」ではなく「予約フィールド」だと考える

まず前提として、user_idをほかのカスタムパラメータと同じ扱いにしないことが重要です。

多くのプロジェクトは、イベント送信の直前でPIIを含みうるパラメータをdenylistでブロックする仕組みを持っています。

user_idをこの共通のイベント送信関数に乗せてしまうと、denylistのロジックに引っかかったり、逆にdenylistをすり抜けて意図しない形で送られたりと、事故の温床になりがちです。

そこで、user_id専用の同期関数を用意し、通常のイベントトラッキング関数とは別ルートで直接dataLayerにpushする設計にすると、コードを読んだときの意図が明確になります。

「これは分析基盤の予約フィールドを扱うコードだ」と一目で分かる状態を作ることが、最初の分岐点です。

差分検知つき同期関数で二重pushを防ぐ

次に問題になるのが、同じuser_idで何度も再レンダリングやフック再実行が走ったときに、pushイベントが重複してしまうことです。

これを防ぐために、モジュールスコープの変数で直近にpushした値を保持し、差分がある場合のみ実際にpushする設計にしました。

let lastPushedUserId: string | null = null;

export function syncUserId(userId: string | null): void {
  if (userId === lastPushedUserId) return;

  if (lastPushedUserId !== null) {
    pushDataLayerEvent({ event: 'unset_user_id', user_id: null });
  }

  lastPushedUserId = userId;

  if (userId !== null) {
    pushDataLayerEvent({ event: 'set_user_id', user_id: userId });
  }
}

同じ値の連続呼び出しは早期リターンでスキップします。

値が変わったときだけ「前の値があればunset」→「新しい値があればset」という順序を守ります。

この2ステップを1関数に閉じ込めることで、呼び出し側は「今表示すべきuser_id(またはnull)」を渡すだけでよくなります。

アカウント切替(A→B)を直接上書きするのではなく、明示的に2回pushする契約にすることで、分析基盤側(GTM/GA4)に古いユーザーIDが紐づいたまま残るリスクを防げます。

認証状態と同意状態を1つのフックに集約する

もう一つの罠は、「認証済みだから」という理由だけでuser_idをセットしてしまうことです。

同意状態がgrantedでない限り、user_idは絶対にセットしてはいけません。

このガード条件をあちこちに書くと漏れが起きやすいので、専用のカスタムフックに集約します。

export function useAnalyticsUserId(userId: string | null): void {
  const consentState = useConsent();

  useEffect(() => {
    syncUserId(consentState === 'granted' ? userId : null);
  }, [consentState, userId]);
}

同意がgrantedでない限りは常にnullを渡す、というガード条件を1行に集約しています。

呼び出し元(認証フック)は「認証済みユーザーのID、または未認証ならnull」を渡すだけでよく、同意ロジックを意識する必要がありません。

useEffectの依存配列に「認証状態×同意状態」の両方を入れておくことで、どちらか一方が変化したタイミングを一括で拾えます。

テストは「同期ロジック」と「呼び出し元」で責務を分ける

最後にテスト設計です。

syncUserIdのような下位モジュールは、差分検知やunset→setの順序といった契約を、個別にユニットテストで決定的に検証します。

一方、useAnalyticsUserIdのような呼び出し元は、下位モジュールをモック化したうえで「どんな引数で呼ばれたか」だけを検証する、という2階層のテスト戦略にすると、テストの意図が読みやすくなります。

ログアウト処理が実際のSSO(外部認証基盤)へのリアルナビゲーションを伴う場合は、その遷移自体はE2Eの対象外とし、状態遷移の契約はユニットテスト側で決定的に検証する、という責務分担が有効です。

つまづきやすいポイント

  • 解除(ログアウト)時は空文字や値の省略ではなく、明示的にuser_id: nullを送る必要があります。GTMなどdataLayerの値は「前回の値が残留する」性質を持つツールが多いためです
  • アカウント切替時にunsetを挟まず直接setで上書きすると、分析基盤側に古いユーザーIDの紐づきが残るリスクがあります
  • モジュールスコープの変数で差分検知を行う場合、テスト実行環境をまたいだ状態のリセットタイミングには注意が必要です
  • ログアウトが外部認証基盤への実ナビゲーションを伴うケースでは、E2Eでは遷移を対象外にし、状態遷移の契約はユニットテスト側で検証する、という切り分けが有効です

まとめ

GA4のuser_idは通常のイベントパラメータと同列に扱わず、専用の同期関数を用意するのが安全です。

差分検知つきの同期関数に「unset→set」の順序を閉じ込め、認証状態と同意状態を1つのカスタムフックに集約することで、呼び出し側のコードをシンプルに保てます。

同意ガードと切替時の順序保証という2つの契約さえ守れば、あとの実装は驚くほどコンパクトに収まります。

テストは下位ロジックと呼び出し元で責務を分けると、意図が読みやすいテストスイートになります。

同種の「予約フィールド/制御イベント」を扱う場面では、この差分検知つき同期関数のパターンをそのまま横展開できるはずです。