Skip to content

Commit 5c17b8d

Browse files
toddbaertjuanparadoxmoredip
authored
feat: query-style, generic useFlag hook (#897)
This PR adds a new `useFlag` hook. This hook: - returns an object supporting a react "query-style" API with relevant properties: `const { value: newMessage, isError, reason, errorCode, isAuthoritative, type } = useFlag('new-message', true);` - supports ergonomic generics: ``` const { value: boolVal }: FlagQuery<boolean> = useFlag('bool-flag', true); const { value: stringVal }: FlagQuery<string> = useFlag('string-flag', 'string'); const { value: objVal }: FlagQuery<{greeting: string}> = useFlag('obj-flag', { greeting: 'hi' }); ``` - supports all the same options and features as other hooks (suspense, etc) This PR fixes some type bugs as well, and adds to the readme. DEPENDS ON: #898 --------- Signed-off-by: Todd Baert <[email protected]> Co-authored-by: Juan Bernal <[email protected]> Co-authored-by: Pete Hodgson <[email protected]>
1 parent 9ec4399 commit 5c17b8d

File tree

7 files changed

+200
-14
lines changed

7 files changed

+200
-14
lines changed

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/react/README.md

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,12 @@ The following list contains the peer dependencies of `@openfeature/react-sdk` wi
8787

8888
### Usage
8989

90+
The `OpenFeatureProvider` represents a scope for feature flag evaluations within a React application.
91+
It binds an OpenFeature client to all evaluations within child components, and allows the use of evaluation hooks.
9092
The example below shows how to use the `OpenFeatureProvider` with OpenFeature's `InMemoryProvider`.
9193

9294
```tsx
93-
import { EvaluationContext, OpenFeatureProvider, useBooleanFlagValue, useBooleanFlagDetails, OpenFeature, InMemoryProvider } from '@openfeature/react-sdk';
95+
import { EvaluationContext, OpenFeatureProvider, useFlag, OpenFeature, InMemoryProvider } from '@openfeature/react-sdk';
9496

9597
const flagConfig = {
9698
'new-message': {
@@ -109,8 +111,11 @@ const flagConfig = {
109111
},
110112
};
111113

114+
// Instantiate and set our provider (be sure this only happens once)!
115+
// Note: there's no need to await its initialization, the React SDK handles re-rendering and suspense for you!
112116
OpenFeature.setProvider(new InMemoryProvider(flagConfig));
113117

118+
// Enclose your content in the configured provider
114119
function App() {
115120
return (
116121
<OpenFeatureProvider>
@@ -120,24 +125,32 @@ function App() {
120125
}
121126

122127
function Page() {
123-
const newMessage = useBooleanFlagValue('new-message', false);
128+
// Use the "query-style" flag evaluation hook.
129+
const { value: showNewMessage } = useFlag('new-message', true);
124130
return (
125131
<div className="App">
126132
<header className="App-header">
127-
{newMessage ? <p>Welcome to this OpenFeature-enabled React app!</p> : <p>Welcome to this React app.</p>}
133+
{showNewMessage ? <p>Welcome to this OpenFeature-enabled React app!</p> : <p>Welcome to this React app.</p>}
128134
</header>
129135
</div>
130136
)
131137
}
132138

133139
export default App;
134140
```
141+
You can use the strongly-typed flag value and flag evaluation detail hooks as well, if you prefer.
135142

136-
You use the detailed flag evaluation hooks to evaluate the flag and get additional information about the flag and the evaluation.
143+
```tsx
144+
import { useBooleanFlagValue } from '@openfeature/react-sdk';
145+
146+
// boolean flag evaluation
147+
const value = useBooleanFlagValue('new-message', false);
148+
```
137149

138150
```tsx
139151
import { useBooleanFlagDetails } from '@openfeature/react-sdk';
140152

153+
// "detailed" boolean flag evaluation
141154
const {
142155
value,
143156
variant,
@@ -178,7 +191,7 @@ You can disable this feature in the hook options:
178191

179192
```tsx
180193
function Page() {
181-
const newMessage = useBooleanFlagValue('new-message', false, { updateOnContextChanged: false });
194+
const showNewMessage = useBooleanFlagValue('new-message', false, { updateOnContextChanged: false });
182195
return (
183196
<MyComponents></MyComponents>
184197
)
@@ -195,7 +208,7 @@ You can disable this feature in the hook options:
195208

196209
```tsx
197210
function Page() {
198-
const newMessage = useBooleanFlagValue('new-message', false, { updateOnConfigurationChanged: false });
211+
const showNewMessage = useBooleanFlagValue('new-message', false, { updateOnConfigurationChanged: false });
199212
return (
200213
<MyComponents></MyComponents>
201214
)
@@ -222,11 +235,11 @@ function Content() {
222235

223236
function Message() {
224237
// component to render after READY.
225-
const newMessage = useBooleanFlagValue('new-message', false);
238+
const showNewMessage = useBooleanFlagValue('new-message', false);
226239

227240
return (
228241
<>
229-
{newMessage ? (
242+
{showNewMessage ? (
230243
<p>Welcome to this OpenFeature-enabled React app!</p>
231244
) : (
232245
<p>Welcome to this plain old React app!</p>

packages/react/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
},
4747
"homepage": "https://github.com/open-feature/js-sdk#readme",
4848
"peerDependencies": {
49-
"@openfeature/web-sdk": ">=1.0.0",
49+
"@openfeature/web-sdk": "^1.0.2",
5050
"react": ">=16.8.0"
5151
},
5252
"devDependencies": {

packages/react/src/evaluation/use-feature-flag.ts

Lines changed: 117 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ import {
66
JsonValue,
77
ProviderEvents,
88
ProviderStatus,
9+
StandardResolutionReasons,
910
} from '@openfeature/web-sdk';
1011
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
1112
import { useOpenFeatureClient } from '../provider';
13+
import { FlagQuery } from '../query';
1214

1315
type ReactFlagEvaluationOptions = {
1416
/**
@@ -52,9 +54,64 @@ enum SuspendState {
5254
Error,
5355
}
5456

57+
// This type is a bit wild-looking, but I think we need it.
58+
// We have to use the conditional, because otherwise useFlag('key', false) would return false, not boolean (too constrained).
59+
// We have a duplicate for the hook return below, this one is just used for casting because the name isn't as clear
60+
type ConstrainedFlagQuery<T> = FlagQuery<
61+
T extends boolean
62+
? boolean
63+
: T extends number
64+
? number
65+
: T extends string
66+
? string
67+
: T extends JsonValue
68+
? T
69+
: JsonValue
70+
>;
71+
72+
/**
73+
* Evaluates a feature flag generically, returning an react-flavored queryable object.
74+
* The resolver method to use is based on the type of the defaultValue.
75+
* For type-specific hooks, use {@link useBooleanFlagValue}, {@link useBooleanFlagDetails} and equivalents.
76+
* By default, components will re-render when the flag value changes.
77+
* @param {string} flagKey the flag identifier
78+
* @template {FlagValue} T A optional generic argument constraining the default.
79+
* @param {T} defaultValue the default value; used to determine what resolved type should be used.
80+
* @param {ReactFlagEvaluationOptions} options for this evaluation
81+
* @returns { FlagQuery } a queryable object containing useful information about the flag.
82+
*/
83+
export function useFlag<T extends FlagValue = FlagValue>(
84+
flagKey: string,
85+
defaultValue: T,
86+
options?: ReactFlagEvaluationOptions,
87+
): FlagQuery<
88+
T extends boolean
89+
? boolean
90+
: T extends number
91+
? number
92+
: T extends string
93+
? string
94+
: T extends JsonValue
95+
? T
96+
: JsonValue
97+
> {
98+
// use the default value to determine the resolver to call
99+
const query =
100+
typeof defaultValue === 'boolean'
101+
? new HookFlagQuery<boolean>(useBooleanFlagDetails(flagKey, defaultValue, options))
102+
: typeof defaultValue === 'number'
103+
? new HookFlagQuery<number>(useNumberFlagDetails(flagKey, defaultValue, options))
104+
: typeof defaultValue === 'string'
105+
? new HookFlagQuery<string>(useStringFlagDetails(flagKey, defaultValue, options))
106+
: new HookFlagQuery<JsonValue>(useObjectFlagDetails(flagKey, defaultValue, options));
107+
// TS sees this as HookFlagQuery<JsonValue>, because the compiler isn't aware of the `typeof` checks above.
108+
return query as unknown as ConstrainedFlagQuery<T>;
109+
}
110+
55111
/**
56112
* Evaluates a feature flag, returning a boolean.
57113
* By default, components will re-render when the flag value changes.
114+
* For a generic hook returning a queryable interface, see {@link useFlag}.
58115
* @param {string} flagKey the flag identifier
59116
* @param {boolean} defaultValue the default value
60117
* @param {ReactFlagEvaluationOptions} options options for this evaluation
@@ -71,6 +128,7 @@ export function useBooleanFlagValue(
71128
/**
72129
* Evaluates a feature flag, returning evaluation details.
73130
* By default, components will re-render when the flag value changes.
131+
* For a generic hook returning a queryable interface, see {@link useFlag}.
74132
* @param {string} flagKey the flag identifier
75133
* @param {boolean} defaultValue the default value
76134
* @param {ReactFlagEvaluationOptions} options options for this evaluation
@@ -94,6 +152,7 @@ export function useBooleanFlagDetails(
94152
/**
95153
* Evaluates a feature flag, returning a string.
96154
* By default, components will re-render when the flag value changes.
155+
* For a generic hook returning a queryable interface, see {@link useFlag}.
97156
* @param {string} flagKey the flag identifier
98157
* @template {string} [T=string] A optional generic argument constraining the string
99158
* @param {T} defaultValue the default value
@@ -104,13 +163,14 @@ export function useStringFlagValue<T extends string = string>(
104163
flagKey: string,
105164
defaultValue: T,
106165
options?: ReactFlagEvaluationOptions,
107-
): T {
166+
): string {
108167
return useStringFlagDetails(flagKey, defaultValue, options).value;
109168
}
110169

111170
/**
112171
* Evaluates a feature flag, returning evaluation details.
113172
* By default, components will re-render when the flag value changes.
173+
* For a generic hook returning a queryable interface, see {@link useFlag}.
114174
* @param {string} flagKey the flag identifier
115175
* @template {string} [T=string] A optional generic argument constraining the string
116176
* @param {T} defaultValue the default value
@@ -121,7 +181,7 @@ export function useStringFlagDetails<T extends string = string>(
121181
flagKey: string,
122182
defaultValue: T,
123183
options?: ReactFlagEvaluationOptions,
124-
): EvaluationDetails<T> {
184+
): EvaluationDetails<string> {
125185
return attachHandlersAndResolve(
126186
flagKey,
127187
defaultValue,
@@ -135,6 +195,7 @@ export function useStringFlagDetails<T extends string = string>(
135195
/**
136196
* Evaluates a feature flag, returning a number.
137197
* By default, components will re-render when the flag value changes.
198+
* For a generic hook returning a queryable interface, see {@link useFlag}.
138199
* @param {string} flagKey the flag identifier
139200
* @template {number} [T=number] A optional generic argument constraining the number
140201
* @param {T} defaultValue the default value
@@ -145,13 +206,14 @@ export function useNumberFlagValue<T extends number = number>(
145206
flagKey: string,
146207
defaultValue: T,
147208
options?: ReactFlagEvaluationOptions,
148-
): T {
209+
): number {
149210
return useNumberFlagDetails(flagKey, defaultValue, options).value;
150211
}
151212

152213
/**
153214
* Evaluates a feature flag, returning evaluation details.
154215
* By default, components will re-render when the flag value changes.
216+
* For a generic hook returning a queryable interface, see {@link useFlag}.
155217
* @param {string} flagKey the flag identifier
156218
* @template {number} [T=number] A optional generic argument constraining the number
157219
* @param {T} defaultValue the default value
@@ -162,7 +224,7 @@ export function useNumberFlagDetails<T extends number = number>(
162224
flagKey: string,
163225
defaultValue: T,
164226
options?: ReactFlagEvaluationOptions,
165-
): EvaluationDetails<T> {
227+
): EvaluationDetails<number> {
166228
return attachHandlersAndResolve(
167229
flagKey,
168230
defaultValue,
@@ -176,6 +238,7 @@ export function useNumberFlagDetails<T extends number = number>(
176238
/**
177239
* Evaluates a feature flag, returning an object.
178240
* By default, components will re-render when the flag value changes.
241+
* For a generic hook returning a queryable interface, see {@link useFlag}.
179242
* @param {string} flagKey the flag identifier
180243
* @template {JsonValue} [T=JsonValue] A optional generic argument describing the structure
181244
* @param {T} defaultValue the default value
@@ -193,6 +256,7 @@ export function useObjectFlagValue<T extends JsonValue = JsonValue>(
193256
/**
194257
* Evaluates a feature flag, returning evaluation details.
195258
* By default, components will re-render when the flag value changes.
259+
* For a generic hook returning a queryable interface, see {@link useFlag}.
196260
* @param {string} flagKey the flag identifier
197261
* @param {T} defaultValue the default value
198262
* @template {JsonValue} [T=JsonValue] A optional generic argument describing the structure
@@ -336,3 +400,52 @@ function suspenseWrapper<T>(promise: Promise<T>) {
336400
}
337401
};
338402
}
403+
404+
// FlagQuery implementation, do not export
405+
class HookFlagQuery<T extends FlagValue = FlagValue> implements FlagQuery {
406+
constructor(private _details: EvaluationDetails<T>) {}
407+
408+
get details() {
409+
return this._details;
410+
}
411+
412+
get value() {
413+
return this._details?.value;
414+
}
415+
416+
get variant() {
417+
return this._details.variant;
418+
}
419+
420+
get flagMetadata() {
421+
return this._details.flagMetadata;
422+
}
423+
424+
get reason() {
425+
return this._details.reason;
426+
}
427+
428+
get isError() {
429+
return !!this._details?.errorCode || this._details.reason == StandardResolutionReasons.ERROR;
430+
}
431+
432+
get errorCode() {
433+
return this._details?.errorCode;
434+
}
435+
436+
get errorMessage() {
437+
return this._details?.errorMessage;
438+
}
439+
440+
get isAuthoritative() {
441+
return (
442+
!this.isError &&
443+
this._details.reason != StandardResolutionReasons.STALE &&
444+
this._details.reason != StandardResolutionReasons.DISABLED
445+
);
446+
}
447+
448+
get type() {
449+
return typeof this._details.value;
450+
}
451+
}

packages/react/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './evaluation';
2+
export * from './query';
23
export * from './provider';
34
// re-export the web-sdk so consumers can access that API from the react-sdk
45
export * from '@openfeature/web-sdk';

packages/react/src/query/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './query';

0 commit comments

Comments
 (0)