The SPA Analytics Paradox

Single Page Applications (SPAs) built on React present a fundamental challenge for Google Tag Manager (GTM): there are no true page loads. Because the browser never fully reloads, traditional GTM "Page View" triggers and DOM scraping methods break completely. If you want reliable GA4 E-Commerce, Meta CAPI, and TikTok Events out of a React SPA, you cannot rely on GTM's auto-magic. You must treat the dataLayer as a strict API contract between frontend developers and analysts. React to GTM Architecture Fixing State Contamination

Single Container Architecture

Instead of cluttering the React codebase with raw GA4 gtag() calls, Meta pixel functions, and TikTok tags, we centralized everything through a single GTM container acting as a router.

React App
↓ window.dataLayer.push({ event: 'purchase', ecommerce: {...} })
GTM Container
├── GA4 Configuration Tag (Pageviews)
├── GA4 E-Commerce Events Tag (purchase, add_to_cart)
├── Meta Pixel + Conversions API
├── TikTok Events API Tag
└── Google Ads Tracking

The Strict TypeScript Contract

We defined the exact schema required using TypeScript interfaces. Any deviation from this schema causes silent data loss.

interface EcommerceItem {
item_id: string;
item_name: string;
item_category: string;
price: number;
quantity: number;
}
interface EcommerceEvent {
event: 'view_item' | 'begin_checkout' | 'purchase';
ecommerce: {
currency: 'KZT';
value: number;
transaction_id?: string;
items: EcommerceItem[];
};
user_data?: {
phone_number?: string;
external_id?: string; // IIN hash
};
}

React Hook Implementation & The Cardinal Rule

The most common mistake causing 200–300% inflated E-Commerce revenue in SPAs is state contamination. When you push an ecommerce object into the dataLayer, GTM merges it with the previously pushed object. If you don't explicitly clear the state before the subsequent event, you will pass duplicated arrays. The Fix: Always clear the object first (ecommerce: null).

export function useAnalytics() {
const track = (event: EcommerceEvent) => {
// CRITICAL: Clear previous state to prevent duplication
window.dataLayer.push({ ecommerce: null });
window.dataLayer.push(event);
};
const trackSubmit = (app: Application) => track({
event: 'purchase',
ecommerce: {
currency: 'KZT',
value: app.requestedAmount,
transaction_id: app.id,
items: [{ item_id: app.productId, item_name: app.productName,
item_category: 'loan_application', price: app.requestedAmount, quantity: 1 }]
},
user_data: { phone_number: app.phone, external_id: app.iinHash }
});
return { trackSubmit };
}

Virtual Pageviews

To simulate navigation without reloads, we injected a monkeypatch into the History API. This fires a virtual_pageview event to GTM upon every route change, telling the GA4 Configuration tag to execute a manual page_view.

(function() {
var pushState = history.pushState;
history.pushState = function() {
pushState.apply(history, arguments);
window.dataLayer.push({
event: 'virtual_pageview',
page_path: window.location.pathname,
page_title: document.title
});
};
})();

Impact

By establishing a strongly-typed dataLayer contract, we attained 100% parity between backend CRM revenue figures and Google Analytics E-commerce module, entirely eliminating frontend double-counting glitches and rogue deduplication errors.

Further Reading & Deeper Dive

Analytics in SPAs requires a paradigm shift from "page-centric" to "event-centric" tracking.