Tilbage til bloggen
Technical

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

Alex Kowalski, Platform ArchitectNovember 18, 202515 min læsning
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 ```tsx // 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) => Promise; acceptAll: () => Promise; rejectAll: () => Promise; acceptNecessaryOnly: () => Promise; openPreferenceCenter: () => void; closePreferenceCenter: () => void; // Helpers hasConsent: (category: ConsentCategory) => boolean; isConsentRequired: () => boolean; getConsentString: () => string; // UI State showBanner: boolean; showPreferenceCenter: boolean; } // ============================================ // CONTEXT CREATION // ============================================ const ConsentContext = createContext(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 = ({ children, config, onConsentChange }) => { // ============================================ // STATE // ============================================ const [consent, setConsent] = useState(config.defaultConsent); const [isLoading, setIsLoading] = useState(true); const [hasInteracted, setHasInteracted] = useState(false); const [consentRecord, setConsentRecord] = useState(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) => { 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(() => ({ 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 ( {children} ); }; // ============================================ // 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) => { 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) => { if (isLoading || !hasConsent('analytics')) return; identifyUser(userId, traits); }, [isLoading, hasConsent]); return { track, identify, isEnabled: hasConsent('analytics') }; }; ``` ### Utility Functions ```typescript // 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 { 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 ```tsx // 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; } export const ConsentAwareScript: React.FC = ({ src, category, id, async = true, defer = false, onLoad, onError, attributes = {} }) => { const { hasConsent, isLoading } = useConsent(); const scriptRef = useRef(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 = ({ 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 ( <> { 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 = ({ 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 ( ); }; ``` ## Building the Consent Banner UI ```tsx // 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 (
); }; // ============================================ // PREFERENCE CENTER // ============================================ export const PreferenceCenter: React.FC = () => { const { showPreferenceCenter, closePreferenceCenter, consent, updateConsent } = useConsent(); const [localPreferences, setLocalPreferences] = useState(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 (
e.stopPropagation()} role="dialog" aria-labelledby="pref-title" aria-modal="true" >

Privacy Preferences

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.

{categories.map(category => (

{category.name}

{category.description}

{category.required && ( Always Active )}
))}
); }; ``` ## Testing Consent Management in React ```tsx // __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
Loading...
; return (
Analytics: {hasConsent('analytics') ? 'granted' : 'denied'}
Marketing: {hasConsent('marketing') ? 'granted' : 'denied'}
Banner: {showBanner ? 'visible' : 'hidden'}
); }; 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( ); expect(screen.getByText('Loading...')).toBeInTheDocument(); await waitFor(() => { expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); }); }); it('shows banner when no prior consent exists', async () => { render( ); await waitFor(() => { expect(screen.getByTestId('banner-status')).toHaveTextContent('visible'); }); }); it('grants all consent when Accept All is clicked', async () => { render( ); 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( ); 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( ); 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( ); 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( ); 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( ); 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( ); 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.

Ofte stillede spørgsmål

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.
A

Alex Kowalski, Platform Architect

Skribent hos GetCookies, specialiseret i privatlivsoverholdelse, samtykkeadministration og optimering af digital markedsføring.

Klar til at forenkle cookiesamtykke?

GetCookies gør GDPR, CCPA og global privatlivsoverholdelse ubesværet. Kom i gang i dag.