Building a Custom CMP in React: Hooks, Context, and Performance
Alex Kowalski, Platform ArchitectNovember 18, 202515 min lesing
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 (
We value your privacy
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.{' '}
Read our Privacy Policy
);
};
// ============================================
// 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 (
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
);
};
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.
Vanlige spørsmå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, spesialisert på personvernsamsvar, samtykkeadministrasjon og optimalisering av digital markedsføring.