React Native SDK interaction tracking mechanics
Overview
This page helps you understand exactly what the @contentful/optimization-react-native SDK is tracking, when each event fires, and how it leaves the device. Every number, state transition, and gate is grounded in SDK source so you can reason about tracking behavior without running a live experiment.
The corresponding Integrate the Optimization React Native SDK in a React Native app walks through the setup, consent, and screen wiring at a tutorial level. We recommend you read that first to understand how to plug the SDK in, then read this page to understand why the entry view is not firing.
The Optimization SDK is currently in beta. For more information, reach out to your Sales representative.
What you get out of the box
If you drop OptimizationRoot at the top, wrap NavigationContainer in OptimizationNavigationContainer, and wrap Contentful entries in <OptimizedEntry />, you get:
- Entry view tracking: Initial event after 2 s at ≥ 80% visibility, periodic updates every 5 s while visible, final event on scroll-away / unmount.
- Screen tracking on every navigation change.
- Identify/screen events before consent (blocked events: everything else until consent is
true). - Offline queueing and background flushing when
@react-native-community/netinfois installed. - Persistence across launches via AsyncStorage (consent, profile, anonymous ID, selected optimizations).
Things you still have to enable yourself:
- Tap tracking: This option is off by default. Opt in via
trackEntryInteraction={{ taps: true }},trackTaps, or anonTapcallback. - Accurate scroll-based view tracking: Wrap the scroll view in
<OptimizationScrollProvider>. - Consent UI: The SDK exposes
consent(true | false). The banner is yours. - Manual tracking for non-Contentful surfaces:
optimization.trackView/trackClick.
1. Events the SDK emits
“Tracking” in the React Native SDK is a small, fixed set of event types. Some are fired by the SDK as a side effect of component rendering and user behavior. Others are explicit method calls you make from application code.
Automatic events
These are emitted by the SDK without an application-level call, as long as consent allows and the relevant provider/component is mounted.
Manual events
Call these on the SDK instance from useOptimization(). Use them for screens or components that don’t fit the OptimizedEntry pattern, or for business events unrelated to a Contentful entry.
At the wire level, “automatic” and “manual” events funnel through the same emission pipeline.
Wire type mapping
The on-the-wire event types used by the Insights API do not always match the public method name. In particular:
These wire types are shared across SDK runtimes.
2. How events flow from the device
The two APIs
The SDK talks to two HTTP endpoints, both defaulting to Contentful Personalization hosts:
Both are configurable via the api config on the SDK (see section 8).
A single user action can touch either or both APIs. trackView({ sticky: true }) delivers through Experience first (sticky views become part of the profile) then through Insights. Plain trackView only hits Insights. identify only touches Experience.
Queueing, flushing, and offline
- Both APIs are fronted by an in-memory queue in the core SDK.
- Events are enqueued, never sent synchronously.
- Insights events are batched and POSTed.
- Experience events are per-request, but the queue handles offline replay and circuit breaking.
- Retry/backoff is configurable via
queuePolicy.flush.
The React Native SDK layers RN-specific behavior on top:
- Online/offline detection via
@react-native-community/netinfo. When offline, the queue buffers. WhenisInternetReachable(preferred) orisConnectedflips back totrue, the SDK resumes flushing. If NetInfo is not installed the SDK logs a warning and stays always-online — you keep tracking but lose offline durability. - Background flushing. On
AppStatetransition tobackgroundorinactive, the SDK callsflush()to drain the queue before the OS might suspend the process. - Final view event on background. If an entry is mid-visibility-cycle when the app backgrounds
useViewportTrackingpauses, emits a final view event if at least one event already fired, and resets.
The offline queue has a cap (queuePolicy.offlineMaxEvents) and a drop callback (queuePolicy.onOfflineDrop). See the README for the common queue configuration entry point.
Persistence via AsyncStorage
AsyncStorageStore persists the following across launches so tracking decisions and variant assignments survive a cold start:
Persistence is best-effort. Write failures keep the SDK running on in-memory state. Structured values are schema-validated on load, and malformed JSON is evicted.
This matters for tracking because selected optimizations persist. So, a user placed in Variant B continues to see it on the next launch and view/tap events carry the correct experienceId variantIndex without re-round-tripping Experience first.
3. Consent gating
The SDK gates event emission behind a three-valued consent state: true, false, or undefined (unset). This is the most common cause of “tracking isn’t working” during integration. Without defaults.consent: true or a banner that calls optimization.consent(true), everything except identify and screen is dropped silently at the SDK boundary.
To widen the default pre-consent allow-list, pass allowedEventTypes to OptimizationRoot:
When consent flips:
consent(true). New events flow normally. Blocked events are not retroactively replayed. They were dropped at the guard (you can observe this viaonEventBlocked). Consent persists to AsyncStorage.consent(false). The allow-list gate re-engages. In-flight events that already cleared the guard continue to flush.
”Why is nothing tracking?”
Four checks, in order of likelihood:
- Consent. Without
defaults.consent: trueor a user accept, onlyidentify/screengo out. SetlogLevel: 'info'to see blocked events in the console. - Tap tracking opt-in. Views default to
true, taps default tofalse. - Visibility requirement. Defaults are strict (80% for 2 s). Scroll-by content never fires.
- No scroll context. An entry below the fold without
<OptimizationScrollProvider>will never pass the visibility requirement —scrollYis assumed0.
4. Entry view tracking mechanics
This section describes the internals of useViewportTracking, the hook <OptimizedEntry /> uses under the hood.
Default visibility and timing
The default entry view settings are:
Tap tracking has one additional requirement:
The visibility state machine
Each mounted <OptimizedEntry> runs a small state machine keyed on a “visibility cycle”: a cycle starts when the entry goes from not-visible to visible, and ends when it transitions back or unmounts. State lives in refs (not React state) to avoid re-rendering on every scroll tick:
On every scroll tick or layout change, checkVisibility() computes the overlap between the entry’s measured {y, height} and the current viewport {scrollY, viewportHeight} to derive a visibilityRatio, and compares it to minVisibleRatio:
- not-visible → visible —
onVisibilityStartresets the cycle, mints a freshviewId, setsvisibleSince = now, and schedules the next fire. - visible → not-visible —
onVisibilityEndclears the fire timer, pauses accumulation, emits a final event ifattempts > 0, and resets the cycle.
Initial, periodic, and final events
Within a cycle, events fire based on accumulated visible time. The schedule mirrors the Web SDK’s ElementViewObserver:
So with defaults:
“Accumulated” is load-bearing: if the user scrolls away at 1.5 s and back 10 s later, the timer resumes from 1.5 s and takes another 0.5 s to fire the initial event. Pause/resume is driven by visibleSince being set/cleared.
A few consequences:
- An entry briefly scrolled into view, less than 2 s total, fires no events. The initial gate is never crossed, so the final event is suppressed (guarded by
attempts > 0). - An entry scrolled into view for 2 s and then immediately unmounted fires one initial event, then one final event from the unmount cleanup effect.
- Each event carries
viewDurationMs, computed from the cycle’s accumulated time at the moment of emission. The sequence of events for a 12 s continuous view is: initial (about 2000 ms), periodic (about 7000 ms), periodic (about 12 000 ms), final (about 12 000 ms). - Each event also carries
viewId— the UUID for the cycle. All events in one cycle share aviewId. A new cycle gets a fresh one. UseviewIddownstream to correlate.
App backgrounding and cleanup
Two additional transitions matter:
-
AppState → background or inactive. The hook listens to
AppStatechanges. On transition to background/inactive, it clears the fire timer, pauses accumulation, and, ifattempts > 0, emits a final event before resetting the cycle and markingisVisibleRef.current = false. When the app becomesactiveagain, it re-checks visibility from scratch, which will start a new cycle if the entry is still on screen. -
Component unmount. The unmount cleanup clears the fire timer and, if the cycle had any successful events (
attempts > 0), flushes a final view event synchronously.
Combined, these guarantees mean that as long as the initial event fired, a final event (with a matching viewId and the true total duration) will always follow, whether visibility ends naturally, the user backgrounds the app, or the component unmounts.
5. Scroll context and viewport resolution
useViewportTracking needs the entry’s position ({y, height} from onLayout) and the viewport ({scrollY, viewportHeight}). Where the viewport comes from depends on whether the entry sits inside <OptimizationScrollProvider>.
Inside OptimizationScrollProvider
OptimizationScrollProvider wraps React Native’s ScrollView and publishes the current scrollY
and layout height through context. The hook reads scroll on every event (scrollEventThrottle={16},
~60 FPS) and recomputes visibility.
Use this for any scrollable screen. Without it, entries below the fold never transition to visible no matter how far the user scrolls.
The React Native reference implementation demonstrates this scroll-provider pattern in its entry list.
Outside OptimizationScrollProvider
With no scroll context, the hook falls back to screen dimensions — scrollY = 0, viewport = Dimensions.get('window').height with an orientation listener.
This is correct for full-screen non-scrollable layouts, hero/banner content always on screen, and modal content. It is incorrect for anything below the fold in a ScrollView. You need to wrap those.
6. Tap tracking semantics
Tap tracking is implemented by useTapTracking. Behavior:
- The wrapping
ViewgetsonTouchStart/onTouchEnd(notonPress). Raw touch events mean taps are captured even when a childPressablealso handles the press. APressablewrapper gives the child’sonPressprecedence. onTouchStartrecords{ pageX, pageY }.onTouchEndcomputes Euclidean distance from start to end. UnderTAP_DISTANCE_THRESHOLD(10 points) → tap; over → scroll/drag, ignored.- On tap:
optimization.trackClick({ componentId, experienceId, variantIndex })(wire typecomponent_click). IfonTapwas passed on<OptimizedEntry>, it’s also invoked synchronously with the resolved entry.
Tap tracking is off by default. Enable via <OptimizationRoot trackEntryInteraction={{ taps: true }}>, <OptimizedEntry trackTaps>, or implicitly by passing onTap.
7. Screen tracking paths
Screen tracking emits screen events, which are allowed before consent and feed into route-based profile attribution. The SDK gives you three paths.
OptimizationNavigationContainer
The highest-automation path. Wrap NavigationContainer in <OptimizationNavigationContainer> and a screen event fires automatically on every active-route change, including the initial ready.
onReady fires the initial screen event. onStateChange compares the current route name to the previous and emits a new screen event when they differ. includeParams: true includes the route params in the event’s properties, which are JSON-validated before being attached.
useScreenTracking
Per-screen hook for apps not using React Navigation, or when you want fine-grained control over when the event fires (e.g. after data loads).
With trackOnMount: true (the default), it fires once on mount. The hook also resets its internal
tracking state whenever name changes, so renaming the screen mid-life re-fires.
useScreenTrackingCallback
Returns a stable (name, properties?) => void callback for imperative screen tracking with names that aren’t known at render time (deep links, dynamic titles, navigation state transforms). OptimizationNavigationContainer uses this internally.
8. The configuration surface
All interaction-tracking behavior is controlled at one of three layers: SDK init config, OptimizationRoot props, or per-component <OptimizedEntry> props. Lower layers override higher ones.
OptimizationRoot props
The “{ views: true, taps: false }” default is the root interaction-tracking context default. Use onStatesReady when diagnostics or app-level observers should attach as soon as SDK state exists and before provider children can emit screen, eventStream, or blockedEventStream updates. Component-local state should still subscribe from hooks and effects under the provider.
OptimizedEntry props
Each default is defined by the SDK component and tracking hook behavior.
SDK init config
Beyond the layer above, the full CoreStatefulConfig is accepted as OptimizationRoot props (since OptimizationRootProps extends CoreStatefulConfig). The ones that directly shape tracking:
The full configuration reference lives in the React Native SDK README.
Resolution order
View tracking enabled?
- If
<OptimizedEntry trackViews={true|false}>, use that. - Else use
trackEntryInteraction.viewsfromOptimizationRoot. - Else use the default (
true).
Tap tracking enabled?
- If
<OptimizedEntry trackTaps={true|false}>, use that. - Else if
<OptimizedEntry onTap={...}>is provided, usetrue. - Else use
trackEntryInteraction.tapsfromOptimizationRoot. - Else use the default (
false).
Live updates enabled?
- If the preview panel is open — always
true, cannot be overridden. - Else if
<OptimizedEntry liveUpdates={true|false}>, use that. - Else use
OptimizationRoot.liveUpdates. - Else default (
false; the entry locks to its first variant).
9. Manual tracking API
For content that doesn’t fit <OptimizedEntry>, such as custom screens, server-rendered fragments, non-Contentful components, call tracking methods directly on the SDK instance. These hit the same wire pipeline, consent gates, and offline queue.
Payload shapes
When to reach for manual tracking
- Screen-wide entry views without viewport-visibility semantics —
trackViewfromuseEffecton mount. - Non-Contentful UI that counts as a component click —
trackClickfrom aPressable’sonPress. - Business events unrelated to a Contentful entry —
track('Added To Cart', { sku }).
For anything backed by a Contentful entry, prefer <OptimizedEntry> — it handles the state machine, initial/periodic/final sequencing, final-on-unmount, final-on-background, and viewId correlation for you.
10. Putting it together
A fully-instrumented list screen combines every mechanism in this guide:
What fires:
- On launch: Consent is seeded
true, so view/tap events flow immediately. - Every route change: A
screenevent viaOptimizationNavigationContainer. - Per card scrolled into view for ≥ 2 s: Initial entry view, periodic updates every 5 s, final event on scroll-away / unmount.
- Per card tapped: A
component_clickevent plus theonTapcallback. - On backgrounding mid-view: A final view event for any card mid-cycle. The queue flushes before the OS suspends the process.
- Offline: Events buffer and replay on reconnect.
For the broader integration walkthrough, read the React Native SDK integration guide.
Reference
- React Native SDK README - Package-level orientation and common configuration.
- React Native reference implementation - Working app that exercises the React Native SDK API surface in this monorepo.
- Integrate the Optimization React Native SDK in a React Native app - Step-by-step React Native integration flow.