A React Native plugin that wraps the native iOS / Android Salesforce Personalization SDKs. Renders personalized UI (SalesforceBanner, SalesforceRecommendations) in React Native apps and lets you ship your own custom components against the same backend.
Looking for a runnable demo? See the
example/app in the repository.
- Requirements
- Install
- Platform Setup
- Quick Start
- Public API Exports
- Core APIs
- Custom Components
- Architecture Compatibility
- More
| Requirement | Version |
|---|---|
| React Native | 0.76+ |
| React | 18.3+ or 19.x |
| Node.js | Host React Native requirement |
| iOS | 15.1+ |
| Android | API 26+ |
npm install @salesforce-personalization/react-native-personalization
# or
yarn add @salesforce-personalization/react-native-personalizationThe host app owns native SDK initialization — there is no JS-side
configure(...). The SDK requires CDP_APP_ID and CDP_ENDPOINT. The
example defaults DATASPACE to default and applies CDN_URL only when you
provide an override. Contact your Salesforce Marketing Cloud administrator for
tenant values, and don't commit real credentials.
After installing, follow the platform setup guide:
- iOS — see docs/iOS.md for
Info.plist, CocoaPods, andAppDelegate.swiftsetup. - Android — see docs/Android.md for
AndroidManifest.xmlandMainApplication.ktsetup.
Once the SDK is initialized natively and the user is opted in, render
personalized content with ContentZone. Do not mount a live zone while consent
is unset or opted out: changing consent does not automatically retry a failed
content request. Gate the zone on your app's persisted opt-in state, as shown
below. ContentZone fetches content for the named zone, matches the
backend's component name against your allowedComponents, and renders the
result.
import { Text } from 'react-native'
import {
ContentZone,
SalesforceBanner,
SalesforceRecommendations,
} from '@salesforce-personalization/react-native-personalization'
function HomeScreen({ isOptedIn }: { isOptedIn: boolean }) {
if (!isOptedIn) {
return <Text>Personalized content requires opt-in consent.</Text>
}
return (
<ContentZone
name="HomeScreen"
allowedComponents={[
SalesforceBanner({
onTap: (event) => console.log('Banner tapped:', event.header),
}),
SalesforceRecommendations({
onTap: (event) => console.log('Item tapped:', event.item.name),
}),
]}
loading={<Text>Loading personalized content...</Text>}
fallback={(error) => <Text>Could not load content</Text>}
/>
)
}Only name and allowedComponents are required. Fetch, timeout, component
matching, and model-validation failures render the optional fallback. Errors
thrown while composing or rendering a component propagate through React; use an
error boundary for those failures. When fallback is omitted, handled failures
render nothing.
See docs/GETTING_STARTED.md for the full guide.
The package root exports the following public API from src/index.tsx:
- Components and controller:
ContentZone,MockContentZone,useContentZoneController - OOTB factories:
SalesforceBanner,SalesforceRecommendations - Engagement:
trackEngagement,trackEngagementPerItem,useTrackEngagementViewOnce,useTrackEngagementViewOncePerItem,EngagementAction - Native bridge facade:
PersonalizationModule - Component types:
ContentZoneProps,MockContentZoneProps,ContentZoneController,Component,ComponentModel,ComponentContext,ContentSource,ViewableItemsChangedHandler - Banner types:
SalesforceBannerModel,SalesforceBannerStyle,SalesforceBannerConfig,SalesforceBannerTapEvent - Recommendation types:
SalesforceRecommendationsModel,SalesforceRecommendationsStyle,SalesforceRecommendationsConfig,SalesforceRecommendationItem,SalesforceRecommendationTapEvent,SalesforceRecommendationCardStyle,SalesforceSectionHeaderStyle - Identity and decision types:
PartyIdentification,ProfileAttributes,DecisionsRequestContext - Event types:
SFMCEvent,CustomEvent,EngagementEvent,SystemEvent,CartEvent,OrderEvent,CatalogObjectEvent,LineItem,Order,CatalogObject
The main component for displaying personalized content.
| Prop | Type | Required | Description |
|---|---|---|---|
name |
string |
Yes | Unique zone identifier matching server config |
allowedComponents |
Component[] |
Yes | Component renderers the zone may use |
controller |
ContentZoneController |
No | Controller for programmatic refresh |
timeoutSeconds |
number |
No | Fetch timeout in seconds (default: 10) |
decisionsRequestContext |
DecisionsRequestContext |
No | Contextual data to bias personalization |
loading |
ReactNode |
No | Loading-state UI |
fallback |
(error: Error) => ReactNode |
No | Error-state UI |
Pull-to-refresh. Pass a controller from useContentZoneController and
route a RefreshControl to controller.refresh():
import { useState } from 'react'
import { RefreshControl, ScrollView } from 'react-native'
import {
ContentZone,
SalesforceBanner,
useContentZoneController,
} from '@salesforce-personalization/react-native-personalization'
function HomeScreen() {
const controller = useContentZoneController()
const [refreshing, setRefreshing] = useState(false)
const refresh = async () => {
setRefreshing(true)
try {
await controller.refresh()
} finally {
setRefreshing(false)
}
}
return (
<ScrollView
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={refresh} />
}
>
<ContentZone
controller={controller}
name="HomeScreen"
allowedComponents={[SalesforceBanner()]}
/>
</ScrollView>
)
}controller.refresh() keeps existing content visible during the fetch; call
controller.refresh(true) to show the loading state instead.
Controller sharing is unsupported: create one controller per mounted zone.
Although a second binding is rejected, cleanup is not ownership-aware, so
mounting or unmounting multiple zones with one controller can leave it unbound.
Calling refresh() while unbound logs a warning and resolves without doing
anything. Changing name refetches and resubscribes. Changes to
timeoutSeconds or decisionsRequestContext do not trigger a fetch, but their
latest values are used by the next automatic or manual fetch.
allowedComponents is captured on mount.
timeoutSeconds must be positive. Loading defaults to no UI, and failures such
as native errors, timeouts, empty allowed components, unmatched component names,
or component validation errors call fallback(error) when supplied; otherwise
the zone renders nothing. Errors thrown by a component's compose function or
rendered React subtree are not converted to fallback state.
Forward preview deep links to the SDK so mounted zones affected by the changed per-zone preview receipt refetch:
import { useEffect } from 'react'
import { Linking } from 'react-native'
import { PersonalizationModule } from '@salesforce-personalization/react-native-personalization'
useEffect(() => {
const handleUrl = async ({ url }: { url: string }) => {
if (!url.includes('sfp-preview')) return
try {
await PersonalizationModule.handlePreviewUrl(url)
} catch (error) {
console.error('Failed to handle preview URL', error)
}
}
Linking.getInitialURL()
.then((url) => url && handleUrl({ url }))
.catch((error) => console.error('Failed to read initial URL', error))
const sub = Linking.addEventListener('url', handleUrl)
return () => sub.remove()
}, [])Deep-link setup is covered in docs/iOS.md and docs/Android.md.
Two ready-to-use components render production content with automatic
View/Click engagement tracking. Both accept an optional onTap callback
and a style config.
SalesforceBanner requires non-blank header and imageUrl values.
SalesforceRecommendations requires a non-empty items array; each retained
item needs non-blank id, name, and imageUrl. Invalid recommendation items
are logged and skipped, but the component fails if none remain. A non-string
model-level ctaText is logged and ignored.
Filtering is display resilience only. Engagement payload indices are not remapped after invalid items are removed, so per-item View/Click attribution can be incorrect when filtering occurs. Production responses should contain only valid items in the original serving order.
Cards are actionable only when an onTap callback or URL exists. On tap, the
OOTB component attempts Click tracking first, then calls onTap; when onTap
is present it takes precedence over opening ctaUrl/item.url. Banner defaults
include image fraction 0.3, content padding 12, white background, and max
image size 150. Recommendations default to one column, except landscape
phones use two; tablets remain one column. Their defaults include card spacing
12, horizontal card content padding 20 with vertical padding 0, white
background, and max image size 150.
A single banner with image, title, subtitle, and CTA. Backed by the
Salesforce_Banner transformer.
SalesforceBanner({
onTap: (event) => console.log('Banner tapped:', event.header),
style: {
backgroundColor: '#f0f0f0',
headerTextColor: '#1a1a1a',
ctaTextColor: '#0066cc',
},
})The tap event is the full SalesforceBannerModel: { id?, header, subheader?, imageUrl, ctaText?, ctaUrl? }.
A scrollable list of recommendation cards. Backed by the Salesforce_Recommendations
transformer.
SalesforceRecommendations({
onTap: (event) =>
console.log('Item tapped:', event.item.name, 'at index:', event.index),
style: {
cardSpacing: 16,
card: { nameTextColor: '#1a1a1a', descriptionTextColor: '#666' },
},
})The model is shared ctaText plus a list of items:
type SalesforceRecommendationItem = {
id: string
name: string
description?: string
imageUrl: string
url?: string
}
interface SalesforceRecommendationsModel {
sectionHeader?: string
ctaText?: string // shared by all items
items: SalesforceRecommendationItem[]
}The tap event is { item: SalesforceRecommendationItem, index: number }. See
docs/GETTING_STARTED.md
for the full style reference.
The OOTB SalesforceBanner and SalesforceRecommendations components
automatically attempt View and Click tracking when the serving includes
matching engagement payloads. Both PRODUCTION and PREVIEW content can carry
these payloads; MOCK content (via MockContentZone) never does.
For Recommendations, automatic per-item attribution assumes the rendered item order still aligns with the server-provided engagement payload order.
Custom components opt in via the engagementPayloads the SDK delivers on
ComponentContext. There are exactly four public runtime engagement APIs:
trackEngagement(context, action)— the un-deduped tracker for clicks and other repeatable signals on a single element. Call it on every tap.trackEngagementPerItem(context, index, action)— the un-deduped tracker for clicks and other repeatable signals on a list item. Call it on every tap.useTrackEngagementViewOnce(context)— a hook for view-appearance tracking (View) on a single-element component (e.g. a Banner or a custom card). Makes at most one tracking attempt per serving, from the component's own mount.useTrackEngagementViewOncePerItem(context)— a hook for view-appearance tracking on a flat-index virtualized list (FlatList/VirtualizedList). Returns a handler for the list'sonViewableItemsChanged; each item makes at most oneViewattempt per serving, from RN-reported viewability. (SectionListis not supported directly — its per-section index would collide across sections; see the hook's API docs.)
Both View hooks are convenience layers over the same once-per-serving dedup:
they key the guarantee on the per-serving ComponentContext identity (not on
personalizationId), and they own the correct wiring so you don't have to.
ComponentContext fields and engagement data are recursively readonly in
TypeScript, providing compile-time guidance against accidental mutation while
keeping the public type structural. Always pass the SDK-provided context
unchanged; spreading or copying it creates a new object identity and resets the
WeakMap-based View dedup scope.
React Native uses separate hooks for single elements and virtualized lists because
FlatList can mount rows before they enter the viewport, so "mounted" is not
"seen." Use the mount-based useTrackEngagementViewOnce for a single
always-present element and the visibility-based
useTrackEngagementViewOncePerItem for a list.
import {
EngagementAction,
trackEngagement,
useTrackEngagementViewOnce,
} from '@salesforce-personalization/react-native-personalization'
// Single-element custom component. The hook wires the once-per-serving View
// (effect + [context] dependency) for you.
useTrackEngagementViewOnce(context)
const handlePress = () => {
// repeat clicks are meaningful — never deduped
trackEngagement(context, EngagementAction.click)
}Single element vs. list — pick the matching hook. The difference is the trigger:
useTrackEngagementViewOncefires from the component's mount, which is correct for one always-present element like a Banner.useTrackEngagementViewOncePerItemfires from RN-reported viewability. This matters for lists: aFlatListmounts rows in a render buffer ahead of the viewport (windowSize,initialNumToRender), so a mount-based approach would count Views for off-screen rows that may never be seen. The list hook keys off RN'sonViewableItemsChangedreports instead. Pair it with aviewabilityConfigthat defines "viewed":
import { useTrackEngagementViewOncePerItem } from '@salesforce-personalization/react-native-personalization'
function MyRecs({ context, items }) {
const onViewableItemsChanged = useTrackEngagementViewOncePerItem(context)
return (
<FlatList
data={items}
renderItem={renderItem}
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
/>
)
}Already have your own onViewableItemsChanged? The hook composes — call the
returned handler from inside yours:
const trackViews = useTrackEngagementViewOncePerItem(context)
const onViewableItemsChanged = useCallback(
(info) => {
trackViews(info)
// ...your own logic (analytics, autoplay, …)
},
[trackViews]
)All four APIs are safe no-ops when tracking is not possible: a missing or
wrong-shape engagementPayloads logs at debug (expected for MOCK content or
contexts without payloads),
while an action not found on a correctly-shaped payload logs at warn (an
actionable misconfiguration). They never throw, and they do the payload lookup
for you, so custom components never inspect engagementPayloads directly.
Action names are server-defined and matched case-insensitively. The exported
EngagementAction constant provides the canonical names for the common
cases — EngagementAction.view ('View'), EngagementAction.click
('Click'), and EngagementAction.dismiss ('Dismiss') — with string values
byte-identical to the native iOS/Android SDKs. Using it is optional: every
engagement API still accepts any custom action string, so both View hooks
also take a custom override (e.g.
useTrackEngagementViewOnce(context, 'Impression')).
Why identity-keyed dedup matters: View is attempted at most once per serving even when
the same element reports becoming visible more than once within it — e.g. a
SalesforceRecommendations card that scrolls out of view and back in reports
one View attempt, and an in-place re-render does not re-attempt. A refresh
yields a new ComponentContext, which is a new serving, so both hooks make a
new tracking attempt: the mount-based useTrackEngagementViewOnce runs from its
[context] effect, and
useTrackEngagementViewOncePerItem replays the last set of items RN reported as
viewable through the new serving's limiter — so previously reported rows are attempted even
when an identical, unscrolled refresh emits no new viewability event. Because
that set comes from before the refresh, if the viewport changes without RN
re-reporting viewability, still-valid but now off-screen indices may each report
one View attempt for the new serving. The hooks use context identity, not
personalizationId, as the dedup key. See the
Custom Components Guide for a full
worked example.
import { PersonalizationModule } from '@salesforce-personalization/react-native-personalization'
// Profile ID (contact key)
await PersonalizationModule.setProfileId('user-123')
// Attributes — all values must be strings
await PersonalizationModule.setAttribute('tier', 'gold')
await PersonalizationModule.setAttributes({
firstName: 'John',
lastName: 'Doe',
})
// Read
const profileId = await PersonalizationModule.getProfileId()
const attributes = await PersonalizationModule.getAttributes()
// Clear attributes only; profile ID and party identification are unchanged
await PersonalizationModule.clearAttribute('tier')
await PersonalizationModule.clearAllAttributes()
// Party identification — per-field setters / getters
await PersonalizationModule.setPartyIdentificationName('John Doe')
await PersonalizationModule.setPartyIdentificationNumber('12345')
await PersonalizationModule.setPartyIdentificationType('individual')
const partyName = await PersonalizationModule.getPartyIdentificationName()
const partyNumber = await PersonalizationModule.getPartyIdentificationNumber()
const partyType = await PersonalizationModule.getPartyIdentificationType()All tracking flows through PersonalizationModule.track(event). Pick the
objType that matches the customer journey. The later calls omit try/catch
for brevity; handle their rejected Promises at the same application boundary as
the first example.
// Custom — free-form name + attributes
try {
await PersonalizationModule.track({
objType: 'CustomEvent',
name: 'product_viewed',
attributes: { productId: 'ABC123', category: 'shoes' },
})
} catch (error) {
console.error('Event tracking failed', error)
}
// Cart — subtype: 'add' | 'remove' use a singular `lineItem`;
// subtype: 'replace' uses a `lineItems` array
await PersonalizationModule.track({
objType: 'CartEvent',
subtype: 'add',
lineItem: {
catalogObjectType: 'product',
catalogObjectId: 'ABC123',
quantity: 2,
price: 49.99,
currency: 'USD',
},
})
// Order — subtype: 'purchase' | 'preorder' | 'cancel' | 'ship' | 'deliver' | 'return' | 'exchange'
await PersonalizationModule.track({
objType: 'OrderEvent',
subtype: 'purchase',
order: {
id: 'ORDER-123',
lineItems: [
{
catalogObjectType: 'product',
catalogObjectId: 'ABC123',
quantity: 2,
price: 49.99,
currency: 'USD',
},
],
totalValue: 99.98,
currency: 'USD',
},
})
// Catalog — subtype: 'view' | 'viewDetail' | 'quickView' | 'favorite' | 'share' | 'review' | 'comment'
await PersonalizationModule.track({
objType: 'CatalogEvent',
subtype: 'view',
catalogObject: {
type: 'product',
id: 'ABC123',
attributes: { name: 'Blue Sneakers' },
},
})See docs/GETTING_STARTED.md for the full event reference.
isConsentOptIn() returns true only when native consent is OPT_IN; explicit
OPT_OUT and consent that has not yet been set both return false. Set consent
according to the native CDP SDK's policy before tracking or requesting
personalized content. This wrapper forwards consent and data operations without
adding a separate JavaScript consent gate.
await PersonalizationModule.setConsent(true) // OPT_IN
await PersonalizationModule.setConsent(false) // OPT_OUT
const isOptedIn = await PersonalizationModule.isConsentOptIn()Render personalized content however you want by implementing Component<T>.
Two patterns:
- Reuse an OOTB transformer (e.g.
Salesforce_Banner,Salesforce_Recommendations) with your own UI — your UI explicitly wires the View/Click helpers and uses engagement payloads when the serving supplies them. No backend configuration is needed. - Define a fully custom transformer with your own fields — you implement engagement tracking manually.
import {
EngagementAction,
trackEngagement,
useTrackEngagementViewOnce,
} from '@salesforce-personalization/react-native-personalization'
import type {
Component,
ComponentModel,
ComponentContext,
} from '@salesforce-personalization/react-native-personalization'
import { Image, Text, TouchableOpacity } from 'react-native'
interface ProductCardModel extends ComponentModel {
header: string
imageUrl: string
ctaText?: string
}
export function ProductCard(config?: {
onTap?: (m: ProductCardModel) => void
}): Component<ProductCardModel> {
return {
name: 'Salesforce_Banner', // reuse the SalesforceBanner transformer's data + engagement payloads
validateAndCreateComponentModel: (json, context) => {
const data = JSON.parse(json)
if (!data.header || !data.imageUrl)
throw new Error('Missing required fields')
return data as ProductCardModel
},
compose: (model, context) => (
<ProductCardView model={model} context={context} onTap={config?.onTap} />
),
}
}
function ProductCardView({
model,
context,
onTap,
}: {
model: ProductCardModel
context: ComponentContext
onTap?: (model: ProductCardModel) => void
}) {
useTrackEngagementViewOnce(context)
const handlePress = () => {
trackEngagement(context, EngagementAction.click)
onTap?.(model)
}
return (
<TouchableOpacity onPress={handlePress}>
<Image
source={{ uri: model.imageUrl }}
style={{ width: '100%', height: 200 }}
/>
<Text>{model.header}</Text>
</TouchableOpacity>
)
}ComponentContext carries:
contentSource:'PRODUCTION'|'PREVIEW'|'MOCK'personalizationId: identifier supplied for the served personalization; context identity is the JavaScript serving boundaryengagementPayloads: tracking data supplied by OOTB transformerscomponentName(optional): resolved component name, used to tag engagement diagnostic logs
For View, use the hook that matches your component shape —
useTrackEngagementViewOnce(context) for a single element,
useTrackEngagementViewOncePerItem(context) for a flat-index virtualized list
(FlatList/VirtualizedList). Both dedup on the context's own object identity,
so a repeated view-appearance signal within a serving makes one View attempt.
On a refresh's new ComponentContext (a new serving), both hooks make a new
tracking attempt: the mount hook from its [context] effect, and the scroll
hook by replaying the last-viewable item set through the new serving's limiter
(so previously reported rows are attempted even when an identical, unscrolled refresh emits
no new viewability event). Because the replay uses the pre-refresh set, a
viewport change without a new RN visibility report may cause still-valid but
now off-screen indices to each report one View for the new serving.
📖 See the Custom Components Guide for complete examples with engagement tracking, including SalesforceRecommendations-shaped list payloads.
This plugin uses TurboModules. React Native 0.76 through 0.81 supports both
architectures, with the New Architecture enabled by default; the plugin also
works when those versions explicitly opt out. React Native 0.82 and later are
New-Architecture-only and ignore Legacy opt-out flags. Your JS/TS code needs no
changes either way: autolinking runs Codegen from the plugin's codegenConfig.
For React Native 0.76 through 0.81 only, use the version's documented opt-out settings if you must run the Legacy Architecture. Otherwise keep the generated template defaults. Clean native build artifacts after changing architecture settings.
Either way, PersonalizationModule, ContentZone, and every other exported
API behave identically. See
TROUBLESHOOTING.md
if you hit a Codegen or CMake linking error after switching architectures.
- docs/GETTING_STARTED.md — full API reference and usage guide.
- docs/TROUBLESHOOTING.md — common issues and fixes.
- docs/iOS.md / docs/Android.md — host-app setup guides.
- docs/RUNNING_THE_EXAMPLE.md — first-time guide to build & run the iOS/Android example app.
- example/ — runnable demo app with OOTB and custom components.
- CHANGELOG.md — release notes.
- SECURITY.md — security and vulnerability reporting.
- CONTRIBUTING.md — how to contribute.
BSD-3-Clause License — see LICENSE for details.
- 🐛 Issues: GitHub Issues
- 📖 Documentation: docs/GETTING_STARTED.md