React Native SDK interaction tracking mechanics
Table of contents
- Overview
- What you get out of the box
- 1. Events the SDK emits
- 2. How events flow from the device
- 3. Consent gating
- 4. Entry view tracking mechanics
- 5. Scroll context and viewport resolution
- 6. Tap tracking semantics
- 7. Screen tracking paths
- 8. The configuration surface
- 9. Manual tracking API
- 10. Putting it together
- Reference
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.
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.
| Event | When it fires | Required wiring |
|---|---|---|
| Screen view | Each time the active navigation route changes. | <OptimizationNavigationContainer> wrapping NavigationContainer (or useScreenTracking on each screen). |
| Entry view (initial) | When a wrapped entry has accumulated enough visible time (default 2000 ms at ≥ 80% visibility). | <OptimizedEntry baselineEntry={entry}> with view tracking enabled (the default). |
| Entry view (periodic updates) | Every viewDurationUpdateIntervalMs (default 5000 ms) while the entry remains visible. |
Same as above. |
| Entry view (final) | When visibility ends (scrolled away, unmounted, or app backgrounded) if at least one event already fired. | Same as above. |
| Entry tap | On touch end, when the touch moved less than 10 points from touch start, on a wrapped entry. | <OptimizedEntry> with tap tracking enabled (off by default; opt in via trackTaps or onTap). |
| Flag view | Internally emitted when a flag value changes (deduplicated via deep equality). Not strictly an interaction; worth knowing. | Any getFlag(...) call or states.flag(...) subscription. |
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.
| Method | Purpose |
|---|---|
identify |
Associates a known user ID and traits with the profile. Always allowed before consent. |
page |
Emits a page event. screen is the mobile idiom; page is rarely used in RN. |
screen |
Emits a screen event. What useScreenTracking and OptimizationNavigationContainer call under the hood. |
track |
Emits a generic track event for arbitrary business actions. |
trackView |
Manually emits an entry view event. |
trackClick |
Manually emits an entry click/tap event (wire type component_click). |
const optimization = useOptimization()
await optimization.identify('user-123', { plan: 'pro' })
await optimization.track('Added to Cart', { sku: 'ABC' })
await optimization.trackView({ componentId: 'entry-123', experienceId: 'exp-456', variantIndex: 0 })
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:
| Method | Wire event type |
|---|---|
trackView |
component |
trackClick |
component_click |
trackHover |
component_hover (not emitted by RN; included for completeness) |
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:
| API | Default base URL | Purpose |
|---|---|---|
| Experience API | https://experience.ninetailed.co/ |
Variant resolution, identify, sticky entry views, profile aggregation. |
| Insights API | https://ingest.insights.ninetailed.co/ |
All fire-and-forget interaction events: entry views, clicks, flag views, track, page, screen. |
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:
| Key | Contents |
|---|---|
CONSENT_KEY |
'accepted' / 'denied' / absent. |
PROFILE_CACHE_KEY |
The aggregated profile returned from the Experience API. |
SELECTED_OPTIMIZATIONS_CACHE_KEY |
Current audience/variant assignments. Drives which variant renders on next launch. |
ANONYMOUS_ID_KEY |
Stable anonymous identifier until identify is called. |
CHANGES_CACHE_KEY |
Pending profile changes. |
DEBUG_FLAG_KEY |
Forces logLevel to 'debug' when set. |
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.
| Consent | Behavior |
|---|---|
undefined |
Only allowedEventTypes emit. RN default: ['identify', 'screen']. All view/tap/track/page events are blocked. |
true |
All event types emit. |
false |
Same as undefined — only allowedEventTypes emit. Persists until consent(true) is called again. |
To widen the default pre-consent allow-list, pass allowedEventTypes to OptimizationRoot:
<OptimizationRoot clientId={CLIENT_ID} allowedEventTypes={['identify', 'screen', 'page']}>
<App />
</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:
| Constant | Value | Meaning |
|---|---|---|
DEFAULT_THRESHOLD |
0.8 |
Minimum visibility ratio (0.0 – 1.0). An entry is "visible" when at least 80% of its height is within the viewport. |
DEFAULT_VIEW_TIME_MS |
2000 |
Minimum accumulated visible time (ms) before the initial view event fires. A.k.a. the "dwell time". |
DEFAULT_VIEW_DURATION_UPDATE_INTERVAL_MS |
5000 |
Interval (ms) between periodic duration update events after the initial event. |
Tap tracking has one additional requirement:
| Constant | Value | Meaning |
|---|---|---|
TAP_DISTANCE_THRESHOLD |
10 |
Maximum pixel distance between touchStart and touchEnd. Beyond this, the gesture is classified as a scroll/drag, not a tap. |
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:
interface ViewCycleState {
viewId: string | null // UUID; correlates all events in this cycle
visibleSince: number | null // Timestamp of last visibility entry; null while paused
accumulatedMs: number // Running total of visible time
attempts: number // Number of view events already emitted
}
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:
requiredMs_for_event_N = dwellTimeMs + N * viewDurationUpdateIntervalMs
So with defaults:
| Event | When it fires (from cycle start) |
|---|---|
| Initial | 2000 ms accumulated visible |
| Periodic #1 | 7000 ms accumulated visible |
| Periodic #2 | 12 000 ms accumulated visible |
| Periodic #N | 2000 + N * 5000 ms accumulated visible |
| Final | At onVisibilityEnd, if attempts > 0 |
"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.
<OptimizationScrollProvider>
<OptimizedEntry baselineEntry={post}>
<ArticleBody post={post} />
</OptimizedEntry>
</OptimizationScrollProvider>
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.
<OptimizationRoot clientId={CLIENT_ID}>
<OptimizationNavigationContainer>
{(navigationProps) => (
<NavigationContainer {...navigationProps}>
<Stack.Navigator>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="BlogPostDetail" component={BlogPostDetailScreen} />
</Stack.Navigator>
</NavigationContainer>
)}
</OptimizationNavigationContainer>
</OptimizationRoot>
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).
function DetailsScreen() {
const { trackScreen } = useScreenTracking({
name: 'Details',
trackOnMount: false,
})
useEffect(() => {
if (dataLoaded) void trackScreen()
}, [dataLoaded, trackScreen])
}
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.
const trackScreen = useScreenTrackingCallback()
trackScreen('Deep Linked Article', { slug, source: 'email' })
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
| Prop | Type | Default | Controls |
|---|---|---|---|
trackEntryInteraction |
{ views?, taps? } |
{ views: true, taps: false } |
Default view/tap tracking for every <OptimizedEntry>. Omitted keys fall back to the defaults. |
liveUpdates |
boolean |
false |
Global live-updates default. When false, <OptimizedEntry> locks to the first variant it sees. |
previewPanel |
PreviewPanelConfig |
undefined |
Forces liveUpdates = true whenever the panel is open (cannot be overridden). |
onStatesReady |
(states) => cleanup |
undefined |
Registers app-level state subscribers when SDK state is ready. |
defaults.consent |
boolean | undefined |
undefined |
Initial consent state at startup. Overridden by consent() calls at runtime. |
allowedEventTypes |
EventType[] |
['identify', 'screen'] |
Event types permitted while consent is undefined or false. |
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
| Prop | Type | Default | Controls |
|---|---|---|---|
trackViews |
boolean | undefined |
undefined |
Per-entry override for view tracking. undefined inherits from trackEntryInteraction.views. |
trackTaps |
boolean | undefined |
undefined |
Per-entry override for tap tracking. undefined inherits from trackEntryInteraction.taps. |
onTap |
(resolved) => void |
undefined |
Implicitly enables tap tracking unless trackTaps is explicitly false. Fires after the click event. |
threshold |
number (0.0 – 1.0) |
0.8 |
Visibility ratio required to consider the entry visible. |
dwellTimeMs |
number |
2000 |
Dwell time before the initial view event. |
viewDurationUpdateIntervalMs |
number |
5000 |
Interval between periodic duration updates after the initial event. |
liveUpdates |
boolean | undefined |
undefined |
Per-entry live-updates override. See resolution order below. |
baselineEntry |
Entry |
(required) | The baseline or optimized Contentful entry. |
children |
ReactNode | ((resolved) => ReactNode) |
(required) | Render prop receives the resolved variant; static children are rendered as-is. |
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:
| Config option | Default | Controls |
|---|---|---|
api.experienceBaseUrl |
https://experience.ninetailed.co/ |
Where sticky views, identify, and variant resolution go. |
api.insightsBaseUrl |
https://ingest.insights.ninetailed.co/ |
Where all fire-and-forget interaction events go. |
api.beaconHandler |
undefined |
Optional custom beacon; takes over batch delivery to Insights if provided. |
fetchOptions.requestTimeout |
3000 |
Max ms per request before abort. |
fetchOptions.retries |
1 |
Retry attempts on failure. |
queuePolicy.flush |
See README | Backoff, circuit breaker, and retry callbacks for the flush loop. |
queuePolicy.offlineMaxEvents |
— | Cap on the offline buffer; overflow triggers onOfflineDrop. |
onEventBlocked |
undefined |
Callback when an event is blocked by the consent guard. Useful for surfacing misconfigurations. |
eventBuilder.channel |
'mobile' |
Channel tag on every event. RN SDK sets this; no need to change. |
logLevel |
'error' |
Set to 'debug' to see every gate decision. |
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.
const optimization = useOptimization()
useEffect(() => {
void optimization.trackView({
componentId: contentfulId,
experienceId,
variantIndex: 0,
viewDurationMs: 0,
})
}, [contentfulId, experienceId, optimization])
Payload shapes
optimization.trackView({
componentId: string,
viewId?: string, // UUID; correlates events in a cycle
experienceId?: string,
variantIndex?: number, // 0 for baseline
viewDurationMs?: number,
sticky?: boolean, // When true, also routes through Experience API
profile?: PartialProfile,
})
optimization.trackClick({
componentId: string,
experienceId?: string,
variantIndex?: number,
})
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:
;<OptimizationRoot
clientId={OPTIMIZATION_CLIENT_ID}
defaults={{ consent: true }}
trackEntryInteraction={{ views: true, taps: true }}
previewPanel={{ enabled: __DEV__, contentfulClient }}
>
<OptimizationNavigationContainer>
{(navigationProps) => (
<NavigationContainer {...navigationProps}>
<Stack.Navigator>
<Stack.Screen name="Home" component={HomeScreen} />
</Stack.Navigator>
</NavigationContainer>
)}
</OptimizationNavigationContainer>
</OptimizationRoot>
function HomeScreen({ navigation }) {
const { posts } = useContentfulData()
return (
<OptimizationScrollProvider>
{posts.map((post) => (
<OptimizedEntry
key={post.sys.id}
baselineEntry={post}
onTap={() => navigation.navigate('BlogPostDetail', { post })}
>
<BlogPostCard post={post} />
</OptimizedEntry>
))}
</OptimizationScrollProvider>
)
}
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.