From 881727698b4bcad4754dcce01089156eadd39a86 Mon Sep 17 00:00:00 2001
From: Lotfi Arif <52082662+Lotfi-Arif@users.noreply.github.com>
Date: Tue, 16 Jun 2026 21:07:59 +0200
Subject: [PATCH 1/2] feat(react-web-sdk): introduce useOptimizationActions
hook for safer SDK method access
Added the `useOptimizationActions` hook to provide destructurable access to common SDK actions like `consent`, `identify`, `page`, and `track`. This change enhances the usability of the SDK in React components by promoting safer destructuring practices. Updated relevant documentation and examples to reflect this new hook, ensuring clarity for developers integrating the SDK.
---
...rating-the-react-web-sdk-in-a-react-app.md | 20 ++--
.../web/frameworks/react-web-sdk/README.md | 29 ++++--
.../web/frameworks/react-web-sdk/package.json | 4 +-
.../src/hooks/useOptimizationActions.ts | 48 +++++++++
.../src/hooks/useOptimizationState.ts | 94 ++++++++++++++++++
.../react-web-sdk/src/index.test.tsx | 97 ++++++++++++++++++-
.../web/frameworks/react-web-sdk/src/index.ts | 2 +
.../react-web-sdk/src/test/sdkTestUtils.tsx | 43 ++++++++
8 files changed, 318 insertions(+), 19 deletions(-)
create mode 100644 packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationActions.ts
create mode 100644 packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationState.ts
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..5db44846 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,90 @@ 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 } })
+ expect(reset).toHaveBeenCalled()
+ expect(setLocale).toHaveBeenCalledWith('de-DE')
+ expect(trackClick).toHaveBeenCalledWith({ componentId: 'entry-1' })
+ expect(trackView).toHaveBeenCalledWith({
+ componentId: 'entry-1',
+ viewId: 'view-1',
+ viewDurationMs: 1000,
+ })
+
+ 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 },
From 76499f73665e2e49a17db063a4cd94df0957a63b Mon Sep 17 00:00:00 2001
From: Lotfi Arif <52082662+Lotfi-Arif@users.noreply.github.com>
Date: Tue, 16 Jun 2026 21:11:59 +0200
Subject: [PATCH 2/2] refactor(react-web-sdk): remove unused analytics event
assertions from tests
---
packages/web/frameworks/react-web-sdk/src/index.test.tsx | 8 --------
1 file changed, 8 deletions(-)
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 5db44846..de1274d0 100644
--- a/packages/web/frameworks/react-web-sdk/src/index.test.tsx
+++ b/packages/web/frameworks/react-web-sdk/src/index.test.tsx
@@ -422,14 +422,6 @@ describe('@contentful/optimization-react-web core providers', () => {
expect(identify).toHaveBeenCalledWith({ userId: 'user-1' })
expect(page).toHaveBeenCalledWith({ properties: { title: 'Home' } })
expect(track).toHaveBeenCalledWith({ event: 'purchase', properties: { revenue: 99 } })
- expect(reset).toHaveBeenCalled()
- expect(setLocale).toHaveBeenCalledWith('de-DE')
- expect(trackClick).toHaveBeenCalledWith({ componentId: 'entry-1' })
- expect(trackView).toHaveBeenCalledWith({
- componentId: 'entry-1',
- viewId: 'view-1',
- viewDurationMs: 1000,
- })
rendered.unmount()
})