Skip to content

Commit e7a5fb5

Browse files
weyertlukas-reiningmarcozabeltoddbaertbeeme1mr
authored
feat(react): add FeatureFlag component (#1164)
## This PR Introduces the FeatureFlag component for React that allow using feature flags in a declarative manner ### Related Issues No ticket ### Notes Maybe consider adding a similar component for other supported frameworks? ### Follow-up Tasks <!-- anything that is related to this PR but not done here should be noted under this section --> <!-- if there is a need for a new issue, please link it here --> ### How to test I have written unit tests to cover the main features of the component --------- Signed-off-by: Todd Baert <[email protected]> Co-authored-by: Lukas Reining <[email protected]> Co-authored-by: marcozabel <[email protected]> Co-authored-by: Todd Baert <[email protected]> Co-authored-by: Michael Beemer <[email protected]>
1 parent e3c9d79 commit e7a5fb5

File tree

6 files changed

+440
-2
lines changed

6 files changed

+440
-2
lines changed

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
dist/
22
node_modules/
3+
test-harness/
34
package-lock.json
45
CHANGELOG.md

packages/react/README.md

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ In addition to the feature provided by the [web sdk](https://openfeature.dev/doc
5050
- [Usage](#usage)
5151
- [OpenFeatureProvider context provider](#openfeatureprovider-context-provider)
5252
- [Evaluation hooks](#evaluation-hooks)
53+
- [Declarative components](#declarative-components)
54+
- [FeatureFlag Component](#featureflag-component)
5355
- [Multiple Providers and Domains](#multiple-providers-and-domains)
5456
- [Re-rendering with Context Changes](#re-rendering-with-context-changes)
5557
- [Re-rendering with Flag Configuration Changes](#re-rendering-with-flag-configuration-changes)
@@ -166,6 +168,68 @@ import { useBooleanFlagDetails } from '@openfeature/react-sdk';
166168
const { value, variant, reason, flagMetadata } = useBooleanFlagDetails('new-message', false);
167169
```
168170

171+
#### Declarative components
172+
173+
The React SDK includes declarative components for feature flagging that provide a more JSX-native approach to conditional rendering.
174+
175+
##### FeatureFlag Component
176+
177+
The `FeatureFlag` component conditionally renders its children based on feature flag evaluation:
178+
179+
```tsx
180+
import { FeatureFlag } from '@openfeature/react-sdk';
181+
182+
function App() {
183+
return (
184+
<OpenFeatureProvider>
185+
{/* Basic usage - renders children when flag is truthy */}
186+
<FeatureFlag flagKey="new-feature" defaultValue={false}>
187+
<NewFeatureComponent />
188+
</FeatureFlag>
189+
190+
{/* Match specific values */}
191+
<FeatureFlag flagKey="theme" matchValue="dark" defaultValue="light">
192+
<DarkThemeStyles />
193+
</FeatureFlag>
194+
195+
{/* Boolean flag with fallback */}
196+
<FeatureFlag flagKey="premium-feature" matchValue={true} defaultValue={false} fallback={<UpgradePrompt />}>
197+
<PremiumContent />
198+
</FeatureFlag>
199+
200+
{/* Custom predicate function for complex matching */}
201+
<FeatureFlag
202+
flagKey="user-segment"
203+
defaultValue=""
204+
matchValue="beta"
205+
// check if the actual flag value includes the match ('beta')
206+
predicate={(expected, actual) => !!expected && actual.value.includes(expected)}
207+
>
208+
<BetaFeatures />
209+
</FeatureFlag>
210+
211+
{/* Function as children for accessing flag details */}
212+
<FeatureFlag flagKey="experiment" defaultValue="control" matchValue="beta">
213+
{({ value, reason }) => (
214+
<span>
215+
value is {value}, reason is {reason?.toString()}
216+
</span>
217+
)}
218+
</FeatureFlag>
219+
</OpenFeatureProvider>
220+
);
221+
}
222+
```
223+
224+
The `FeatureFlag` component supports the following props:
225+
226+
- **`flagKey`** (required): The feature flag key to evaluate
227+
- **`defaultValue`** (required): Default value when the flag is not available
228+
- **`matchValue`** (required, except for boolean flags): Value to match against the flag value. By default, an optimized deep-comparison function is used.
229+
- **`predicate`** (optional): Custom function for matching logic that receives the expected value and evaluation details
230+
- **`children`**: Content to render when condition is met (can be JSX or a function receiving flag details)
231+
- **`fallback`** (optional): Content to render when condition is not met
232+
169233
#### Multiple Providers and Domains
170234

171235
Multiple providers can be used by passing a `domain` to the `OpenFeatureProvider`:
@@ -308,8 +372,8 @@ The [OpenFeature debounce hook](https://github.com/open-feature/js-sdk-contrib/t
308372
### Testing
309373

310374
The React SDK includes a built-in context provider for testing.
311-
This allows you to easily test components that use evaluation hooks, such as `useFlag`.
312-
If you try to test a component (in this case, `MyComponent`) which uses an evaluation hook, you might see an error message like:
375+
This allows you to easily test components that use evaluation hooks (such as `useFlag`) or declarative components (such as `FeatureFlag`).
376+
If you try to test a component (in this case, `MyComponent`) which uses feature flags, you might see an error message like:
313377

314378
> No OpenFeature client available - components using OpenFeature must be wrapped with an `<OpenFeatureProvider>`.
315379
@@ -330,6 +394,16 @@ If you'd like to control the values returned by the evaluation hooks, you can pa
330394
<OpenFeatureTestProvider flagValueMap={{ 'my-boolean-flag': true }}>
331395
<MyComponent />
332396
</OpenFeatureTestProvider>
397+
398+
// testing declarative FeatureFlag components
399+
<OpenFeatureTestProvider flagValueMap={{ 'new-feature': true, 'theme': 'dark' }}>
400+
<FeatureFlag flagKey="new-feature" defaultValue={false}>
401+
<NewFeature />
402+
</FeatureFlag>
403+
<FeatureFlag flagKey="theme" match="dark" defaultValue="light">
404+
<DarkMode />
405+
</FeatureFlag>
406+
</OpenFeatureTestProvider>
333407
```
334408

335409
Additionally, you can pass an artificial delay for the provider startup to test your suspense boundaries or loaders/spinners impacted by feature flags:
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import React from 'react';
2+
import { useFlag } from '../evaluation';
3+
import type { FlagQuery } from '../query';
4+
import type { FlagValue, EvaluationDetails } from '@openfeature/core';
5+
import { isEqual } from '../internal';
6+
7+
/**
8+
* Default predicate function that checks if the expected value equals the actual flag value.
9+
* @param {T} expected The expected value to match against
10+
* @param {EvaluationDetails<T>} actual The evaluation details containing the actual flag value
11+
* @returns {boolean} true if the values match, false otherwise
12+
*/
13+
function equals<T extends FlagValue>(expected: T, actual: EvaluationDetails<T>): boolean {
14+
return isEqual(expected, actual.value);
15+
}
16+
17+
/**
18+
* Props for the FeatureFlag component that conditionally renders content based on feature flag state.
19+
* @interface FeatureFlagProps
20+
*/
21+
interface FeatureFlagProps<T extends FlagValue = FlagValue> {
22+
/**
23+
* The key of the feature flag to evaluate.
24+
*/
25+
flagKey: string;
26+
27+
/**
28+
* Optional predicate function for custom matching logic.
29+
* If provided, this function will be used instead of the default equality check.
30+
* @param matchValue The value to match (matchValue prop)
31+
* @param details The evaluation details
32+
* @returns true if the condition is met, false otherwise
33+
*/
34+
predicate?: (matchValue: T | undefined, details: EvaluationDetails<T>) => boolean;
35+
36+
/**
37+
* Content to render when the feature flag condition is met.
38+
* Can be a React node or a function that receives flag query details and returns a React node.
39+
*/
40+
children: React.ReactNode | ((details: FlagQuery<T>) => React.ReactNode);
41+
42+
/**
43+
* Optional content to render when the feature flag condition is not met.
44+
* Can be a React node or a function that receives evaluation details and returns a React node.
45+
*/
46+
fallback?: React.ReactNode | ((details: EvaluationDetails<T>) => React.ReactNode);
47+
}
48+
49+
/**
50+
* Configuration for matching flag values.
51+
* For boolean flags, `match` is optional (defaults to checking truthiness).
52+
* For non-boolean flags (string, number, object), `match` is required to determine when to render.
53+
*/
54+
type FeatureFlagMatchConfig<T extends FlagValue> = {
55+
/**
56+
* Default value to use when the feature flag is not found.
57+
*/
58+
defaultValue: T;
59+
} & (T extends boolean
60+
? {
61+
/**
62+
* Optional value to match against the feature flag value.
63+
*/
64+
matchValue?: T | undefined;
65+
}
66+
: {
67+
/**
68+
* Value to match against the feature flag value.
69+
* Required for non-boolean flags to determine when children should render.
70+
* By default, strict equality is used for comparison.
71+
*/
72+
matchValue: T;
73+
});
74+
75+
type FeatureFlagComponentProps<T extends FlagValue> = FeatureFlagProps<T> & FeatureFlagMatchConfig<T>;
76+
77+
/**
78+
* @experimental This API is experimental, and is subject to change.
79+
* FeatureFlag component that conditionally renders its children based on the evaluation of a feature flag.
80+
* @param {FeatureFlagComponentProps} props The properties for the FeatureFlag component.
81+
* @returns {React.ReactElement | null} The rendered component or null if the feature is not enabled.
82+
*/
83+
export function FeatureFlag<T extends FlagValue = FlagValue>({
84+
flagKey,
85+
matchValue,
86+
predicate,
87+
defaultValue,
88+
children,
89+
fallback = null,
90+
}: FeatureFlagComponentProps<T>): React.ReactElement | null {
91+
const details = useFlag(flagKey, defaultValue, {
92+
updateOnContextChanged: true,
93+
});
94+
95+
// If the flag evaluation failed, we render the fallback
96+
if (details.reason === 'ERROR') {
97+
const fallbackNode: React.ReactNode =
98+
typeof fallback === 'function' ? fallback(details.details as EvaluationDetails<T>) : fallback;
99+
return <>{fallbackNode}</>;
100+
}
101+
102+
// Use custom predicate if provided, otherwise use default matching logic
103+
let shouldRender = false;
104+
if (predicate) {
105+
shouldRender = predicate(matchValue as T, details.details as EvaluationDetails<T>);
106+
} else if (matchValue !== undefined) {
107+
// Default behavior: check if match value equals flag value
108+
shouldRender = equals(matchValue, details.details as EvaluationDetails<T>);
109+
} else if (details.type === 'boolean') {
110+
// If no match value is provided, render if flag is truthy
111+
shouldRender = Boolean(details.value);
112+
} else {
113+
shouldRender = false;
114+
}
115+
116+
if (shouldRender) {
117+
const childNode: React.ReactNode = typeof children === 'function' ? children(details as FlagQuery<T>) : children;
118+
return <>{childNode}</>;
119+
}
120+
121+
const fallbackNode: React.ReactNode =
122+
typeof fallback === 'function' ? fallback(details.details as EvaluationDetails<T>) : fallback;
123+
return <>{fallbackNode}</>;
124+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './FeatureFlag';

packages/react/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './declarative';
12
export * from './evaluation';
23
export * from './query';
34
export * from './provider';

0 commit comments

Comments
 (0)