Skip to content

Commit d2b8c8a

Browse files
[FSSDK-12296] common code abstraction
1 parent b54e1e2 commit d2b8c8a

4 files changed

Lines changed: 132 additions & 171 deletions

File tree

src/hooks/useAsyncDecision.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* Copyright 2026, Optimizely
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { useEffect, useState } from 'react';
18+
import type { OptimizelyUserContext } from '@optimizely/optimizely-sdk';
19+
20+
import type { Client } from '@optimizely/optimizely-sdk';
21+
import type { ProviderState } from '../provider/index';
22+
23+
interface AsyncState<TResult> {
24+
result: TResult;
25+
error: Error | null;
26+
isLoading: boolean;
27+
}
28+
29+
/**
30+
* Shared async decision state machine used by useDecideAsync,
31+
* useDecideForKeysAsync, and useDecideAllAsync.
32+
*
33+
* Handles: loading state, error propagation, cancellation of stale promises,
34+
* and redundant re-render avoidance on first mount.
35+
*
36+
* @param state - Provider state from useProviderState
37+
* @param client - Optimizely client instance
38+
* @param fdVersion - Forced decision version counter (triggers re-evaluation)
39+
* @param emptyResult - Default/empty result value (null for single, {} for multi)
40+
* @param execute - Callback that performs the async SDK call
41+
*/
42+
export function useAsyncDecision<TResult>(
43+
state: ProviderState,
44+
client: Client,
45+
fdVersion: number,
46+
emptyResult: TResult,
47+
execute: (userContext: OptimizelyUserContext) => Promise<TResult>
48+
): AsyncState<TResult> {
49+
const [asyncState, setAsyncState] = useState<AsyncState<TResult>>({
50+
result: emptyResult,
51+
error: null,
52+
isLoading: true,
53+
});
54+
55+
useEffect(() => {
56+
const { userContext, error } = state;
57+
const hasConfig = client.getOptimizelyConfig() !== null;
58+
59+
// Store-level error — no async call needed
60+
if (error) {
61+
setAsyncState({ result: emptyResult, error, isLoading: false });
62+
return;
63+
}
64+
65+
// Store not ready — stay in loading
66+
if (!hasConfig || userContext === null) {
67+
setAsyncState({ result: emptyResult, error: null, isLoading: true });
68+
return;
69+
}
70+
71+
// Store is ready — fire async decision
72+
let cancelled = false;
73+
// Reset to loading before firing the async call.
74+
// If already in the initial loading state, returns `prev` as-is to
75+
// skip a redundant re-render on first mount.
76+
setAsyncState((prev) => {
77+
if (prev.isLoading && prev.error === null && prev.result === emptyResult) return prev;
78+
return { result: emptyResult, error: null, isLoading: true };
79+
});
80+
81+
execute(userContext).then(
82+
(result) => {
83+
if (!cancelled) {
84+
setAsyncState({ result, error: null, isLoading: false });
85+
}
86+
},
87+
(err) => {
88+
if (!cancelled) {
89+
setAsyncState({
90+
result: emptyResult,
91+
error: err instanceof Error ? err : new Error(String(err)),
92+
isLoading: false,
93+
});
94+
}
95+
}
96+
);
97+
98+
return () => {
99+
cancelled = true;
100+
};
101+
}, [state, fdVersion, client, execute, emptyResult]);
102+
103+
return asyncState;
104+
}

src/hooks/useDecideAllAsync.ts

Lines changed: 8 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,18 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { useEffect, useState } from 'react';
18-
import type { OptimizelyDecision } from '@optimizely/optimizely-sdk';
17+
import { useCallback, useEffect, useState } from 'react';
18+
import type { OptimizelyUserContext } from '@optimizely/optimizely-sdk';
1919

2020
import { useOptimizelyContext } from './useOptimizelyContext';
2121
import { useProviderState } from './useProviderState';
2222
import { useStableArray } from './useStableArray';
23+
import { useAsyncDecision } from './useAsyncDecision';
2324
import type { UseDecideConfig } from './useDecide';
2425
import type { UseDecideMultiAsyncResult } from './useDecideForKeysAsync';
2526

27+
const EMPTY_DECISIONS = {} as Record<string, never>;
28+
2629
/**
2730
* Returns feature flag decisions for all flags using the async
2831
* `decideAllAsync` API. Required for CMAB (Contextual Multi-Armed Bandit) support.
@@ -43,61 +46,9 @@ export function useDecideAllAsync(config?: UseDecideConfig): UseDecideMultiAsync
4346
return store.subscribeAllForcedDecisions(() => setFdVersion((v) => v + 1));
4447
}, [store]);
4548

46-
// --- Async decision state ---
47-
const [asyncState, setAsyncState] = useState<{
48-
decisions: Record<string, OptimizelyDecision> | Record<string, never>;
49-
error: Error | null;
50-
isLoading: boolean;
51-
}>({ decisions: {} as Record<string, never>, error: null, isLoading: true });
52-
53-
// --- Async decision effect ---
54-
useEffect(() => {
55-
const { userContext, error } = state;
56-
const hasConfig = client.getOptimizelyConfig() !== null;
57-
58-
// Store-level error — no async call needed
59-
if (error) {
60-
setAsyncState({ decisions: {} as Record<string, never>, error, isLoading: false });
61-
return;
62-
}
63-
64-
// Store not ready — stay in loading
65-
if (!hasConfig || userContext === null) {
66-
setAsyncState({ decisions: {} as Record<string, never>, error: null, isLoading: true });
67-
return;
68-
}
69-
70-
// Store is ready — fire async decision
71-
let cancelled = false;
72-
// Reset to loading before firing the async call.
73-
// If already in the initial loading state, returns `prev` as-is to
74-
// skip a redundant re-render on first mount.
75-
setAsyncState((prev) => {
76-
if (prev.isLoading && prev.error === null && Object.keys(prev.decisions).length === 0) return prev;
77-
return { decisions: {} as Record<string, never>, error: null, isLoading: true };
78-
});
79-
80-
userContext.decideAllAsync(decideOptions).then(
81-
(decisions) => {
82-
if (!cancelled) {
83-
setAsyncState({ decisions, error: null, isLoading: false });
84-
}
85-
},
86-
(err) => {
87-
if (!cancelled) {
88-
setAsyncState({
89-
decisions: {} as Record<string, never>,
90-
error: err instanceof Error ? err : new Error(String(err)),
91-
isLoading: false,
92-
});
93-
}
94-
}
95-
);
49+
const execute = useCallback((uc: OptimizelyUserContext) => uc.decideAllAsync(decideOptions), [decideOptions]);
9650

97-
return () => {
98-
cancelled = true;
99-
};
100-
}, [state, fdVersion, client, decideOptions]);
51+
const { result, error, isLoading } = useAsyncDecision(state, client, fdVersion, EMPTY_DECISIONS, execute);
10152

102-
return asyncState as UseDecideMultiAsyncResult;
53+
return { decisions: result, error, isLoading } as UseDecideMultiAsyncResult;
10354
}

src/hooks/useDecideAsync.ts

Lines changed: 9 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { useEffect, useState } from 'react';
18-
import type { OptimizelyDecision } from '@optimizely/optimizely-sdk';
17+
import { useCallback, useEffect, useState } from 'react';
18+
import type { OptimizelyDecision, OptimizelyUserContext } from '@optimizely/optimizely-sdk';
1919

2020
import { useOptimizelyContext } from './useOptimizelyContext';
2121
import { useProviderState } from './useProviderState';
2222
import { useStableArray } from './useStableArray';
23+
import { useAsyncDecision } from './useAsyncDecision';
2324
import type { UseDecideConfig } from './useDecide';
2425

2526
export type UseDecideAsyncResult =
@@ -50,61 +51,12 @@ export function useDecideAsync(flagKey: string, config?: UseDecideConfig): UseDe
5051
});
5152
}, [store, flagKey]);
5253

53-
// --- Async decision state ---
54-
const [asyncState, setAsyncState] = useState<{
55-
decision: OptimizelyDecision | null;
56-
error: Error | null;
57-
isLoading: boolean;
58-
}>({ decision: null, error: null, isLoading: true });
54+
const execute = useCallback(
55+
(uc: OptimizelyUserContext) => uc.decideAsync(flagKey, decideOptions),
56+
[flagKey, decideOptions]
57+
);
5958

60-
// --- Async decision effect ---
61-
useEffect(() => {
62-
const { userContext, error } = state;
63-
const hasConfig = client.getOptimizelyConfig() !== null;
64-
65-
// Store-level error — no async call needed
66-
if (error) {
67-
setAsyncState({ decision: null, error, isLoading: false });
68-
return;
69-
}
70-
71-
// Store not ready — stay in loading
72-
if (!hasConfig || userContext === null) {
73-
setAsyncState({ decision: null, error: null, isLoading: true });
74-
return;
75-
}
76-
77-
// Store is ready — fire async decision
78-
let cancelled = false;
79-
// Reset to loading before firing the async call.
80-
// If already in the initial loading state, returns `prev` as-is to
81-
// skip a redundant re-render on first mount.
82-
setAsyncState((prev) => {
83-
if (prev.isLoading && prev.error === null && prev.decision === null) return prev;
84-
return { decision: null, error: null, isLoading: true };
85-
});
86-
87-
userContext.decideAsync(flagKey, decideOptions).then(
88-
(decision) => {
89-
if (!cancelled) {
90-
setAsyncState({ decision, error: null, isLoading: false });
91-
}
92-
},
93-
(err) => {
94-
if (!cancelled) {
95-
setAsyncState({
96-
decision: null,
97-
error: err instanceof Error ? err : new Error(String(err)),
98-
isLoading: false,
99-
});
100-
}
101-
}
102-
);
103-
104-
return () => {
105-
cancelled = true;
106-
};
107-
}, [state, fdVersion, client, flagKey, decideOptions]);
59+
const { result, error, isLoading } = useAsyncDecision(state, client, fdVersion, null, execute);
10860

109-
return asyncState as UseDecideAsyncResult;
61+
return { decision: result, error, isLoading } as UseDecideAsyncResult;
11062
}

src/hooks/useDecideForKeysAsync.ts

Lines changed: 11 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,22 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { useEffect, useState } from 'react';
18-
import type { OptimizelyDecision } from '@optimizely/optimizely-sdk';
17+
import { useCallback, useEffect, useState } from 'react';
18+
import type { OptimizelyDecision, OptimizelyUserContext } from '@optimizely/optimizely-sdk';
1919

2020
import { useOptimizelyContext } from './useOptimizelyContext';
2121
import { useProviderState } from './useProviderState';
2222
import { useStableArray } from './useStableArray';
23+
import { useAsyncDecision } from './useAsyncDecision';
2324
import type { UseDecideConfig } from './useDecide';
2425

2526
export type UseDecideMultiAsyncResult =
2627
| { isLoading: true; error: null; decisions: Record<string, never> }
2728
| { isLoading: false; error: Error; decisions: Record<string, never> }
2829
| { isLoading: false; error: null; decisions: Record<string, OptimizelyDecision> };
2930

31+
const EMPTY_DECISIONS = {} as Record<string, never>;
32+
3033
/**
3134
* Returns feature flag decisions for the given flag keys using the async
3235
* `decideForKeysAsync` API. Required for CMAB (Contextual Multi-Armed Bandit) support.
@@ -50,61 +53,12 @@ export function useDecideForKeysAsync(flagKeys: string[], config?: UseDecideConf
5053
return () => unsubscribes.forEach((unsub) => unsub());
5154
}, [store, stableKeys]);
5255

53-
// --- Async decision state ---
54-
const [asyncState, setAsyncState] = useState<{
55-
decisions: Record<string, OptimizelyDecision> | Record<string, never>;
56-
error: Error | null;
57-
isLoading: boolean;
58-
}>({ decisions: {} as Record<string, never>, error: null, isLoading: true });
59-
60-
// --- Async decision effect ---
61-
useEffect(() => {
62-
const { userContext, error } = state;
63-
const hasConfig = client.getOptimizelyConfig() !== null;
64-
65-
// Store-level error — no async call needed
66-
if (error) {
67-
setAsyncState({ decisions: {} as Record<string, never>, error, isLoading: false });
68-
return;
69-
}
70-
71-
// Store not ready — stay in loading
72-
if (!hasConfig || userContext === null) {
73-
setAsyncState({ decisions: {} as Record<string, never>, error: null, isLoading: true });
74-
return;
75-
}
76-
77-
// Store is ready — fire async decision
78-
let cancelled = false;
79-
// Reset to loading before firing the async call.
80-
// If already in the initial loading state, returns `prev` as-is to
81-
// skip a redundant re-render on first mount.
82-
setAsyncState((prev) => {
83-
if (prev.isLoading && prev.error === null && Object.keys(prev.decisions).length === 0) return prev;
84-
return { decisions: {} as Record<string, never>, error: null, isLoading: true };
85-
});
86-
87-
userContext.decideForKeysAsync(stableKeys, decideOptions).then(
88-
(decisions) => {
89-
if (!cancelled) {
90-
setAsyncState({ decisions, error: null, isLoading: false });
91-
}
92-
},
93-
(err) => {
94-
if (!cancelled) {
95-
setAsyncState({
96-
decisions: {} as Record<string, never>,
97-
error: err instanceof Error ? err : new Error(String(err)),
98-
isLoading: false,
99-
});
100-
}
101-
}
102-
);
56+
const execute = useCallback(
57+
(uc: OptimizelyUserContext) => uc.decideForKeysAsync(stableKeys, decideOptions),
58+
[stableKeys, decideOptions]
59+
);
10360

104-
return () => {
105-
cancelled = true;
106-
};
107-
}, [state, fdVersion, client, stableKeys, decideOptions]);
61+
const { result, error, isLoading } = useAsyncDecision(state, client, fdVersion, EMPTY_DECISIONS, execute);
10862

109-
return asyncState as UseDecideMultiAsyncResult;
63+
return { decisions: result, error, isLoading } as UseDecideMultiAsyncResult;
11064
}

0 commit comments

Comments
 (0)