diff --git a/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md b/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md
index f5359ca0..32fe960e 100644
--- a/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md
+++ b/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md
@@ -186,12 +186,16 @@ Inside the provider tree, use hooks to interact with the SDK:
import {
useEntryResolver,
useOptimization,
+ useOptimizationActions,
useOptimizationContext,
} from '@contentful/optimization-react-web'
function MyComponent() {
- const { consent, identify, page, track, getFlag } = useOptimization()
+ const { consent, identify, page, track } = useOptimizationActions()
+ const optimization = useOptimization()
const { resolveEntry } = useEntryResolver()
+
+ optimization.getFlag('hero-copy')
// SDK is guaranteed to be ready here
}
@@ -293,10 +297,10 @@ When your application policy depends on user choice, call `consent()` from the b
or account settings flow that owns the user's choice:
```tsx
-import { useOptimization } from '@contentful/optimization-react-web'
+import { useOptimizationActions } from '@contentful/optimization-react-web'
function ConsentBanner() {
- const { consent } = useOptimization()
+ const { consent } = useOptimizationActions()
return (
@@ -348,14 +352,12 @@ function ConsentStatus() {
To revoke consent after it was previously accepted:
```tsx
-function RevokeConsent() {
- const { consent } = useOptimization()
+import { useOptimizationActions } from '@contentful/optimization-react-web'
- const handleRevoke = () => {
- consent(false)
- }
+function RevokeConsent() {
+ const { consent } = useOptimizationActions()
- return
+ return
}
```
diff --git a/packages/web/frameworks/react-web-sdk/README.md b/packages/web/frameworks/react-web-sdk/README.md
index d2ab307b..c1fdff87 100644
--- a/packages/web/frameworks/react-web-sdk/README.md
+++ b/packages/web/frameworks/react-web-sdk/README.md
@@ -153,14 +153,14 @@ end-user consent UI, seed accepted consent on `OptimizationRoot`:
```
When application policy depends on user choice, leave `defaults.consent` unset and call `consent()`
-from the relevant control:
+from `useOptimizationActions()` in the relevant control:
```tsx
-import { useOptimization } from '@contentful/optimization-react-web'
+import { useOptimizationActions } from '@contentful/optimization-react-web'
function ConsentButton() {
- const sdk = useOptimization()
- return
+ const { consent } = useOptimizationActions()
+ return
}
```
@@ -175,9 +175,24 @@ continuity should stay session-only. For cross-SDK consent guidance, see
commit, outside render, and renders no children while the SDK is pending. In normal browser
rendering this uses a layout-effect path so ready children can mount before the first visible paint.
-Use `useOptimization()` when a component needs direct access to the instance for methods such as
-`identify()`, `reset()`, or manual tracking. Use `useEntryResolver()` when a component needs manual
-entry resolution without the `OptimizedEntry` wrapper:
+Use the dedicated React SDK action hooks when components need common Optimization actions:
+
+```tsx
+import { useOptimizationActions } from '@contentful/optimization-react-web'
+
+function ProductCta() {
+ const { track } = useOptimizationActions()
+
+ return
+}
+```
+
+Use `useOptimization()` when a component needs direct access to the SDK instance itself, and prefer
+`useOptimizationActions()` when a component wants destructurable action methods such as `track()`,
+`identify()`, `page()`, or `consent()`.
+
+Use `useEntryResolver()` when a component needs manual entry resolution without the `OptimizedEntry`
+wrapper:
`useOptimization()` returns the SDK instance itself. Keep that instance in a variable and call
methods from it. Do not destructure SDK methods from the returned value because those methods rely
diff --git a/packages/web/frameworks/react-web-sdk/package.json b/packages/web/frameworks/react-web-sdk/package.json
index da7bd8b9..f148205c 100644
--- a/packages/web/frameworks/react-web-sdk/package.json
+++ b/packages/web/frameworks/react-web-sdk/package.json
@@ -121,8 +121,8 @@
"buildTools": {
"bundleSize": {
"gzipBudgets": {
- "index.cjs": 3300,
- "index.mjs": 2400
+ "index.cjs": 3400,
+ "index.mjs": 2500
}
}
},
diff --git a/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationActions.ts b/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationActions.ts
new file mode 100644
index 00000000..26ecbfb4
--- /dev/null
+++ b/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationActions.ts
@@ -0,0 +1,48 @@
+import { useMemo } from 'react'
+
+import type { OptimizationSdk } from '../context/OptimizationContext'
+import { useOptimization } from './useOptimization'
+
+/**
+ * Bound Optimization SDK actions safe to destructure in React components.
+ *
+ * @public
+ */
+export interface UseOptimizationActionsResult {
+ readonly consent: OptimizationSdk['consent']
+ readonly identify: OptimizationSdk['identify']
+ readonly page: OptimizationSdk['page']
+ readonly track: OptimizationSdk['track']
+}
+
+/**
+ * Returns bound Optimization SDK actions that are safe to destructure.
+ *
+ * @example
+ * ```tsx
+ * const { track, consent } = useOptimizationActions()
+ * await track({ event: 'purchase' })
+ * consent(true)
+ * ```
+ *
+ * @remarks
+ * This hook does not create a new SDK instance. It binds the most common
+ * actions from the existing SDK instance returned by `useOptimization()`.
+ *
+ * @public
+ */
+export function useOptimizationActions(): UseOptimizationActionsResult {
+ const sdk = useOptimization()
+
+ return useMemo
(
+ () => ({
+ consent: (value) => {
+ sdk.consent(value)
+ },
+ identify: async (payload) => await sdk.identify(payload),
+ page: async (payload) => await sdk.page(payload),
+ track: async (payload) => await sdk.track(payload),
+ }),
+ [sdk],
+ )
+}
diff --git a/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationState.ts b/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationState.ts
new file mode 100644
index 00000000..50e33598
--- /dev/null
+++ b/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationState.ts
@@ -0,0 +1,94 @@
+import { useCallback, useRef, useSyncExternalStore } from 'react'
+
+import type { OptimizationSdk } from '../context/OptimizationContext'
+import { useOptimization } from './useOptimization'
+
+type OptimizationStates = OptimizationSdk['states']
+type ObservableValue = T extends { readonly current: infer V } ? V : never
+
+interface ObservableLike {
+ readonly current: T
+ readonly subscribe: (next: (value: T) => void) => { unsubscribe: () => void }
+}
+
+function useObservableState(observable: ObservableLike): T {
+ const snapshotRef = useRef(observable.current)
+ const observableRef = useRef(observable)
+
+ if (observableRef.current !== observable) {
+ const { current } = observable
+ observableRef.current = observable
+ snapshotRef.current = current
+ }
+
+ const subscribe = useCallback(
+ (onStoreChange: () => void) => {
+ const subscription = observable.subscribe((value) => {
+ snapshotRef.current = value
+ onStoreChange()
+ })
+
+ return () => {
+ const { unsubscribe } = subscription
+ unsubscribe()
+ }
+ },
+ [observable],
+ )
+
+ const getSnapshot = useCallback(() => snapshotRef.current, [])
+
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
+}
+
+/**
+ * Returns the current consent state.
+ *
+ * @public
+ */
+export function useConsentState(): ObservableValue {
+ const sdk = useOptimization()
+ return useObservableState(sdk.states.consent)
+}
+
+/**
+ * Returns whether optimization data is currently available.
+ *
+ * @public
+ */
+export function useCanOptimizeState(): ObservableValue {
+ const sdk = useOptimization()
+ return useObservableState(sdk.states.canOptimize)
+}
+
+/**
+ * Returns the latest emitted event payload.
+ *
+ * @public
+ */
+export function useEventStreamState(): ObservableValue {
+ const sdk = useOptimization()
+ return useObservableState(sdk.states.eventStream)
+}
+
+/**
+ * Returns the current profile state.
+ *
+ * @public
+ */
+export function useProfileState(): ObservableValue {
+ const sdk = useOptimization()
+ return useObservableState(sdk.states.profile)
+}
+
+/**
+ * Returns the current selected optimizations state.
+ *
+ * @public
+ */
+export function useSelectedOptimizationsState(): ObservableValue<
+ OptimizationStates['selectedOptimizations']
+> {
+ const sdk = useOptimization()
+ return useObservableState(sdk.states.selectedOptimizations)
+}
diff --git a/packages/web/frameworks/react-web-sdk/src/index.test.tsx b/packages/web/frameworks/react-web-sdk/src/index.test.tsx
index 19cde1ca..de1274d0 100644
--- a/packages/web/frameworks/react-web-sdk/src/index.test.tsx
+++ b/packages/web/frameworks/react-web-sdk/src/index.test.tsx
@@ -14,11 +14,13 @@ import {
useEntryResolver,
useLiveUpdates,
useOptimization,
+ useOptimizationActions,
useOptimizationContext,
useOptimizedEntry,
type OptimizationContextValue,
type OptimizationSdk,
type UseEntryResolverResult,
+ type UseOptimizationActionsResult,
} from './index'
import {
captureRenderError,
@@ -37,7 +39,10 @@ const testConfig = {
},
}
-function renderClient(element: ReactElement): { unmount: () => void } {
+function renderClient(element: ReactElement): {
+ rerender: (next: ReactElement) => void
+ unmount: () => void
+} {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
@@ -47,6 +52,11 @@ function renderClient(element: ReactElement): { unmount: () => void } {
})
return {
+ rerender(next: ReactElement) {
+ act(() => {
+ root.render(next)
+ })
+ },
unmount() {
act(() => {
root.unmount()
@@ -72,6 +82,7 @@ describe('@contentful/optimization-react-web core providers', () => {
expect(OptimizationRoot).toBeTypeOf('function')
expect(useEntryResolver).toBeTypeOf('function')
expect(useOptimization).toBeTypeOf('function')
+ expect(useOptimizationActions).toBeTypeOf('function')
expect(useOptimizationContext).toBeTypeOf('function')
expect(useOptimizedEntry).toBeTypeOf('function')
expect(useLiveUpdates).toBeTypeOf('function')
@@ -339,6 +350,82 @@ describe('@contentful/optimization-react-web core providers', () => {
])
})
+ it('exposes bound SDK action hooks that are safe to destructure', async () => {
+ const consent = rs.fn(() => undefined)
+ const identify: OptimizationSdk['identify'] = rs.fn(async () => {
+ await Promise.resolve()
+ return undefined
+ })
+ const page: OptimizationSdk['page'] = rs.fn(async () => {
+ await Promise.resolve()
+ return undefined
+ })
+ const reset = rs.fn(() => undefined)
+ const setLocale = rs.fn(() => undefined)
+ const track: OptimizationSdk['track'] = rs.fn(async () => {
+ await Promise.resolve()
+ return undefined
+ })
+ const trackClick: OptimizationSdk['trackClick'] = rs.fn(async () => {
+ await Promise.resolve()
+ return undefined
+ })
+ const trackView: OptimizationSdk['trackView'] = rs.fn(async () => {
+ await Promise.resolve()
+ return undefined
+ })
+ const captures: UseOptimizationActionsResult[] = []
+
+ function Probe(): null {
+ captures.push(useOptimizationActions())
+ return null
+ }
+
+ const sdk = createOptimizationSdk({
+ consent,
+ identify,
+ page,
+ reset,
+ setLocale,
+ track,
+ trackClick,
+ trackView,
+ })
+
+ const rendered = renderClient(
+
+
+ ,
+ )
+
+ rendered.rerender(
+
+
+ ,
+ )
+
+ const [firstRender, secondRender] = captures
+
+ expect(secondRender).toBeDefined()
+ if (!firstRender || !secondRender) {
+ throw new Error('Expected action-hook captures across renders')
+ }
+
+ expect(secondRender).toBe(firstRender)
+
+ firstRender.consent(true)
+ await firstRender.identify({ userId: 'user-1' })
+ await firstRender.page({ properties: { title: 'Home' } })
+ await firstRender.track({ event: 'purchase', properties: { revenue: 99 } })
+
+ expect(consent).toHaveBeenCalledWith(true)
+ expect(identify).toHaveBeenCalledWith({ userId: 'user-1' })
+ expect(page).toHaveBeenCalledWith({ properties: { title: 'Home' } })
+ expect(track).toHaveBeenCalledWith({ event: 'purchase', properties: { revenue: 99 } })
+
+ rendered.unmount()
+ })
+
it('defaults liveUpdates to false in OptimizationRoot', () => {
let capturedGlobalLiveUpdates = false
diff --git a/packages/web/frameworks/react-web-sdk/src/index.ts b/packages/web/frameworks/react-web-sdk/src/index.ts
index 30c70ed4..65689262 100644
--- a/packages/web/frameworks/react-web-sdk/src/index.ts
+++ b/packages/web/frameworks/react-web-sdk/src/index.ts
@@ -16,6 +16,8 @@ export { useLiveUpdates } from './hooks/useLiveUpdates'
export { useMergeTagResolver } from './hooks/useMergeTagResolver'
export type { UseMergeTagResolverResult } from './hooks/useMergeTagResolver'
export { useOptimization, useOptimizationContext } from './hooks/useOptimization'
+export { useOptimizationActions } from './hooks/useOptimizationActions'
+export type { UseOptimizationActionsResult } from './hooks/useOptimizationActions'
export { OptimizedEntry } from './optimized-entry/OptimizedEntry'
export type {
OptimizedEntryLoadingFallback,
diff --git a/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx b/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx
index 774d7592..adab3ed3 100644
--- a/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx
+++ b/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx
@@ -51,6 +51,49 @@ export function createObservable(current: T): ObservableLike {
}
}
+export function createMutableCloningObservable(initial: T): {
+ emit: (value: T) => Promise
+ observable: ObservableLike
+} {
+ const subscribers = new Set>()
+ let current = structuredClone(initial)
+
+ const observable: ObservableLike = {
+ get current() {
+ return structuredClone(current)
+ },
+ subscribe(next: RuntimeSubscriber) {
+ subscribers.add(next)
+ next(structuredClone(current))
+
+ return {
+ unsubscribe() {
+ subscribers.delete(next)
+ },
+ }
+ },
+ subscribeOnce(next: (value: NonNullable) => void) {
+ if (current !== undefined && current !== null) {
+ next(structuredClone(current) as NonNullable)
+ }
+ return { unsubscribe: () => undefined }
+ },
+ }
+
+ async function emit(value: T): Promise {
+ current = structuredClone(value)
+
+ await act(async () => {
+ await Promise.resolve()
+ subscribers.forEach((subscriber) => {
+ subscriber(structuredClone(current))
+ })
+ })
+ }
+
+ return { emit, observable }
+}
+
export function createTestEntry(id: string): TestEntry {
return {
fields: { title: id },