Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 87 additions & 13 deletions guides/self-opt-in.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,29 +19,71 @@ For an overview of how opt-in affects access and how memberships are managed in

Render the available opt-in flags and let the current user set their opt-in status.

If you're using `<ReflagBootstrappedProvider>` without a `<Suspense>` boundary, see the loading section below.
Use `@reflag/react-sdk` 1.6.2 or later. This example explicitly enables Suspense on the hook and supplies a boundary inside your existing Reflag provider. A boundary alone does not enable Suspense in the SDK. See below for loading without Suspense.

```tsx
import { useState } from "react";
import { Suspense, useState } from "react";
import {
type OptInFlag,
useClient,
useOptInFlags,
useSetOptIn,
} from "@reflag/react-sdk";
import { Spinner } from "your-component-library";

function OptInPage() {
const { flags: optInFlags } = useOptInFlags();
return (
<Suspense fallback={<Spinner aria-label="Loading opt-in flags" />}>
<OptInList />
</Suspense>
);
}

function OptInList() {
const { flags: optInFlags } = useOptInFlags({ suspense: true });

if (optInFlags.length === 0) {
return <p>No opt-in flags are available.</p>;
return (
<section>
<p>No opt-in flags to show. If you expected some, try reloading.</p>
<ReloadOptInFlags />
</section>
);
}

return optInFlags.map((flag) => (
<OptInFlagCard key={flag.key} flag={flag} />
));
}

function ReloadOptInFlags() {
const client = useClient();
const [isReloading, setIsReloading] = useState(false);
const [reloadError, setReloadError] = useState<string | null>(null);

async function reload() {
setReloadError(null);
setIsReloading(true);
try {
const flags = await client.refresh();
if (!flags) throw new Error("Flag refresh failed");
} catch {
setReloadError("Could not reload opt-in flags. Please try again.");
} finally {
setIsReloading(false);
}
}

return (
<>
<button type="button" disabled={isReloading} onClick={reload}>
{isReloading ? "Reloading…" : "Reload opt-in flags"}
</button>
{reloadError && <p role="alert">{reloadError}</p>}
</>
);
}

function OptInFlagCard({ flag }: { flag: OptInFlag }) {
const setOptIn = useSetOptIn();
const [isUpdating, setIsUpdating] = useState(false);
Expand All @@ -57,7 +99,7 @@ function OptInFlagCard({ flag }: { flag: OptInFlag }) {
optedIn: !flag.userOptedIn,
});

if (response?.ok === false) {
if (!response?.ok) {
throw new Error("Opt-in request failed");
}
} catch {
Expand All @@ -72,6 +114,7 @@ function OptInFlagCard({ flag }: { flag: OptInFlag }) {
<h2>{flag.name}</h2>
{flag.description && <p>{flag.description}</p>}
<button
type="button"
aria-busy={isUpdating}
disabled={isUpdating}
onClick={updateOptIn}
Expand All @@ -88,44 +131,75 @@ function OptInFlagCard({ flag }: { flag: OptInFlag }) {
}
```

`setOptIn()` returns a promise that resolves after the SDK applies the latest flag state, confirms the membership change, and notifies components using `useOptInFlags()`. React may not have committed the resulting render yet.
`setOptIn()` returns `Promise<Response | undefined>`:

* An OK `Response` means the SDK has applied refreshed flag state and confirmed the membership change. Subscribed components are notified when flags change; React may not have committed the render yet.
* A non-OK `Response` means the HTTP request failed; the SDK does not refresh flags. Check `response.ok` and, if needed, read `response.json()` for error details.
* `undefined` means the request was skipped because offline mode is enabled, the scoped context ID is missing, or the arguments are invalid.
* Network and confirmation failures reject the promise. A confirmation failure can happen **after** membership changed remotely, so an error does not necessarily mean nothing changed.

The example handles both non-OK/missing responses and promise rejections.

`useOptInFlags()` keeps the list synchronized with Reflag. `useSetOptIn()` changes the current user's opt-in by default and requires the current Reflag context to include a `user.id`.

## Company opt-in

To change the current company's opt-in, pass `scope: "company"`. The current Reflag context must include a `company.id`.

Inside the `try` block in `updateOptIn()` above, replace the request with this call, keeping the same response check and error handling. Also use `flag.companyOptedIn` instead of `flag.userOptedIn` for the button label.

```tsx
setOptIn(flag.key, {
const response = await setOptIn(flag.key, {
optedIn: !flag.companyOptedIn,
scope: "company",
});
```

{% hint style="warning" %}
Opt-in is not an authorization boundary. Requests use a publishable key and caller-supplied context IDs; company scope does not verify company membership or administrator permissions. Hiding the button from non-admins does not prevent direct requests. For admin-only or sensitive access, enforce authorization in your backend and use server-controlled access rules instead of public end-user opt-in.
{% endhint %}

User and company opt-ins are independent. Setting `optedIn` to `false` removes only the selected scope, so `isOptedIn` remains `true` while either scope is opted in.

Cancelling every opt-in does not necessarily disable the flag: an access rule may independently enable it for the current context.

## Managing loading state with `<ReflagBootstrappedProvider>` and without `<Suspense>`
## Loading without Suspense

With `ReflagBootstrappedProvider`, the SDK fetches opt-in metadata on demand only if it is missing from the bootstrapped state. Node SDK `getFlagsForBootstrap()` data currently lacks this metadata. Complete bootstrapped metadata is immediately available without an extra request.

Only apps using `ReflagBootstrappedProvider` without Suspense need to handle this loading state. Bootstrapped flag data does not include opt-in metadata, so the SDK fetches it when `useOptInFlags()` is first used.
{% hint style="warning" %}
The on-demand refresh evaluates flags using browser-visible context. If your bootstrap depends on server-only or secret context, refreshed flags may differ. Disabling `enableLiveFlagUpdates` does not prevent this metadata refresh; only request opt-in data if browser-side re-evaluation is appropriate.
{% endhint %}

Check the hook's `isLoading` value before rendering an empty state:
With a regular `ReflagProvider`, `useOptInFlags().isLoading` remains `false`; `useIsLoading()` tracks normal initialization. To support either provider without Suspense, check both loading values before rendering an empty state:

```tsx
import { useIsLoading, useOptInFlags } from "@reflag/react-sdk";

const isProviderLoading = useIsLoading();
const { flags: optInFlags, isLoading } = useOptInFlags({ suspense: false });

if (isLoading) {
if (isProviderLoading || isLoading) {
return <Spinner aria-label="Loading opt-in flags" />;
}

if (optInFlags.length === 0) {
return <p>No opt-in flags are available.</p>;
return (
<section>
<p>No opt-in flags to show. If you expected some, try reloading.</p>
<ReloadOptInFlags />
</section>
);
}
```

With a regular `ReflagProvider`, opt-in metadata arrives as part of the normal flags request, so `useOptInFlags().isLoading` remains `false`. Use `useIsLoading()`, suspense or the provider's `loadingComponent` for the normal initial loading state.
`ReloadOptInFlags` is defined in the quick-start example. A provider's `loadingComponent` can handle normal initialization instead, but does not cover the on-demand metadata fetch.

### Failed metadata requests and retrying

The hook stops loading (or suspending) when the metadata refresh succeeds **or fails**. It does not expose an error field or throw fetch failures to an error boundary. An empty list can therefore mean either no available flags or a failed request; it is not proof that no opt-in flags exist.

After a failed on-demand refresh, rendering the hook again does not start another attempt for the same context. The example provides a manual retry through `useClient().refresh()`. This bypasses the cache, updates subscribers on success, and returns `undefined` if the refresh fails or is skipped. Track the retry's pending/error state separately, as shown above.

## Next steps

Expand Down
38 changes: 26 additions & 12 deletions sdk/@reflag/browser-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,30 +227,44 @@ If a flag has end-user opt-in enabled in Reflag, you can list the opt-in options

```ts
const optInFlags = reflagClient.getOptInFlags();
const isLoadingOptInFlags = reflagClient.getIsLoadingOptInFlags();
// [{ key, name, description, isEnabled, userOptedIn, companyOptedIn, isOptedIn }]
const isLoadingOptInFlags = reflagClient.getIsLoadingOptInFlags();

await reflagClient.setOptIn("huddle", { optedIn: true });
await reflagClient.setOptIn("huddle", { optedIn: false });

await reflagClient.setOptIn("huddle", {
optedIn: true,
scope: "company",
});
try {
const response = await reflagClient.setOptIn("huddle", {
optedIn: true, // Use false to cancel this scope's opt-in.
scope: "user", // Use "company" to change the current company's opt-in.
});
if (!response?.ok) throw new Error("Opt-in request failed or was skipped");
} catch (error) {
// Show an error in your UI; confirmation may fail after a remote change.
console.error("Could not update opt-in", error);
}
```

By default, `setOptIn()` changes the opt-in for the current user, so the current context must include a `user.id`. To manage the current company's opt-in instead, pass `scope: "company"`; the context must then include a `company.id`.

User and company opt-ins are managed independently. Setting `optedIn` to `false` removes the opt-in only for the selected scope. For example, cancelling a user's opt-in does not change the company's opt-in for the same flag.

`setOptIn` returns a promise so you can wait for the new membership state to be synchronized. It resolves after the latest flag state has been applied locally, the requested membership change has been confirmed, and `flagsUpdated` listeners have been notified.
Opt-in is not an authorization boundary. Requests use a publishable key and caller-supplied context IDs; company scope does not verify company membership or administrator permissions. Hiding the button from non-admins does not prevent direct requests. For admin-only or sensitive access, enforce authorization in your backend and use server-controlled access rules instead of public end-user opt-in.

`setOptIn` returns `Promise<Response | undefined>`:

- An OK `Response` is returned after refreshed flag state confirms the membership change. `flagsUpdated` listeners are notified when flags change.
- HTTP failures return a non-OK `Response` without refreshing flags. Check `response.ok`; `response.json()` can provide error details.
- Offline mode, invalid arguments, or a missing scoped context ID return `undefined` without sending a request.
- Network and confirmation failures reject the promise. A confirmation failure can happen after membership changed remotely.

Always check `response?.ok` and catch rejections, as in the example.

The `description` comes from the dedicated SDK-facing opt-in description configured in Reflag.

For a bootstrapped client, the first `getOptInFlags()` or `getIsLoadingOptInFlags()` call starts one flags refresh. The list call returns the currently available list synchronously, and the loading getter returns `true` until the refresh succeeds or fails. Normal initialization already exposes loading through the client's state.
For a bootstrapped client, the first `getOptInFlags()` or `getIsLoadingOptInFlags()` call requests a flags refresh only if opt-in metadata is missing. The list call returns the currently available list synchronously, and the loading getter returns `true` until that refresh succeeds or fails. Complete bootstrapped metadata is immediately available without an extra request. Normal initialization also exposes loading through the client's state.

Listen for `optInFlagsLoadingUpdated` to update UI when this loading state changes. `flagsUpdated` is emitted when a successful refresh updates the list.

A failed metadata refresh clears the loading state without exposing a separate error state. An empty list may mean unavailable data, not just no eligible flags. Calling the getters again does not retry the on-demand refresh for the same context. Call `reflagClient.refresh()` to retry manually, check for an `undefined` result, and track the retry's pending/error state in your UI.

## Remote config

Remote config is a dynamic and flexible approach to configuring flag behavior outside of your app – without needing to re-deploy it.
Expand Down Expand Up @@ -348,7 +362,7 @@ The `bootstrappedState` object contains:

If you want live flag updates to continue working after bootstrapping, use a recent `@reflag/node-sdk` so `getFlagsForBootstrap()` includes `flagStateVersion`.

If a bootstrapped application requests opt-in flags, the browser SDK performs one flags refresh. Applications that do not request opt-in data do not make this request.
If a bootstrapped application requests opt-in flags and its state lacks opt-in metadata, the browser SDK requests a flags refresh. Node SDK bootstrap data currently lacks this metadata. Complete metadata, or applications that do not request opt-in data, do not require this extra request.

If you previously used `bootstrappedFlags`, migrate like this:

Expand All @@ -372,7 +386,7 @@ const client = new ReflagClient({
```

{% hint style="info" %}
After bootstrapping, any live flag updates are fetched directly by the browser SDK from Reflag using the browser-visible context. If your bootstrapped snapshot depends on server-only or secret context that is not available in the browser, later live refreshes may differ. In that case, keep `enableLiveFlagUpdates` disabled.
After bootstrapping, on-demand opt-in metadata and live flag updates are fetched using the browser-visible context. If your snapshot depends on server-only or secret context, refreshed flags may differ. Disabling `enableLiveFlagUpdates` does not prevent the opt-in metadata refresh; only request opt-in data if browser-side re-evaluation is appropriate.
{% endhint %}

This eliminates loading states and removes the initial render's dependency on the flags API.
Expand Down
13 changes: 11 additions & 2 deletions sdk/@reflag/browser-sdk/globals.md
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,15 @@ setOptIn(flagKey: string, options: SetOptInOptions): Promise<

Set whether the current user or company has opted into a flag.

A successful Response is returned after the refreshed flag state confirms
the membership change. HTTP failures return a non-OK Response without
refreshing flags. Offline mode, invalid arguments, or missing scoped context
return undefined. Network and confirmation failures reject the promise;
a confirmation failure may occur after membership changed remotely.

Context IDs are supplied by the caller, not authenticated user identities.
Company scope does not enforce application roles or admin permissions.

###### Parameters

<table>
Expand Down Expand Up @@ -4715,11 +4724,11 @@ User ID from your own application.
```ts
type SetOptInOptions = {
optedIn: boolean;
scope: "user" | "company";
scope?: "user" | "company";
};
```

Represents a flag.
Options for changing the current user or company's opt-in membership.

#### Type declaration

Expand Down
25 changes: 20 additions & 5 deletions sdk/@reflag/react-native-sdk/globals.md
Original file line number Diff line number Diff line change
Expand Up @@ -652,13 +652,17 @@ type FlagType = {
### OptInFlag

```ts
type OptInFlag = Omit<OptInFlag, "key"> & {
type OptInFlag = Omit<import("@reflag/browser-sdk").OptInFlag, "key"> & {
key: FlagKey;
};
```

An opt-in-enabled flag for the generated React SDK flag definitions.

Includes all fields from [BrowserOptInFlag](../browser-sdk/globals.md#optinflag): `name`, `description`,
`isEnabled`, `userOptedIn`, `companyOptedIn`, and `isOptedIn`.
Only `key` is narrowed to the generated [FlagKey](globals.md#flagkey) type.

#### Type declaration

<table>
Expand Down Expand Up @@ -1059,11 +1063,11 @@ type RequestFeedbackOptions = Omit<RequestFeedbackData, "flagKey" | "featureId">
```ts
type SetOptInOptions = {
optedIn: boolean;
scope: "user" | "company";
scope?: "user" | "company";
};
```

Represents a flag.
Options for changing the current user or company's opt-in membership.

#### Type declaration

Expand Down Expand Up @@ -1748,9 +1752,15 @@ Returns opt-in-enabled flags and their loading state for the current context.

The loading state is only used with `ReflagBootstrappedProvider` while
opt-in metadata is fetched on demand. Regular providers load opt-in metadata
with the initial flags.
with the initial flags; use [useIsLoading](globals.md#useisloading) for their loading state.
Complete bootstrapped opt-in metadata needs no extra request.
When suspense is enabled for the provider or this hook, it suspends instead
of returning a loading result.
of returning a loading result. A Suspense boundary alone does not enable it.

Fetch failures end loading without exposing an error, so an empty list can
also mean unavailable data. Re-rendering does not retry a failed on-demand
fetch for the same context. Call the client returned by [useClient](globals.md#useclient)'s
`refresh()` method to retry and manage the retry's pending/error state yourself.

#### Parameters

Expand Down Expand Up @@ -1907,6 +1917,11 @@ function useSetOptIn(): (key: FlagKey, options: SetOptInOptions) => Promise<

Returns a function to set whether the current user or company has opted into a flag.

Check the returned Response's `ok` property and catch promise rejections.
HTTP failures return a non-OK Response; offline mode, invalid arguments, or
missing scoped context return undefined. Confirmation failures can reject
after the membership changed remotely. See [ReflagClient.setOptIn](../browser-sdk/globals.md#setoptin).

#### Returns

`Function`
Expand Down
Loading
Loading