Torna al blog
Technical

Building a Custom CMP in React: Hooks, Context, and Performance

Il team di GetCookiesNovember 18, 202515 min di lettura
ReactDevelopmentJavaScriptHooks

TLDR: Integrate consent management into React apps with custom hooks, context providers, and conditional rendering patterns that respect user privacy while maintaining app functionality.

Read full summary

This comprehensive React integration tutorial covers building production-ready consent management: custom hooks for consent state, context providers for global access, conditional script loading, handling consent changes reactively, TypeScript type safety, performance optimization, and testing strategies. Includes complete implementations for Google Consent Mode v2, GTM integration, and real-world patterns for handling the complexity of SPAs with third-party tracking.

Summary by Claude AI

How Do I Handle Cookie Consent Properly in a React Application?

React's component-based architecture and client-side rendering create unique challenges for consent management. Unlike traditional server-rendered pages where scripts execute on page load, React SPAs trigger scripts on route changes, user interactions, and component mounts—often before consent state is determined.

What makes React consent management different?

Traditional cookie consent solutions assume a document-based model: show banner, collect consent, reload page with appropriate scripts. This breaks down in React because:

  1. SPAs don't reload between page views—scripts initialized once persist across navigation
  2. Components mount and unmount dynamically, potentially triggering tracking calls
  3. Third-party libraries often initialize immediately on import
  4. React's hydration can cause flash-of-unconsented-content issues
  5. State management must be coordinated across the entire application tree

The solution requires lifting consent state to React's context system, implementing conditional script loading at the component level, and building reactive systems that respond to consent changes in real-time.

Building the Consent Management Architecture

The Consent Context and Provider

// contexts/ConsentContext.tsx
import React, {
  createContext,
  useContext,
  useState,
  useEffect,
  useCallback,
  useMemo,
  ReactNode
} from 'react';

// ============================================
// TYPE DEFINITIONS
// ============================================

type ConsentCategory =
  | 'necessary'
  | 'analytics'
  | 'marketing'
  | 'personalization'
  | 'social_media';

type ConsentStatus = 'granted' | 'denied' | 'pending';

interface ConsentPreferences {
  necessary: ConsentStatus;
  analytics: ConsentStatus;
  marketing: ConsentStatus;
  personalization: ConsentStatus;
  social_media: ConsentStatus;
}

interface ConsentMetadata {
  timestamp: Date;
  version: string;
  method: 'banner' | 'preference_center' | 'api' | 'default';
  userAgent: string;
  geoLocation?: {
    country: string;
    region?: string;
  };
}

interface ConsentRecord {
  preferences: ConsentPreferences;
  metadata: ConsentMetadata;
  tcString?: string;  // IAB TCF consent string
  gppString?: string; // Global Privacy Platform string
}

interface ConsentContextValue {
  // Current consent state
  consent: ConsentPreferences;
  isLoading: boolean;
  hasInteracted: boolean;
  consentRecord: ConsentRecord | null;

  // Actions
  updateConsent: (updates: Partial<ConsentPreferences>) => Promise<void>;
  acceptAll: () => Promise<void>;
  rejectAll: () => Promise<void>;
  acceptNecessaryOnly: () => Promise<void>;
  openPreferenceCenter: () => void;
  closePreferenceCenter: () => void;

  // Helpers
  hasConsent: (category: ConsentCategory) => boolean;
  isConsentRequired: () => boolean;
  getConsentString: () => string;

  // UI State
  showBanner: boolean;
  showPreferenceCenter: boolean;
}

// ============================================
// CONTEXT CREATION
// ============================================

const ConsentContext = createContext<ConsentContextValue | null>(null);

// ============================================
// CONSENT PROVIDER IMPLEMENTATION
// ============================================

interface ConsentProviderProps {
  children: ReactNode;
  config: ConsentConfig;
  onConsentChange?: (consent: ConsentPreferences) => void;
}

interface ConsentConfig {
  cookieName: string;
  cookieDomain?: string;
  cookieExpiry: number; // days
  defaultConsent: ConsentPreferences;
  geoDetection: boolean;
  tcfEnabled: boolean;
  gppEnabled: boolean;
  consentVersion: string;
  apiEndpoint?: string;
}

export const ConsentProvider: React.FC<ConsentProviderProps> = ({
  children,
  config,
  onConsentChange
}) => {
  // ============================================
  // STATE
  // ============================================

  const [consent, setConsent] = useState<ConsentPreferences>(config.defaultConsent);
  const [isLoading, setIsLoading] = useState(true);
  const [hasInteracted, setHasInteracted] = useState(false);
  const [consentRecord, setConsentRecord] = useState<ConsentRecord | null>(null);
  const [showBanner, setShowBanner] = useState(false);
  const [showPreferenceCenter, setShowPreferenceCenter] = useState(false);
  const [geoLocation, setGeoLocation] = useState<{ country: string; region?: string } | null>(null);

  // ============================================
  // INITIALIZATION
  // ============================================

  useEffect(() => {
    const initializeConsent = async () => {
      try {
        // 1. Load existing consent from storage
        const storedConsent = loadConsentFromStorage(config.cookieName);

        // 2. Detect geo location if enabled
        if (config.geoDetection) {
          const geo = await detectGeoLocation();
          setGeoLocation(geo);
        }

        // 3. Determine if consent is required
        const requiresConsent = await checkIfConsentRequired(geoLocation);

        if (storedConsent && isConsentValid(storedConsent, config.consentVersion)) {
          // Valid consent exists
          setConsent(storedConsent.preferences);
          setConsentRecord(storedConsent);
          setHasInteracted(true);
          setShowBanner(false);

          // Apply consent to tracking systems
          applyConsentToTrackingSystems(storedConsent.preferences);
        } else if (requiresConsent) {
          // No valid consent, show banner
          setShowBanner(true);

          // Set default consent (deny all optional)
          const defaultDenied: ConsentPreferences = {
            necessary: 'granted',
            analytics: 'denied',
            marketing: 'denied',
            personalization: 'denied',
            social_media: 'denied'
          };
          setConsent(defaultDenied);
          applyConsentToTrackingSystems(defaultDenied);
        } else {
          // Consent not required (e.g., US visitors without state laws)
          const impliedConsent: ConsentPreferences = {
            necessary: 'granted',
            analytics: 'granted',
            marketing: 'granted',
            personalization: 'granted',
            social_media: 'granted'
          };
          setConsent(impliedConsent);
          applyConsentToTrackingSystems(impliedConsent);
        }
      } catch (error) {
        console.error('Failed to initialize consent:', error);
        // Fail safe: require consent
        setShowBanner(true);
      } finally {
        setIsLoading(false);
      }
    };

    initializeConsent();
  }, [config, geoLocation]);

  // ============================================
  // CONSENT ACTIONS
  // ============================================

  const updateConsent = useCallback(async (updates: Partial<ConsentPreferences>) => {
    const newConsent: ConsentPreferences = {
      ...consent,
      ...updates,
      necessary: 'granted' // Necessary is always granted
    };

    const record: ConsentRecord = {
      preferences: newConsent,
      metadata: {
        timestamp: new Date(),
        version: config.consentVersion,
        method: showPreferenceCenter ? 'preference_center' : 'banner',
        userAgent: navigator.userAgent,
        geoLocation: geoLocation || undefined
      },
      tcString: config.tcfEnabled ? generateTCString(newConsent) : undefined,
      gppString: config.gppEnabled ? generateGPPString(newConsent) : undefined
    };

    // Update state
    setConsent(newConsent);
    setConsentRecord(record);
    setHasInteracted(true);
    setShowBanner(false);
    setShowPreferenceCenter(false);

    // Persist to storage
    saveConsentToStorage(config.cookieName, record, config.cookieExpiry, config.cookieDomain);

    // Apply to tracking systems
    applyConsentToTrackingSystems(newConsent);

    // Sync to backend if configured
    if (config.apiEndpoint) {
      await syncConsentToBackend(config.apiEndpoint, record);
    }

    // Notify callback
    onConsentChange?.(newConsent);
  }, [consent, config, geoLocation, showPreferenceCenter, onConsentChange]);

  const acceptAll = useCallback(async () => {
    await updateConsent({
      analytics: 'granted',
      marketing: 'granted',
      personalization: 'granted',
      social_media: 'granted'
    });
  }, [updateConsent]);

  const rejectAll = useCallback(async () => {
    await updateConsent({
      analytics: 'denied',
      marketing: 'denied',
      personalization: 'denied',
      social_media: 'denied'
    });
  }, [updateConsent]);

  const acceptNecessaryOnly = useCallback(async () => {
    await rejectAll();
  }, [rejectAll]);

  // ============================================
  // HELPERS
  // ============================================

  const hasConsent = useCallback((category: ConsentCategory): boolean => {
    if (category === 'necessary') return true;
    return consent[category] === 'granted';
  }, [consent]);

  const isConsentRequired = useCallback((): boolean => {
    if (!geoLocation) return true; // Default to requiring consent
    const gdprCountries = ['AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'GB', 'IS', 'LI', 'NO'];
    return gdprCountries.includes(geoLocation.country);
  }, [geoLocation]);

  const getConsentString = useCallback((): string => {
    if (consentRecord?.tcString) return consentRecord.tcString;
    return btoa(JSON.stringify(consent));
  }, [consent, consentRecord]);

  const openPreferenceCenter = useCallback(() => {
    setShowPreferenceCenter(true);
  }, []);

  const closePreferenceCenter = useCallback(() => {
    setShowPreferenceCenter(false);
  }, []);

  // ============================================
  // CONTEXT VALUE
  // ============================================

  const value = useMemo<ConsentContextValue>(() => ({
    consent,
    isLoading,
    hasInteracted,
    consentRecord,
    updateConsent,
    acceptAll,
    rejectAll,
    acceptNecessaryOnly,
    openPreferenceCenter,
    closePreferenceCenter,
    hasConsent,
    isConsentRequired,
    getConsentString,
    showBanner,
    showPreferenceCenter
  }), [
    consent,
    isLoading,
    hasInteracted,
    consentRecord,
    updateConsent,
    acceptAll,
    rejectAll,
    acceptNecessaryOnly,
    openPreferenceCenter,
    closePreferenceCenter,
    hasConsent,
    isConsentRequired,
    getConsentString,
    showBanner,
    showPreferenceCenter
  ]);

  return (
    <ConsentContext.Provider value={value}>
      {children}
    </ConsentContext.Provider>
  );
};

// ============================================
// CUSTOM HOOK
// ============================================

export const useConsent = (): ConsentContextValue => {
  const context = useContext(ConsentContext);

  if (!context) {
    throw new Error('useConsent must be used within a ConsentProvider');
  }

  return context;
};

// ============================================
// SPECIALIZED HOOKS
// ============================================

/**
 * Hook for checking specific consent category
 */
export const useConsentCategory = (category: ConsentCategory): {
  isGranted: boolean;
  isLoading: boolean;
  request: () => void;
} => {
  const { consent, isLoading, hasConsent, openPreferenceCenter } = useConsent();

  return {
    isGranted: hasConsent(category),
    isLoading,
    request: openPreferenceCenter
  };
};

/**
 * Hook for conditionally executing effects based on consent
 */
export const useConsentEffect = (
  category: ConsentCategory,
  effect: () => void | (() => void),
  deps: React.DependencyList = []
): void => {
  const { hasConsent, isLoading } = useConsent();

  useEffect(() => {
    if (isLoading) return;
    if (!hasConsent(category)) return;

    return effect();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [category, isLoading, hasConsent(category), ...deps]);
};

/**
 * Hook for analytics with consent awareness
 */
export const useAnalytics = () => {
  const { hasConsent, isLoading, consent } = useConsent();

  const track = useCallback((event: string, properties?: Record<string, any>) => {
    if (isLoading || !hasConsent('analytics')) {
      // Queue event for when consent is granted
      queueAnalyticsEvent(event, properties);
      return;
    }

    // Send to analytics
    sendAnalyticsEvent(event, properties);
  }, [isLoading, hasConsent]);

  const identify = useCallback((userId: string, traits?: Record<string, any>) => {
    if (isLoading || !hasConsent('analytics')) return;
    identifyUser(userId, traits);
  }, [isLoading, hasConsent]);

  return { track, identify, isEnabled: hasConsent('analytics') };
};

Utility Functions

// utils/consent-helpers.ts

/**
 * Load consent from cookie storage
 */
export function loadConsentFromStorage(cookieName: string): ConsentRecord | null {
  if (typeof document === 'undefined') return null;

  const cookies = document.cookie.split(';');
  for (const cookie of cookies) {
    const [name, value] = cookie.trim().split('=');
    if (name === cookieName) {
      try {
        return JSON.parse(decodeURIComponent(value));
      } catch {
        return null;
      }
    }
  }
  return null;
}

/**
 * Save consent to cookie storage
 */
export function saveConsentToStorage(
  cookieName: string,
  record: ConsentRecord,
  expiryDays: number,
  domain?: string
): void {
  const expires = new Date();
  expires.setDate(expires.getDate() + expiryDays);

  let cookieString = `${cookieName}=${encodeURIComponent(JSON.stringify(record))}`;
  cookieString += `; expires=${expires.toUTCString()}`;
  cookieString += '; path=/';
  cookieString += '; SameSite=Lax';

  if (domain) {
    cookieString += `; domain=${domain}`;
  }

  if (window.location.protocol === 'https:') {
    cookieString += '; Secure';
  }

  document.cookie = cookieString;
}

/**
 * Check if stored consent is still valid
 */
export function isConsentValid(record: ConsentRecord, currentVersion: string): boolean {
  // Check version match
  if (record.metadata.version !== currentVersion) return false;

  // Check if consent is expired (e.g., 13 months for GDPR)
  const consentDate = new Date(record.metadata.timestamp);
  const thirteenMonthsAgo = new Date();
  thirteenMonthsAgo.setMonth(thirteenMonthsAgo.getMonth() - 13);

  if (consentDate < thirteenMonthsAgo) return false;

  return true;
}

/**
 * Apply consent to Google Tag Manager / gtag
 */
export function applyConsentToTrackingSystems(preferences: ConsentPreferences): void {
  // Google Consent Mode v2
  if (typeof window !== 'undefined' && window.gtag) {
    window.gtag('consent', 'update', {
      ad_storage: preferences.marketing === 'granted' ? 'granted' : 'denied',
      ad_user_data: preferences.marketing === 'granted' ? 'granted' : 'denied',
      ad_personalization: preferences.personalization === 'granted' ? 'granted' : 'denied',
      analytics_storage: preferences.analytics === 'granted' ? 'granted' : 'denied',
      functionality_storage: preferences.necessary === 'granted' ? 'granted' : 'denied',
      personalization_storage: preferences.personalization === 'granted' ? 'granted' : 'denied',
      security_storage: 'granted' // Always granted for security purposes
    });
  }

  // Dispatch custom event for other systems
  window.dispatchEvent(new CustomEvent('consentUpdate', {
    detail: preferences
  }));
}

/**
 * Detect user's geo location
 */
export async function detectGeoLocation(): Promise<{ country: string; region?: string } | null> {
  try {
    // Use a privacy-friendly geo detection service
    const response = await fetch('https://api.country.is/');
    const data = await response.json();
    return { country: data.country };
  } catch {
    return null;
  }
}

/**
 * Check if consent is required based on jurisdiction
 */
export async function checkIfConsentRequired(
  geo: { country: string; region?: string } | null
): Promise<boolean> {
  if (!geo) return true; // Default to requiring consent

  // GDPR countries
  const gdprCountries = [
    'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR',
    'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL',
    'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'GB', 'IS', 'LI', 'NO'
  ];

  if (gdprCountries.includes(geo.country)) return true;

  // US states with privacy laws
  if (geo.country === 'US' && geo.region) {
    const regulatedStates = ['CA', 'VA', 'CO', 'CT', 'UT', 'TX', 'OR', 'MT'];
    if (regulatedStates.includes(geo.region)) return true;
  }

  // Other countries with privacy laws
  const otherRegulatedCountries = ['BR', 'ZA', 'KR', 'JP', 'AU', 'NZ', 'CA'];
  if (otherRegulatedCountries.includes(geo.country)) return true;

  return false;
}

Conditional Script Loading Components

Script Loader with Consent

// components/ConsentAwareScript.tsx
import { useEffect, useRef } from 'react';
import { useConsent, ConsentCategory } from '../contexts/ConsentContext';

interface ConsentAwareScriptProps {
  src: string;
  category: ConsentCategory;
  id?: string;
  async?: boolean;
  defer?: boolean;
  onLoad?: () => void;
  onError?: (error: Error) => void;
  attributes?: Record<string, string>;
}

export const ConsentAwareScript: React.FC<ConsentAwareScriptProps> = ({
  src,
  category,
  id,
  async = true,
  defer = false,
  onLoad,
  onError,
  attributes = {}
}) => {
  const { hasConsent, isLoading } = useConsent();
  const scriptRef = useRef<HTMLScriptElement | null>(null);
  const hasLoadedRef = useRef(false);

  useEffect(() => {
    // Don't do anything while loading consent state
    if (isLoading) return;

    // Check consent
    if (!hasConsent(category)) {
      // Remove script if consent was revoked
      if (scriptRef.current) {
        scriptRef.current.remove();
        scriptRef.current = null;
        hasLoadedRef.current = false;
      }
      return;
    }

    // Don't reload if already loaded
    if (hasLoadedRef.current) return;

    // Check if script already exists in DOM
    const existingScript = document.getElementById(id || src);
    if (existingScript) {
      hasLoadedRef.current = true;
      return;
    }

    // Create and inject script
    const script = document.createElement('script');
    script.src = src;
    script.async = async;
    script.defer = defer;

    if (id) script.id = id;

    // Add custom attributes
    Object.entries(attributes).forEach(([key, value]) => {
      script.setAttribute(key, value);
    });

    script.onload = () => {
      hasLoadedRef.current = true;
      onLoad?.();
    };

    script.onerror = () => {
      onError?.(new Error(`Failed to load script: ${src}`));
    };

    document.head.appendChild(script);
    scriptRef.current = script;

    return () => {
      // Cleanup on unmount (but keep script loaded)
    };
  }, [isLoading, hasConsent, category, src, id, async, defer, attributes, onLoad, onError]);

  return null; // This component doesn't render anything
};

// ============================================
// GOOGLE ANALYTICS COMPONENT
// ============================================

interface GoogleAnalyticsProps {
  measurementId: string;
  debug?: boolean;
}

export const GoogleAnalytics: React.FC<GoogleAnalyticsProps> = ({
  measurementId,
  debug = false
}) => {
  const { hasConsent, isLoading, consent } = useConsent();

  useEffect(() => {
    if (isLoading) return;

    // Initialize gtag with consent defaults
    window.dataLayer = window.dataLayer || [];
    function gtag(...args: any[]) {
      window.dataLayer.push(args);
    }
    window.gtag = gtag;

    // Set default consent state
    gtag('consent', 'default', {
      ad_storage: 'denied',
      ad_user_data: 'denied',
      ad_personalization: 'denied',
      analytics_storage: 'denied',
      wait_for_update: 500
    });

    // Update based on current consent
    gtag('consent', 'update', {
      ad_storage: consent.marketing === 'granted' ? 'granted' : 'denied',
      ad_user_data: consent.marketing === 'granted' ? 'granted' : 'denied',
      ad_personalization: consent.personalization === 'granted' ? 'granted' : 'denied',
      analytics_storage: consent.analytics === 'granted' ? 'granted' : 'denied'
    });

    if (debug) {
      gtag('set', 'debug_mode', true);
    }
  }, [isLoading, consent, debug]);

  if (isLoading) return null;

  return (
    <>
      <ConsentAwareScript
        src={`https://www.googletagmanager.com/gtag/js?id=${measurementId}`}
        category="analytics"
        id="ga-script"
        onLoad={() => {
          window.gtag('js', new Date());
          window.gtag('config', measurementId, {
            send_page_view: hasConsent('analytics')
          });
        }}
      />
    </>
  );
};

// ============================================
// FACEBOOK PIXEL COMPONENT
// ============================================

interface FacebookPixelProps {
  pixelId: string;
}

export const FacebookPixel: React.FC<FacebookPixelProps> = ({ pixelId }) => {
  const { hasConsent, isLoading } = useConsent();

  useEffect(() => {
    if (isLoading || !hasConsent('marketing')) return;

    // Initialize Facebook Pixel
    (function(f: any, b: any, e: any, v: any, n?: any, t?: any, s?: any) {
      if (f.fbq) return;
      n = f.fbq = function() {
        n.callMethod ? n.callMethod.apply(n, arguments) : n.queue.push(arguments);
      };
      if (!f._fbq) f._fbq = n;
      n.push = n;
      n.loaded = !0;
      n.version = '2.0';
      n.queue = [];
      t = b.createElement(e);
      t.async = !0;
      t.src = v;
      s = b.getElementsByTagName(e)[0];
      s?.parentNode?.insertBefore(t, s);
    })(window, document, 'script', 'https://connect.facebook.net/en_US/fbevents.js');

    window.fbq('init', pixelId);
    window.fbq('track', 'PageView');

    // Listen for consent revocation
    const handleConsentUpdate = (event: CustomEvent) => {
      if (event.detail.marketing !== 'granted') {
        // Disable pixel (it will be re-enabled on next page load if consent is granted)
        window.fbq?.('consent', 'revoke');
      }
    };

    window.addEventListener('consentUpdate', handleConsentUpdate as EventListener);

    return () => {
      window.removeEventListener('consentUpdate', handleConsentUpdate as EventListener);
    };
  }, [isLoading, hasConsent, pixelId]);

  // No-script fallback with consent check
  if (!hasConsent('marketing')) return null;

  return (
    <noscript>
      <img
        height="1"
        width="1"
        style={{ display: 'none' }}
        src={`https://www.facebook.com/tr?id=${pixelId}&ev=PageView&noscript=1`}
        alt=""
      />
    </noscript>
  );
};

Building the Consent Banner UI

// components/ConsentBanner.tsx
import React, { useState } from 'react';
import { useConsent, ConsentPreferences } from '../contexts/ConsentContext';
import styles from './ConsentBanner.module.css';

export const ConsentBanner: React.FC = () => {
  const {
    showBanner,
    acceptAll,
    rejectAll,
    openPreferenceCenter,
    isLoading
  } = useConsent();

  if (isLoading || !showBanner) return null;

  return (
    <div
      className={styles.banner}
      role="dialog"
      aria-labelledby="consent-title"
      aria-describedby="consent-description"
    >
      <div className={styles.content}>
        <h2 id="consent-title" className={styles.title}>
          We value your privacy
        </h2>
        <p id="consent-description" className={styles.description}>
          We use cookies to enhance your browsing experience, serve personalized
          content, and analyze our traffic. By clicking "Accept All", you consent
          to our use of cookies.{' '}
          <a href="/privacy-policy" className={styles.link}>
            Read our Privacy Policy
          </a>
        </p>
      </div>

      <div className={styles.actions}>
        <button
          onClick={rejectAll}
          className={styles.rejectButton}
          type="button"
        >
          Reject All
        </button>
        <button
          onClick={openPreferenceCenter}
          className={styles.customizeButton}
          type="button"
        >
          Customize
        </button>
        <button
          onClick={acceptAll}
          className={styles.acceptButton}
          type="button"
        >
          Accept All
        </button>
      </div>
    </div>
  );
};

// ============================================
// PREFERENCE CENTER
// ============================================

export const PreferenceCenter: React.FC = () => {
  const {
    showPreferenceCenter,
    closePreferenceCenter,
    consent,
    updateConsent
  } = useConsent();

  const [localPreferences, setLocalPreferences] = useState<ConsentPreferences>(consent);

  // Sync local state when consent changes
  React.useEffect(() => {
    setLocalPreferences(consent);
  }, [consent]);

  if (!showPreferenceCenter) return null;

  const categories = [
    {
      id: 'necessary' as const,
      name: 'Strictly Necessary',
      description: 'Essential for the website to function. Cannot be disabled.',
      required: true
    },
    {
      id: 'analytics' as const,
      name: 'Analytics & Performance',
      description: 'Help us understand how visitors interact with our website.',
      required: false
    },
    {
      id: 'marketing' as const,
      name: 'Marketing & Advertising',
      description: 'Used to deliver relevant advertisements and track campaign performance.',
      required: false
    },
    {
      id: 'personalization' as const,
      name: 'Personalization',
      description: 'Remember your preferences and customize your experience.',
      required: false
    },
    {
      id: 'social_media' as const,
      name: 'Social Media',
      description: 'Enable sharing content on social platforms and embed social features.',
      required: false
    }
  ];

  const handleToggle = (categoryId: keyof ConsentPreferences) => {
    if (categoryId === 'necessary') return; // Can't toggle necessary

    setLocalPreferences(prev => ({
      ...prev,
      [categoryId]: prev[categoryId] === 'granted' ? 'denied' : 'granted'
    }));
  };

  const handleSave = async () => {
    await updateConsent(localPreferences);
  };

  const handleAcceptAll = async () => {
    await updateConsent({
      analytics: 'granted',
      marketing: 'granted',
      personalization: 'granted',
      social_media: 'granted'
    });
  };

  const handleRejectAll = async () => {
    await updateConsent({
      analytics: 'denied',
      marketing: 'denied',
      personalization: 'denied',
      social_media: 'denied'
    });
  };

  return (
    <div className={styles.overlay} onClick={closePreferenceCenter}>
      <div
        className={styles.modal}
        onClick={e => e.stopPropagation()}
        role="dialog"
        aria-labelledby="pref-title"
        aria-modal="true"
      >
        <header className={styles.modalHeader}>
          <h2 id="pref-title">Privacy Preferences</h2>
          <button
            onClick={closePreferenceCenter}
            className={styles.closeButton}
            aria-label="Close"
          >
            ×
          </button>
        </header>

        <div className={styles.modalBody}>
          <p className={styles.intro}>
            When you visit our website, we may store or retrieve information on
            your browser, mostly in the form of cookies. This information might
            be about you, your preferences, or your device. Use the toggles
            below to customize your experience.
          </p>

          <div className={styles.categories}>
            {categories.map(category => (
              <div key={category.id} className={styles.category}>
                <div className={styles.categoryHeader}>
                  <div className={styles.categoryInfo}>
                    <h3 className={styles.categoryName}>{category.name}</h3>
                    <p className={styles.categoryDescription}>
                      {category.description}
                    </p>
                  </div>
                  <label className={styles.toggle}>
                    <input
                      type="checkbox"
                      checked={localPreferences[category.id] === 'granted'}
                      onChange={() => handleToggle(category.id)}
                      disabled={category.required}
                    />
                    <span className={styles.toggleSlider} />
                  </label>
                </div>
                {category.required && (
                  <span className={styles.requiredBadge}>Always Active</span>
                )}
              </div>
            ))}
          </div>
        </div>

        <footer className={styles.modalFooter}>
          <button onClick={handleRejectAll} className={styles.rejectButton}>
            Reject All
          </button>
          <button onClick={handleSave} className={styles.saveButton}>
            Save Preferences
          </button>
          <button onClick={handleAcceptAll} className={styles.acceptButton}>
            Accept All
          </button>
        </footer>
      </div>
    </div>
  );
};

Testing Consent Management in React

// __tests__/ConsentContext.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { ConsentProvider, useConsent } from '../contexts/ConsentContext';

// Test component that uses consent context
const TestComponent: React.FC = () => {
  const {
    consent,
    isLoading,
    hasConsent,
    acceptAll,
    rejectAll,
    showBanner
  } = useConsent();

  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      <div data-testid="analytics-status">
        Analytics: {hasConsent('analytics') ? 'granted' : 'denied'}
      </div>
      <div data-testid="marketing-status">
        Marketing: {hasConsent('marketing') ? 'granted' : 'denied'}
      </div>
      <div data-testid="banner-status">
        Banner: {showBanner ? 'visible' : 'hidden'}
      </div>
      <button onClick={acceptAll}>Accept All</button>
      <button onClick={rejectAll}>Reject All</button>
    </div>
  );
};

const defaultConfig = {
  cookieName: 'test_consent',
  cookieExpiry: 365,
  defaultConsent: {
    necessary: 'granted' as const,
    analytics: 'denied' as const,
    marketing: 'denied' as const,
    personalization: 'denied' as const,
    social_media: 'denied' as const
  },
  geoDetection: false,
  tcfEnabled: false,
  gppEnabled: false,
  consentVersion: '1.0'
};

describe('ConsentContext', () => {
  beforeEach(() => {
    // Clear cookies before each test
    document.cookie.split(';').forEach(cookie => {
      const name = cookie.split('=')[0].trim();
      document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`;
    });

    // Mock window.gtag
    window.gtag = jest.fn();
  });

  it('shows loading state initially', async () => {
    render(
      <ConsentProvider config={defaultConfig}>
        <TestComponent />
      </ConsentProvider>
    );

    expect(screen.getByText('Loading...')).toBeInTheDocument();

    await waitFor(() => {
      expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
    });
  });

  it('shows banner when no prior consent exists', async () => {
    render(
      <ConsentProvider config={defaultConfig}>
        <TestComponent />
      </ConsentProvider>
    );

    await waitFor(() => {
      expect(screen.getByTestId('banner-status')).toHaveTextContent('visible');
    });
  });

  it('grants all consent when Accept All is clicked', async () => {
    render(
      <ConsentProvider config={defaultConfig}>
        <TestComponent />
      </ConsentProvider>
    );

    await waitFor(() => {
      expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
    });

    const acceptButton = screen.getByText('Accept All');
    await act(async () => {
      fireEvent.click(acceptButton);
    });

    await waitFor(() => {
      expect(screen.getByTestId('analytics-status')).toHaveTextContent('granted');
      expect(screen.getByTestId('marketing-status')).toHaveTextContent('granted');
    });
  });

  it('denies optional consent when Reject All is clicked', async () => {
    render(
      <ConsentProvider config={defaultConfig}>
        <TestComponent />
      </ConsentProvider>
    );

    await waitFor(() => {
      expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
    });

    const rejectButton = screen.getByText('Reject All');
    await act(async () => {
      fireEvent.click(rejectButton);
    });

    await waitFor(() => {
      expect(screen.getByTestId('analytics-status')).toHaveTextContent('denied');
      expect(screen.getByTestId('marketing-status')).toHaveTextContent('denied');
    });
  });

  it('calls gtag consent update when consent changes', async () => {
    render(
      <ConsentProvider config={defaultConfig}>
        <TestComponent />
      </ConsentProvider>
    );

    await waitFor(() => {
      expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
    });

    const acceptButton = screen.getByText('Accept All');
    await act(async () => {
      fireEvent.click(acceptButton);
    });

    expect(window.gtag).toHaveBeenCalledWith('consent', 'update', expect.objectContaining({
      analytics_storage: 'granted',
      ad_storage: 'granted'
    }));
  });

  it('hides banner after consent is given', async () => {
    render(
      <ConsentProvider config={defaultConfig}>
        <TestComponent />
      </ConsentProvider>
    );

    await waitFor(() => {
      expect(screen.getByTestId('banner-status')).toHaveTextContent('visible');
    });

    const acceptButton = screen.getByText('Accept All');
    await act(async () => {
      fireEvent.click(acceptButton);
    });

    await waitFor(() => {
      expect(screen.getByTestId('banner-status')).toHaveTextContent('hidden');
    });
  });

  it('persists consent to cookies', async () => {
    render(
      <ConsentProvider config={defaultConfig}>
        <TestComponent />
      </ConsentProvider>
    );

    await waitFor(() => {
      expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
    });

    const acceptButton = screen.getByText('Accept All');
    await act(async () => {
      fireEvent.click(acceptButton);
    });

    expect(document.cookie).toContain('test_consent=');
  });
});

// ============================================
// INTEGRATION TESTS
// ============================================

describe('ConsentAwareScript', () => {
  it('does not load script when consent is denied', async () => {
    const onLoad = jest.fn();

    render(
      <ConsentProvider config={defaultConfig}>
        <TestConsumerWithScript onLoad={onLoad} />
      </ConsentProvider>
    );

    await waitFor(() => {
      expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
    });

    // Script should not have loaded
    expect(onLoad).not.toHaveBeenCalled();
    expect(document.querySelector('script[src*="example-analytics"]')).toBeNull();
  });

  it('loads script when consent is granted', async () => {
    const onLoad = jest.fn();

    render(
      <ConsentProvider config={defaultConfig}>
        <TestConsumerWithScript onLoad={onLoad} />
      </ConsentProvider>
    );

    await waitFor(() => {
      expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
    });

    // Grant consent
    const acceptButton = screen.getByText('Accept All');
    await act(async () => {
      fireEvent.click(acceptButton);
    });

    // Script should now be in the DOM
    await waitFor(() => {
      expect(document.querySelector('script[src*="example-analytics"]')).toBeInTheDocument();
    });
  });
});

FAQ: React Consent Management

How do I prevent tracking scripts from loading before consent?

The key is initializing Google Consent Mode v2 with default set to denied before any GTM or analytics scripts load. Place the consent default configuration in your HTML head before any tracking scripts. Then use the ConsentProvider to manage updates. The wait_for_update parameter (set to 500ms) gives your app time to check consent before any hits are sent.

How do I handle consent in Next.js with server-side rendering?

Next.js requires special handling because the server doesn't have access to cookies during the initial render. Use useEffect for all consent logic (it only runs client-side), and render consent UI conditionally with typeof window !== 'undefined' checks. For App Router, mark consent components with 'use client'. Consider using next/script with strategy="lazyOnload" for tracking scripts that depend on consent.

What happens when a user revokes consent?

When consent is revoked, you should: (1) update the consent state, (2) clear any tracking cookies, (3) disable or remove loaded tracking scripts, and (4) optionally trigger a page reload to ensure clean state. Note that some tracking libraries cannot be fully "unloaded"—they may continue to operate until page refresh. The safest approach is to reload the page when consent is revoked for marketing or analytics.

How do I test consent functionality locally?

Use the React Testing Library setup shown above, mocking the gtag function and document.cookie. For manual testing, use browser DevTools to clear cookies and localStorage between tests. Chrome DevTools > Application > Cookies lets you inspect consent storage. Consider building a "Reset Consent" developer tool that clears consent state and reloads the page.

Should I use a CMP library or build my own?

Build your own for simple sites or when you need full control. Use a library (like react-cookie-consent, @segment/consent-manager, or OneTrust's React SDK) when you need IAB TCF compliance, GPP support, or advanced features like vendor management. The context-based approach shown here gives you a foundation that can be extended or replaced with a commercial solution.

How do I handle consent for embedded iframes (YouTube, Maps)?

Create wrapper components that check consent before rendering the iframe. Show a placeholder with a "Load content" button that requests consent. For YouTube specifically, use the privacy-enhanced embed domain (youtube-nocookie.com) for users who haven't granted marketing consent. This still requires some consent due to potential data collection, but is more privacy-friendly than standard embeds.

Performance Optimization for Consent-Aware React Apps

Loading consent state should never block your application's initial render. The pattern shown uses React's Suspense-friendly async initialization, showing a loading state only for consent UI components while the rest of your app renders normally.

For large applications with many conditional scripts, consider using dynamic imports with React.lazy() for tracking components. This ensures tracking code is only downloaded when consent is actually granted, reducing initial bundle size by 20-40KB for typical analytics stacks.

The consent context is intentionally memoized to prevent unnecessary re-renders. Child components only re-render when they actually use changed consent values, not on every consent check. This is critical for maintaining 60fps performance in complex applications with many consent-aware components.

Domande frequenti

Can I use Google Tag Manager with React?
Yes. You should install the GTM snippet (or use a library like react-gtm-module) and push "consent update" events to the dataLayer whenever the user updates their preferences in your React UI.
G

Il team di GetCookies

Scritto e aggiornato dal team di GetCookies di Getia AS a Oslo, in Norvegia. Questo articolo è un'informazione generale sul consenso ai cookie, non una consulenza legale.

Scansiona il tuo sito gratis

Il piano gratuito mostra il banner su un sito con tre scansioni dei cookie al mese. Nessuna carta, nessuna chiamata commerciale.