From 9365b98258fd6c2769a1284d527603cc88778b06 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Mon, 3 Aug 2026 20:33:30 +0200 Subject: [PATCH 01/13] docs: update beta opt-in guide --- guides/self-opt-in.md | 127 +++++++++++++++++++++----------- product-handbook/self-opt-in.md | 63 ---------------- 2 files changed, 82 insertions(+), 108 deletions(-) delete mode 100644 product-handbook/self-opt-in.md diff --git a/guides/self-opt-in.md b/guides/self-opt-in.md index c1d3e57..e91f37b 100644 --- a/guides/self-opt-in.md +++ b/guides/self-opt-in.md @@ -1,63 +1,100 @@ --- -description: How to create a beta feature self opt-in page in React with Reflag +description: How to build a beta feature opt-in page with the Reflag React SDK icon: browser --- # Beta feature opt-in -Creating a page where users can opt into certain beta/experimental features is straightforward with Reflag and the Reflag React SDK. +Let users opt themselves—or their company—into beta and experimental features with Reflag's React SDK. -The basic concept is to set an attribute on the user/company which denotes that the user has self-opted into a specific feature, for example `optin- = true.` Updating an attribute is very simple from the SDK. Then use that attribute to control who has access to the feature by updating the feature access rules such that users/companies with the attribute `optin-=true` will have access to the feature. +## Quick start -## Step-by-step guide +After enabling end-user opt-in on at least one flag in Reflag, render the available flags and let the current user set their opt-in status: -Here's a step-by-step guide: +```tsx +import { useOptInFlags, useSetOptIn } from "@reflag/react-sdk"; -1. Select a feature to let people self-opt into. -2. Add the rule: `optin- IS TRUE` to the rules section for all the environments. Replace `` with the actual feature key of the feature. -3. Use the following React component to let users self-opt opt-in to specific features: +function OptInList() { + const optInFlags = useOptInFlags(); + const setOptIn = useSetOptIn(); -```tsx -import { useUpdateUser, useFlag, ReflagFeatures } from "@reflag/react-sdk"; -import { useState } from "react"; - -function FeatureOptIn({ - featureKey, - featureName, -}: { - featureKey: ReflagFeatures; - featureName: string; -}) { - const updateUser = useUpdateUser(); - const [sendingUpdate, setSendingUpdate] = useState(false); - const { isEnabled } = useFlag(featureKey); - - return ( -
- - { - setSendingUpdate(true); - updateUser({ - [`optin-${featureKey}`]: isEnabled ? "false" : "true", - }).then(() => { - setSendingUpdate(false); - }); - }} - /> -
- ); + return optInFlags.map((flag) => ( + + )); } ``` -### How it works +`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`. + +## Configure a flag for opt-in + +1. Open a non-secret flag in Reflag. +2. Go to **Settings > Opt-in**. +3. Enable **End-user opt-in**. +4. Optionally add a **Public description**. The SDK exposes this text so you can display it in your opt-in UI. +5. Save your changes. +6. On the flag's **Access** tab, verify that access is set to **Some** in each environment where users should be able to opt in. Leave the other access rules empty for an opt-in-only feature, or add rules to grant access through either targeting or opt-in. + +Secret flags cannot use end-user opt-in because opt-ins are submitted directly from a browser or client using a publishable key. + +## Opt-in flag data + +`useOptInFlags()` returns the opt-in-enabled flags available to the current context. Each flag includes: -The React component above uses [remote attributes](https://reflag.com/changelog/introducing-remote-attributes) to ensure that any feature you've enabled stays enabled between sessions. +| Field | Description | +| --- | --- | +| `key` | The flag key. | +| `name` | The flag's display name. | +| `description` | The public opt-in description configured in Reflag, or `null`. | +| `isEnabled` | Whether the flag is enabled for the current context. | +| `userOptedIn` | Whether the current user opted in. | +| `companyOptedIn` | Whether the current company opted in. | +| `isOptedIn` | Whether either the current user or company opted in. | + +Use `userOptedIn` or `companyOptedIn`—not `isEnabled`—as the state of an opt-in control. A flag can be enabled by an access rule even when the user or company has not opted in. + +## Company opt-in + +To change the current company's opt-in, pass `scope: "company"`. The current Reflag context must include a `company.id`. + +```tsx +setOptIn(flag.key, { + optedIn: !flag.companyOptedIn, + scope: "company", +}); +``` + +User and company opt-ins are independent. 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. `isOptedIn` remains `true` while either scope is opted in. + +Cancelling every opt-in also does not necessarily disable the flag: an access rule may independently enable it for the current context. + +## Access behavior + +A flag's access setting determines how opt-in membership affects evaluation: + +| Access | Behavior | +| --- | --- | +| **No one** | The flag is off for everyone. Existing opt-ins are inactive, and new opt-ins are rejected. | +| **Some** | The flag is enabled when another access rule matches **or** the current user or company opted in. With no other rules, access is opt-in-only. | +| **Everyone** | The flag is enabled for everyone, regardless of opt-in status. | + +Disabling end-user opt-in stops new opt-ins and makes existing memberships inactive, but it does not delete them. Re-enabling opt-in reactivates those memberships unless access is set to **No one**. + +## Waiting for an update + +`setOptIn()` returns a promise. It resolves after the latest flag state has been applied, the requested membership change has been confirmed, and components using `useOptInFlags()` have been notified. React schedules the resulting render normally, so it may not have committed when the promise resolves. + +You can await it when your UI needs a pending or error state: + +```tsx +await setOptIn(flag.key, { optedIn: true }); +``` ### Next steps -* Learn how to manage who has access, and modify the [Targeting rules](../product-handbook/feature-rollouts/feature-targeting-rules.md) in the UI. +Learn how to manage additional access with [Access rules](../product-handbook/feature-rollouts/feature-targeting-rules.md). diff --git a/product-handbook/self-opt-in.md b/product-handbook/self-opt-in.md deleted file mode 100644 index 9c6afe0..0000000 --- a/product-handbook/self-opt-in.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -description: How to create a beta feature self opt-in page in React with Reflag -icon: browser ---- - -# Create a beta feature opt-in page - -Creating a page where users can opt into certain beta/experimental features is straight forward with Reflag and the Reflag React SDK. - -The basic concept is to set an attribute on the user/company which denotes that the user has self opted into a specific feature, for example `optin- = true.` Updating an attribute is very simple from the SDK. Then use that attribute to control who has access to the feature by updating the flag access rules such that users/companies with the attribute `optin-=true` will have access to the feature. - -## Step by step guide - -Here's a step by step guide: - -1. Select a feature to let people self opt-into. -2. Add the rule: `optin- IS TRUE` to the rules section for all the environments. Replace `` with the actual flag key of the flag. -3. Use the following React component to let users self opt-in to specific features: - -```tsx -import { useUpdateUser, useFlag, TypedFlags } from "@reflag/react-sdk"; -import { useState } from "react"; - -function FeatureOptIn({ - flagKey, - featureName, -}: { - flagKey: TypedFlags; - featureName: string; -}) { - const updateUser = useUpdateUser(); - const [sendingUpdate, setSendingUpdate] = useState(false); - const { isEnabled } = useFlag(flagKey); - - return ( -
- - { - setSendingUpdate(true); - updateUser({ - [`optin-${flagKey}`]: isEnabled ? "false" : "true", - }).then(() => { - setSendingUpdate(false); - }); - }} - /> -
- ); -} -``` - -### How it works - -The React component above uses [remote attributes](https://reflag.com/changelog/introducing-remote-attributes) to ensure that any flag you've enabled stays enabled between sessions. - -### Next steps - -- Learn how to manage who has access, modify the [Targeting rules](feature-rollouts/feature-targeting-rules.md) in the UI. From b25895ae16878747000aa2cf0f49908a36f7ee7b Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Mon, 3 Aug 2026 20:48:35 +0200 Subject: [PATCH 02/13] docs: show opt-in descriptions --- guides/self-opt-in.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/guides/self-opt-in.md b/guides/self-opt-in.md index e91f37b..4e45641 100644 --- a/guides/self-opt-in.md +++ b/guides/self-opt-in.md @@ -19,12 +19,15 @@ function OptInList() { const setOptIn = useSetOptIn(); return optInFlags.map((flag) => ( - +
+

{flag.name}

+ {flag.description &&

{flag.description}

} + +
)); } ``` From 3666eceb41899a8d20a36a0949d307fe472cc300 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 4 Aug 2026 13:51:26 +0200 Subject: [PATCH 03/13] docs: show pending opt-in state --- guides/self-opt-in.md | 57 +++++++++++++++++++++++++++++++++---------- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/guides/self-opt-in.md b/guides/self-opt-in.md index 4e45641..ef5a871 100644 --- a/guides/self-opt-in.md +++ b/guides/self-opt-in.md @@ -12,26 +12,57 @@ Let users opt themselves—or their company—into beta and experimental feature After enabling end-user opt-in on at least one flag in Reflag, render the available flags and let the current user set their opt-in status: ```tsx -import { useOptInFlags, useSetOptIn } from "@reflag/react-sdk"; +import { useState } from "react"; +import { + type OptInFlag, + useOptInFlags, + useSetOptIn, +} from "@reflag/react-sdk"; +import { Spinner } from "your-component-library"; function OptInList() { const optInFlags = useOptInFlags(); const setOptIn = useSetOptIn(); - - return optInFlags.map((flag) => ( -
-

{flag.name}

- {flag.description &&

{flag.description}

} - -
- )); + const [updatingFlagKey, setUpdatingFlagKey] = useState(null); + + async function updateOptIn(flag: OptInFlag) { + setUpdatingFlagKey(flag.key); + + try { + await setOptIn(flag.key, { optedIn: !flag.userOptedIn }); + } finally { + setUpdatingFlagKey(null); + } + } + + return optInFlags.map((flag) => { + const isUpdating = updatingFlagKey === flag.key; + + return ( +
+

{flag.name}

+ {flag.description &&

{flag.description}

} + +
+ ); + }); } ``` +Replace the `Spinner` import with the spinner component from your application or component library. + `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`. ## Configure a flag for opt-in From 531bd3a1bb2f1f1fce90be1ef3468534feeca79d Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 4 Aug 2026 19:39:03 +0200 Subject: [PATCH 04/13] docs: explain opt-in metadata loading --- guides/self-opt-in.md | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/guides/self-opt-in.md b/guides/self-opt-in.md index ef5a871..ae4fe6c 100644 --- a/guides/self-opt-in.md +++ b/guides/self-opt-in.md @@ -21,7 +21,7 @@ import { import { Spinner } from "your-component-library"; function OptInList() { - const optInFlags = useOptInFlags(); + const { flags: optInFlags, isLoading } = useOptInFlags(); const setOptIn = useSetOptIn(); const [updatingFlagKey, setUpdatingFlagKey] = useState(null); @@ -35,6 +35,14 @@ function OptInList() { } } + if (isLoading) { + return ; + } + + if (optInFlags.length === 0) { + return

No opt-in flags are available.

; + } + return optInFlags.map((flag) => { const isUpdating = updatingFlagKey === flag.key; @@ -65,6 +73,31 @@ Replace the `Spinner` import with the spinner component from your application or `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`. +### Loading bootstrapped opt-in metadata + +`useOptInFlags()` returns `{ flags, isLoading }`. `isLoading` is only `true` when you use `ReflagBootstrappedProvider` with bootstrap data that does not contain browser opt-in metadata. The SDK fetches that metadata on demand and sets `isLoading` back to `false` after the request succeeds or fails. Bootstrap data that already contains complete opt-in metadata reports `false` immediately. + +With a regular `ReflagProvider`, opt-in metadata arrives as part of the normal flags request, so `useOptInFlags().isLoading` remains `false`. Use `useIsLoading()` or the provider's `loadingComponent` for the normal initial loading state. + +`useOptInFlags()` also supports React Suspense. Enable `suspense` on the provider or for one hook call with `useOptInFlags({ suspense: true })`: + +```tsx +import { Suspense } from "react"; +import { ReflagBootstrappedProvider } from "@reflag/react-sdk"; + + + }> + + +; +``` + +When Suspense is enabled, the fallback is shown instead of returning an `isLoading: true` result. Pass `{ suspense: false }` to one `useOptInFlags()` call to opt out of provider-level Suspense. + ## Configure a flag for opt-in 1. Open a non-secret flag in Reflag. @@ -78,7 +111,7 @@ Secret flags cannot use end-user opt-in because opt-ins are submitted directly f ## Opt-in flag data -`useOptInFlags()` returns the opt-in-enabled flags available to the current context. Each flag includes: +The `flags` value returned by `useOptInFlags()` contains the opt-in-enabled flags available to the current context. Each flag includes: | Field | Description | | --- | --- | From d9ab06d3bebeb04055e94a359a8df07cd69507fe Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 4 Aug 2026 20:43:37 +0200 Subject: [PATCH 05/13] docs: isolate opt-in flag pending state --- guides/self-opt-in.md | 75 ++++++++++++++++++++++--------------------- 1 file changed, 39 insertions(+), 36 deletions(-) diff --git a/guides/self-opt-in.md b/guides/self-opt-in.md index ae4fe6c..b6e07bc 100644 --- a/guides/self-opt-in.md +++ b/guides/self-opt-in.md @@ -22,18 +22,6 @@ import { Spinner } from "your-component-library"; function OptInList() { const { flags: optInFlags, isLoading } = useOptInFlags(); - const setOptIn = useSetOptIn(); - const [updatingFlagKey, setUpdatingFlagKey] = useState(null); - - async function updateOptIn(flag: OptInFlag) { - setUpdatingFlagKey(flag.key); - - try { - await setOptIn(flag.key, { optedIn: !flag.userOptedIn }); - } finally { - setUpdatingFlagKey(null); - } - } if (isLoading) { return ; @@ -43,33 +31,48 @@ function OptInList() { return

No opt-in flags are available.

; } - return optInFlags.map((flag) => { - const isUpdating = updatingFlagKey === flag.key; - - return ( -
-

{flag.name}

- {flag.description &&

{flag.description}

} - -
- ); - }); + return optInFlags.map((flag) => ( + + )); +} + +function OptInFlagCard({ flag }: { flag: OptInFlag }) { + const setOptIn = useSetOptIn(); + const [isUpdating, setIsUpdating] = useState(false); + + async function updateOptIn() { + setIsUpdating(true); + + try { + await setOptIn(flag.key, { optedIn: !flag.userOptedIn }); + } finally { + setIsUpdating(false); + } + } + + return ( +
+

{flag.name}

+ {flag.description &&

{flag.description}

} + +
+ ); } ``` -Replace the `Spinner` import with the spinner component from your application or component library. +Replace the `Spinner` import with the spinner component from your application or component library. Each `OptInFlagCard` owns its pending state, preventing duplicate clicks on that flag while leaving the other opt-in controls available. `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`. From bf0742c267a1ec3f7bffde18f575b37a68cb0133 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 4 Aug 2026 20:43:51 +0200 Subject: [PATCH 06/13] docs: remove redundant spinner guidance --- guides/self-opt-in.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/self-opt-in.md b/guides/self-opt-in.md index b6e07bc..8250ab0 100644 --- a/guides/self-opt-in.md +++ b/guides/self-opt-in.md @@ -72,7 +72,7 @@ function OptInFlagCard({ flag }: { flag: OptInFlag }) { } ``` -Replace the `Spinner` import with the spinner component from your application or component library. Each `OptInFlagCard` owns its pending state, preventing duplicate clicks on that flag while leaving the other opt-in controls available. +Each `OptInFlagCard` owns its pending state, preventing duplicate clicks on that flag while leaving the other opt-in controls available. `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`. From f6a96c0424b75aeff49385bd2dc3920e0aa6f3b9 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 4 Aug 2026 20:49:21 +0200 Subject: [PATCH 07/13] docs: lead with Suspense opt-in example --- guides/self-opt-in.md | 94 +++++++++++++++++++++++-------------------- 1 file changed, 50 insertions(+), 44 deletions(-) diff --git a/guides/self-opt-in.md b/guides/self-opt-in.md index 8250ab0..a6d2f35 100644 --- a/guides/self-opt-in.md +++ b/guides/self-opt-in.md @@ -12,7 +12,7 @@ Let users opt themselves—or their company—into beta and experimental feature After enabling end-user opt-in on at least one flag in Reflag, render the available flags and let the current user set their opt-in status: ```tsx -import { useState } from "react"; +import { Suspense, use, useState } from "react"; import { type OptInFlag, useOptInFlags, @@ -20,86 +20,92 @@ import { } from "@reflag/react-sdk"; import { Spinner } from "your-component-library"; -function OptInList() { - const { flags: optInFlags, isLoading } = useOptInFlags(); +function OptInPage() { + return ( + }> + + + ); +} - if (isLoading) { - return ; - } +function OptInList() { + const { flags: optInFlags } = useOptInFlags({ suspense: true }); if (optInFlags.length === 0) { return

No opt-in flags are available.

; } return optInFlags.map((flag) => ( - + } + > + + )); } function OptInFlagCard({ flag }: { flag: OptInFlag }) { const setOptIn = useSetOptIn(); - const [isUpdating, setIsUpdating] = useState(false); - - async function updateOptIn() { - setIsUpdating(true); + const [pendingUpdate, setPendingUpdate] = useState | null>(null); - try { - await setOptIn(flag.key, { optedIn: !flag.userOptedIn }); - } finally { - setIsUpdating(false); - } - } + if (pendingUpdate) use(pendingUpdate); return (

{flag.name}

{flag.description &&

{flag.description}

}
); } ``` -Each `OptInFlagCard` owns its pending state, preventing duplicate clicks on that flag while leaving the other opt-in controls available. +This React 19 example uses one Suspense boundary for the initial flags and a boundary around each opt-in feature. Calling `use()` with the promise returned by `setOptIn()` shows the pending fallback for only that feature, leaving the other opt-in controls available. You can enable `suspense` on `ReflagProvider` or `ReflagBootstrappedProvider` instead of passing `{ suspense: true }` to each hook. `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`. -### Loading bootstrapped opt-in metadata +### Managing loading state without `` -`useOptInFlags()` returns `{ flags, isLoading }`. `isLoading` is only `true` when you use `ReflagBootstrappedProvider` with bootstrap data that does not contain browser opt-in metadata. The SDK fetches that metadata on demand and sets `isLoading` back to `false` after the request succeeds or fails. Bootstrap data that already contains complete opt-in metadata reports `false` immediately. +Without Suspense, `useOptInFlags()` returns `{ flags, isLoading }`. Check `isLoading` before rendering an empty state: + +```tsx +const { flags: optInFlags, isLoading } = useOptInFlags({ suspense: false }); + +if (isLoading) { + return ; +} + +if (optInFlags.length === 0) { + return

No opt-in flags are available.

; +} +``` + +This opt-in loading state is only `true` when you use `ReflagBootstrappedProvider` with bootstrap data that does not contain browser opt-in metadata. The SDK fetches that metadata on demand and sets `isLoading` back to `false` after the request succeeds or fails. Bootstrap data that already contains complete opt-in metadata reports `false` immediately. With a regular `ReflagProvider`, opt-in metadata arrives as part of the normal flags request, so `useOptInFlags().isLoading` remains `false`. Use `useIsLoading()` or the provider's `loadingComponent` for the normal initial loading state. -`useOptInFlags()` also supports React Suspense. Enable `suspense` on the provider or for one hook call with `useOptInFlags({ suspense: true })`: +For individual updates in React 19, `useTransition()` provides a pending state without suspending the component: ```tsx -import { Suspense } from "react"; -import { ReflagBootstrappedProvider } from "@reflag/react-sdk"; - - - }> - - -; +const [isUpdating, startTransition] = useTransition(); + +function updateOptIn() { + startTransition(() => + setOptIn(flag.key, { optedIn: !flag.userOptedIn }), + ); +} ``` -When Suspense is enabled, the fallback is shown instead of returning an `isLoading: true` result. Pass `{ suspense: false }` to one `useOptInFlags()` call to opt out of provider-level Suspense. +Use `isUpdating` to disable the selected flag's button and display its spinner. Pass `{ suspense: false }` to opt out for one hook when Suspense is enabled at the provider level. ## Configure a flag for opt-in From 22b6d852a9a2ed002cc0cf0f2414a7ff4e5fb853 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 4 Aug 2026 20:50:47 +0200 Subject: [PATCH 08/13] docs: scope update fallback to opt-in button --- guides/self-opt-in.md | 51 +++++++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/guides/self-opt-in.md b/guides/self-opt-in.md index a6d2f35..fcc894a 100644 --- a/guides/self-opt-in.md +++ b/guides/self-opt-in.md @@ -36,12 +36,7 @@ function OptInList() { } return optInFlags.map((flag) => ( - } - > - - + )); } @@ -49,27 +44,51 @@ function OptInFlagCard({ flag }: { flag: OptInFlag }) { const setOptIn = useSetOptIn(); const [pendingUpdate, setPendingUpdate] = useState | null>(null); - if (pendingUpdate) use(pendingUpdate); - return (

{flag.name}

{flag.description &&

{flag.description}

} - } > - {flag.userOptedIn ? "Cancel opt-in" : `Try ${flag.name}`} - + + setPendingUpdate( + setOptIn(flag.key, { optedIn: !flag.userOptedIn }), + ) + } + /> +
); } + +function OptInButton({ + flag, + pendingUpdate, + onClick, +}: { + flag: OptInFlag; + pendingUpdate: Promise | null; + onClick: () => void; +}) { + if (pendingUpdate) use(pendingUpdate); + + return ( + + ); +} ``` -This React 19 example uses one Suspense boundary for the initial flags and a boundary around each opt-in feature. Calling `use()` with the promise returned by `setOptIn()` shows the pending fallback for only that feature, leaving the other opt-in controls available. You can enable `suspense` on `ReflagProvider` or `ReflagBootstrappedProvider` instead of passing `{ suspense: true }` to each hook. +This React 19 example uses one Suspense boundary for the initial flags. Each `OptInFlagCard` contains its own boundary around the opt-in button, so calling `use()` with the promise returned by `setOptIn()` replaces only that button with its pending fallback and leaves the other controls available. You can enable `suspense` on `ReflagProvider` or `ReflagBootstrappedProvider` instead of passing `{ suspense: true }` to each hook. `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`. From 301ae2abd9907eb1f70b228e427e6e087e141a32 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 4 Aug 2026 21:06:51 +0200 Subject: [PATCH 09/13] docs: stabilize and isolate opt-in updates --- guides/self-opt-in.md | 83 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 17 deletions(-) diff --git a/guides/self-opt-in.md b/guides/self-opt-in.md index fcc894a..551fe90 100644 --- a/guides/self-opt-in.md +++ b/guides/self-opt-in.md @@ -43,32 +43,64 @@ function OptInList() { function OptInFlagCard({ flag }: { flag: OptInFlag }) { const setOptIn = useSetOptIn(); const [pendingUpdate, setPendingUpdate] = useState | null>(null); + const [updateError, setUpdateError] = useState(null); + + function updateOptIn() { + setUpdateError(null); + + const update = setOptIn(flag.key, { + optedIn: !flag.userOptedIn, + }) + .then((response) => { + if (response?.ok === false) { + throw new Error("Opt-in request failed"); + } + }) + .catch(() => { + setUpdateError(`Could not update ${flag.name}. Please try again.`); + }); + + setPendingUpdate(update); + } return (

{flag.name}

{flag.description &&

{flag.description}

} - - - - } - > + }> - setPendingUpdate( - setOptIn(flag.key, { optedIn: !flag.userOptedIn }), - ) - } + onClick={updateOptIn} /> + {updateError &&

{updateError}

}
); } +function OptInButtonFallback({ flag }: { flag: OptInFlag }) { + const label = flag.userOptedIn ? "Cancel opt-in" : `Try ${flag.name}`; + + return ( + + ); +} + function OptInButton({ flag, pendingUpdate, @@ -88,7 +120,9 @@ function OptInButton({ } ``` -This React 19 example uses one Suspense boundary for the initial flags. Each `OptInFlagCard` contains its own boundary around the opt-in button, so calling `use()` with the promise returned by `setOptIn()` replaces only that button with its pending fallback and leaves the other controls available. You can enable `suspense` on `ReflagProvider` or `ReflagBootstrappedProvider` instead of passing `{ suspense: true }` to each hook. +This React 19 example uses one Suspense boundary for the initial flags. Each `OptInFlagCard` owns its pending and error state and contains its own boundary around the opt-in button, so calling `use()` with the update promise replaces only that button and leaves the other controls available. The fallback keeps an invisible copy of the button label in the layout and overlays the spinner, preventing nearby content from reflowing while the update is pending. + +You can enable `suspense` on `ReflagProvider` or `ReflagBootstrappedProvider` instead of passing `{ suspense: true }` to each hook. `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`. @@ -116,15 +150,30 @@ For individual updates in React 19, `useTransition()` provides a pending state w ```tsx const [isUpdating, startTransition] = useTransition(); +const [updateError, setUpdateError] = useState(null); function updateOptIn() { - startTransition(() => - setOptIn(flag.key, { optedIn: !flag.userOptedIn }), - ); + setUpdateError(null); + + startTransition(async () => { + try { + const response = await setOptIn(flag.key, { + optedIn: !flag.userOptedIn, + }); + + if (response?.ok === false) { + throw new Error("Opt-in request failed"); + } + } catch { + setUpdateError(`Could not update ${flag.name}. Please try again.`); + } + }); } ``` -Use `isUpdating` to disable the selected flag's button and display its spinner. Pass `{ suspense: false }` to opt out for one hook when Suspense is enabled at the provider level. +Keep this state in the component that renders one flag so only that flag's control is disabled. Reserve space for the spinner—or overlay it within a control of the same dimensions—to avoid shifting nearby content while `isUpdating` changes. + +Pass `{ suspense: false }` to `useOptInFlags()` to opt out of the initial opt-in metadata Suspense behavior for one hook when Suspense is enabled at the provider level. ## Configure a flag for opt-in From cbdc91f47559afbfd194fe23698407e6c5778505 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 4 Aug 2026 21:08:42 +0200 Subject: [PATCH 10/13] docs: remove unnecessary opt-in transition example --- guides/self-opt-in.md | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/guides/self-opt-in.md b/guides/self-opt-in.md index 551fe90..c5a4937 100644 --- a/guides/self-opt-in.md +++ b/guides/self-opt-in.md @@ -146,33 +146,6 @@ This opt-in loading state is only `true` when you use `ReflagBootstrappedProvide With a regular `ReflagProvider`, opt-in metadata arrives as part of the normal flags request, so `useOptInFlags().isLoading` remains `false`. Use `useIsLoading()` or the provider's `loadingComponent` for the normal initial loading state. -For individual updates in React 19, `useTransition()` provides a pending state without suspending the component: - -```tsx -const [isUpdating, startTransition] = useTransition(); -const [updateError, setUpdateError] = useState(null); - -function updateOptIn() { - setUpdateError(null); - - startTransition(async () => { - try { - const response = await setOptIn(flag.key, { - optedIn: !flag.userOptedIn, - }); - - if (response?.ok === false) { - throw new Error("Opt-in request failed"); - } - } catch { - setUpdateError(`Could not update ${flag.name}. Please try again.`); - } - }); -} -``` - -Keep this state in the component that renders one flag so only that flag's control is disabled. Reserve space for the spinner—or overlay it within a control of the same dimensions—to avoid shifting nearby content while `isUpdating` changes. - Pass `{ suspense: false }` to `useOptInFlags()` to opt out of the initial opt-in metadata Suspense behavior for one hook when Suspense is enabled at the provider level. ## Configure a flag for opt-in From cc589f459473b7afe33fdab04b372a0bafc3fe8b Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 4 Aug 2026 21:16:03 +0200 Subject: [PATCH 11/13] docs: clarify bootstrapped opt-in loading --- guides/self-opt-in.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/self-opt-in.md b/guides/self-opt-in.md index c5a4937..f56a770 100644 --- a/guides/self-opt-in.md +++ b/guides/self-opt-in.md @@ -142,7 +142,7 @@ if (optInFlags.length === 0) { } ``` -This opt-in loading state is only `true` when you use `ReflagBootstrappedProvider` with bootstrap data that does not contain browser opt-in metadata. The SDK fetches that metadata on demand and sets `isLoading` back to `false` after the request succeeds or fails. Bootstrap data that already contains complete opt-in metadata reports `false` immediately. +This opt-in loading state is only `true` with `ReflagBootstrappedProvider`. The SDK fetches opt-in metadata on first use and sets `isLoading` back to `false` after the flags refresh succeeds or fails. With a regular `ReflagProvider`, opt-in metadata arrives as part of the normal flags request, so `useOptInFlags().isLoading` remains `false`. Use `useIsLoading()` or the provider's `loadingComponent` for the normal initial loading state. From d6fa592dbecdf551226ee7325680fe5676c05c3b Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 4 Aug 2026 21:26:52 +0200 Subject: [PATCH 12/13] docs: simplify end-user opt-in guide --- guides/self-opt-in.md | 142 ++++++++++++------------------------------ 1 file changed, 39 insertions(+), 103 deletions(-) diff --git a/guides/self-opt-in.md b/guides/self-opt-in.md index f56a770..a2310b6 100644 --- a/guides/self-opt-in.md +++ b/guides/self-opt-in.md @@ -9,10 +9,12 @@ Let users opt themselves—or their company—into beta and experimental feature ## Quick start -After enabling end-user opt-in on at least one flag in Reflag, render the available flags and let the current user set their opt-in status: +After enabling end-user opt-in on at least one flag in Reflag, render the available flags and let the current user set their opt-in status. + +This example assumes your app has a `` boundary. See below for an example without ``. ```tsx -import { Suspense, use, useState } from "react"; +import { useState } from "react"; import { type OptInFlag, useOptInFlags, @@ -21,14 +23,6 @@ import { import { Spinner } from "your-component-library"; function OptInPage() { - return ( - }> - - - ); -} - -function OptInList() { const { flags: optInFlags } = useOptInFlags({ suspense: true }); if (optInFlags.length === 0) { @@ -42,93 +36,59 @@ function OptInList() { function OptInFlagCard({ flag }: { flag: OptInFlag }) { const setOptIn = useSetOptIn(); - const [pendingUpdate, setPendingUpdate] = useState | null>(null); + const [isUpdating, setIsUpdating] = useState(false); const [updateError, setUpdateError] = useState(null); + const label = flag.userOptedIn ? "Cancel opt-in" : `Try ${flag.name}`; - function updateOptIn() { + async function updateOptIn() { setUpdateError(null); + setIsUpdating(true); - const update = setOptIn(flag.key, { - optedIn: !flag.userOptedIn, - }) - .then((response) => { - if (response?.ok === false) { - throw new Error("Opt-in request failed"); - } - }) - .catch(() => { - setUpdateError(`Could not update ${flag.name}. Please try again.`); + try { + const response = await setOptIn(flag.key, { + optedIn: !flag.userOptedIn, }); - setPendingUpdate(update); + if (response?.ok === false) { + throw new Error("Opt-in request failed"); + } + } catch { + setUpdateError(`Could not update ${flag.name}. Please try again.`); + } finally { + setIsUpdating(false); + } } return (

{flag.name}

{flag.description &&

{flag.description}

} - }> - - + {updateError &&

{updateError}

}
); } - -function OptInButtonFallback({ flag }: { flag: OptInFlag }) { - const label = flag.userOptedIn ? "Cancel opt-in" : `Try ${flag.name}`; - - return ( - - ); -} - -function OptInButton({ - flag, - pendingUpdate, - onClick, -}: { - flag: OptInFlag; - pendingUpdate: Promise | null; - onClick: () => void; -}) { - if (pendingUpdate) use(pendingUpdate); - - return ( - - ); -} ``` -This React 19 example uses one Suspense boundary for the initial flags. Each `OptInFlagCard` owns its pending and error state and contains its own boundary around the opt-in button, so calling `use()` with the update promise replaces only that button and leaves the other controls available. The fallback keeps an invisible copy of the button label in the layout and overlays the spinner, preventing nearby content from reflowing while the update is pending. - -You can enable `suspense` on `ReflagProvider` or `ReflagBootstrappedProvider` instead of passing `{ suspense: true }` to each hook. +To use Suspense by default, enable `suspense` on `ReflagProvider` or `ReflagBootstrappedProvider` instead of passing `{ suspense: true }` to the hook. `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`. ### Managing loading state without `` -Without Suspense, `useOptInFlags()` returns `{ flags, isLoading }`. Check `isLoading` before rendering an empty state: +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. + +Check the hook's `isLoading` value before rendering an empty state: ```tsx const { flags: optInFlags, isLoading } = useOptInFlags({ suspense: false }); @@ -142,12 +102,8 @@ if (optInFlags.length === 0) { } ``` -This opt-in loading state is only `true` with `ReflagBootstrappedProvider`. The SDK fetches opt-in metadata on first use and sets `isLoading` back to `false` after the flags refresh succeeds or fails. - With a regular `ReflagProvider`, opt-in metadata arrives as part of the normal flags request, so `useOptInFlags().isLoading` remains `false`. Use `useIsLoading()` or the provider's `loadingComponent` for the normal initial loading state. -Pass `{ suspense: false }` to `useOptInFlags()` to opt out of the initial opt-in metadata Suspense behavior for one hook when Suspense is enabled at the provider level. - ## Configure a flag for opt-in 1. Open a non-secret flag in Reflag. @@ -159,22 +115,6 @@ Pass `{ suspense: false }` to `useOptInFlags()` to opt out of the initial opt-in Secret flags cannot use end-user opt-in because opt-ins are submitted directly from a browser or client using a publishable key. -## Opt-in flag data - -The `flags` value returned by `useOptInFlags()` contains the opt-in-enabled flags available to the current context. Each flag includes: - -| Field | Description | -| --- | --- | -| `key` | The flag key. | -| `name` | The flag's display name. | -| `description` | The public opt-in description configured in Reflag, or `null`. | -| `isEnabled` | Whether the flag is enabled for the current context. | -| `userOptedIn` | Whether the current user opted in. | -| `companyOptedIn` | Whether the current company opted in. | -| `isOptedIn` | Whether either the current user or company opted in. | - -Use `userOptedIn` or `companyOptedIn`—not `isEnabled`—as the state of an opt-in control. A flag can be enabled by an access rule even when the user or company has not opted in. - ## Company opt-in To change the current company's opt-in, pass `scope: "company"`. The current Reflag context must include a `company.id`. @@ -186,9 +126,9 @@ setOptIn(flag.key, { }); ``` -User and company opt-ins are independent. 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. `isOptedIn` remains `true` while either scope is opted in. +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 also does not necessarily disable the flag: an access rule may independently enable it for the current context. +Cancelling every opt-in does not necessarily disable the flag: an access rule may independently enable it for the current context. ## Access behavior @@ -204,14 +144,10 @@ Disabling end-user opt-in stops new opt-ins and makes existing memberships inact ## Waiting for an update -`setOptIn()` returns a promise. It resolves after the latest flag state has been applied, the requested membership change has been confirmed, and components using `useOptInFlags()` have been notified. React schedules the resulting render normally, so it may not have committed when the promise resolves. +`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. -You can await it when your UI needs a pending or error state: - -```tsx -await setOptIn(flag.key, { optedIn: true }); -``` +The quick-start example awaits this promise to disable the button while the update is pending and report errors. -### Next steps +## Next steps Learn how to manage additional access with [Access rules](../product-handbook/feature-rollouts/feature-targeting-rules.md). From 0ae6389bfa834f9a2644c228cf81e5e13095b84a Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 4 Aug 2026 21:32:07 +0200 Subject: [PATCH 13/13] docs: reorganize opt-in guidance --- guides/self-opt-in.md | 42 ++++++++++++++++++------------------------ 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/guides/self-opt-in.md b/guides/self-opt-in.md index a2310b6..57d419c 100644 --- a/guides/self-opt-in.md +++ b/guides/self-opt-in.md @@ -80,30 +80,10 @@ function OptInFlagCard({ flag }: { flag: OptInFlag }) { } ``` -To use Suspense by default, enable `suspense` on `ReflagProvider` or `ReflagBootstrappedProvider` instead of passing `{ suspense: true }` to the hook. +`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. `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`. -### Managing loading state without `` - -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. - -Check the hook's `isLoading` value before rendering an empty state: - -```tsx -const { flags: optInFlags, isLoading } = useOptInFlags({ suspense: false }); - -if (isLoading) { - return ; -} - -if (optInFlags.length === 0) { - return

No opt-in flags are available.

; -} -``` - -With a regular `ReflagProvider`, opt-in metadata arrives as part of the normal flags request, so `useOptInFlags().isLoading` remains `false`. Use `useIsLoading()` or the provider's `loadingComponent` for the normal initial loading state. - ## Configure a flag for opt-in 1. Open a non-secret flag in Reflag. @@ -142,11 +122,25 @@ A flag's access setting determines how opt-in membership affects evaluation: Disabling end-user opt-in stops new opt-ins and makes existing memberships inactive, but it does not delete them. Re-enabling opt-in reactivates those memberships unless access is set to **No one**. -## Waiting for an update +## Managing loading state with `` and without `` -`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. +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. + +Check the hook's `isLoading` value before rendering an empty state: + +```tsx +const { flags: optInFlags, isLoading } = useOptInFlags({ suspense: false }); + +if (isLoading) { + return ; +} -The quick-start example awaits this promise to disable the button while the update is pending and report errors. +if (optInFlags.length === 0) { + return

No opt-in flags are available.

; +} +``` + +With a regular `ReflagProvider`, opt-in metadata arrives as part of the normal flags request, so `useOptInFlags().isLoading` remains `false`. Use `useIsLoading()` or the provider's `loadingComponent` for the normal initial loading state. ## Next steps