From 381433b07c2d8675bbfb7c866dd321fb9082d9ff Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 11 Feb 2026 09:22:16 +0000 Subject: [PATCH] feat: add useExperiment hook with backend-aligned two-trait convention Auto-enrolls users by setting `exp_{key}_variant` trait on render, and reports outcomes via `exp_{key}_converted` trait to match the backend experiment analytics endpoint. Co-Authored-By: Claude Opus 4.6 --- react.d.ts | 6 ++ react.tsx | 37 ++++++++++++ test/react.test.tsx | 144 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 185 insertions(+), 2 deletions(-) diff --git a/react.d.ts b/react.d.ts index faa36b2..418c21e 100644 --- a/react.d.ts +++ b/react.d.ts @@ -30,3 +30,9 @@ export declare function useFlags< export declare const useFlagsmith: , T extends string = string>() => IFlagsmith; export declare const useFlagsmithLoading: () => LoadingState | undefined; +export declare function useExperiment(flagKey: string): { + enabled: boolean; + value: IFlagsmithValue; + success: () => Promise; + failure: () => Promise; +}; diff --git a/react.tsx b/react.tsx index eb2d0c3..14cd8a7 100644 --- a/react.tsx +++ b/react.tsx @@ -198,3 +198,40 @@ export function useFlagsmith, T extends s return context as unknown as IFlagsmith } + +export function useExperiment(flagKey: string) { + const flagsmith = useFlagsmith() + const flags = useFlags([flagKey]) + const enrolledRef = useRef(false) + const reportedRef = useRef(false) + + // Auto-enroll: set variant trait when user first sees the experiment + useEffect(() => { + if (!enrolledRef.current && flags[flagKey]?.enabled) { + enrolledRef.current = true + const variant = flagsmith.getValue(flagKey) + flagsmith.setTrait(`exp_${flagKey}_variant`, String(variant)) + } + }, [flagsmith, flagKey, flags]) + + const reportOutcome = useCallback( + (result: boolean) => { + if (reportedRef.current) { + return Promise.resolve() + } + reportedRef.current = true + return flagsmith.setTrait(`exp_${flagKey}_converted`, result) + }, + [flagsmith, flagKey], + ) + + const success = useCallback(() => reportOutcome(true), [reportOutcome]) + const failure = useCallback(() => reportOutcome(false), [reportOutcome]) + + return { + enabled: flags[flagKey]?.enabled ?? false, + value: flags[flagKey]?.value ?? null, + success, + failure, + } +} diff --git a/test/react.test.tsx b/test/react.test.tsx index cc996e0..35b64ff 100644 --- a/test/react.test.tsx +++ b/test/react.test.tsx @@ -1,6 +1,6 @@ import React, { FC } from 'react' -import { render, screen, waitFor } from '@testing-library/react' -import { FlagsmithProvider, useFlags, useFlagsmithLoading } from '../lib/flagsmith/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { FlagsmithProvider, useFlags, useFlagsmithLoading, useExperiment } from '../lib/flagsmith/react' import { defaultState, delay, @@ -315,3 +315,143 @@ it('should not throw unhandled promise rejection when server returns 500 error', expect(onError).toHaveBeenCalledTimes(1) window.removeEventListener('unhandledrejection', unhandledRejectionHandler) }) + +describe('useExperiment', () => { + const ExperimentPage: FC<{ flagKey: string }> = ({ flagKey }) => { + const { enabled, value, success, failure } = useExperiment(flagKey) + return ( + <> +
{String(enabled)}
+
{String(value)}
+