Skip to content

Feature Flag System

DoCurious includes a built-in feature flag system with three layers: a Zustand store for flag management, a React hook for evaluation, and a declarative gate component for conditional rendering.

Architecture

Store: useFeatureFlagStore

Defined in src/store/useFeatureFlagStore.ts. Manages feature flag definitions, CRUD operations, and flag evaluation. Persists to localStorage via Zustand's persist middleware (key: docurious-feature-flags).

State

typescript
interface FeatureFlagState {
  flags: FeatureFlag[]
  isLoading: boolean
  error: string | null
}

Actions

typescript
interface FeatureFlagActions {
  fetchFlags: () => void
  createFlag: (data: CreateFeatureFlagData) => void
  updateFlag: (id: string, data: UpdateFeatureFlagData) => void
  deleteFlag: (id: string) => void
  toggleFlag: (id: string) => void
  evaluateFlag: (flagKey: string, context: FlagEvaluationContext) => FlagEvaluationResult
  clearError: () => void
}

Selectors

typescript
selectFlags         // All flags
selectActiveFlags   // Only active flags
selectFlagByKey(key) // Single flag by key (returns selector function)

Flag Types

Flags support three types:

TypeDescriptionValue
booleanSimple on/offtrue or false
multivariateA/B test with weighted variantsVariant value (string)
jsonArbitrary JSON payloadJSON object

Targeting Rules

Each flag can have multiple targeting rules (AND logic -- all must pass):

Rule TypeOperatorsExample
rolein, not_inShow only to platform_admin
user_idin, not_inTarget specific users
account_tierin, not_inTarget tier_2_full only
percentagepercentage_lteRoll out to 25% of users
environmentin, not_inOnly in development

Percentage rollouts use deterministic hashing (userId:flagKey) to ensure consistent assignment.

Seed Flags

The store ships with four seed flags:

KeyNameTypeStatusTargeting
wallet_v2Wallet V2booleanactiveRole: platform_admin only
beta_challengesBeta Challengesbooleanactive25% rollout
dark_modeDark ModebooleaninactiveNone (disabled)
new_onboardingNew Onboarding FlowmultivariateactiveExclude admin/staff; 50/50 control vs streamlined

Hook: useFeatureFlag

Defined in src/hooks/useFeatureFlag.ts. Bridges the auth store (user context) with the feature flag store (evaluation).

Usage

typescript
import { useFeatureFlag } from '../hooks/useFeatureFlag'

function MyComponent() {
  const { enabled, value, isLoading } = useFeatureFlag('wallet_v2')

  if (isLoading) return <Spinner />
  if (!enabled) return <OldWallet />
  return <NewWallet />
}

Return Type

typescript
interface UseFeatureFlagReturn {
  enabled: boolean   // Whether the flag is enabled for the current user
  value: unknown     // Resolved value (boolean, variant string, or JSON)
  isLoading: boolean // Whether the flag store is still loading
}

How It Works

The hook:

  1. Reads the current user from useAuthStore
  2. Constructs a FlagEvaluationContext with userId, role, accountTier, and environment
  3. Calls evaluateFlag on the feature flag store
  4. Memoizes the result to avoid unnecessary recalculations
typescript
const context: FlagEvaluationContext = {
  userId: user.id,
  role: user.role,
  accountTier: user.accountTier,
  environment: import.meta.env.MODE ?? 'development',
}

If no user is logged in, the hook returns { enabled: false, value: false }.

Component: FeatureGate

Defined in src/components/common/FeatureGate.tsx. Declarative component for conditional rendering based on feature flags.

Usage

tsx
import { FeatureGate } from '../components/common'

// Basic usage — render children only if flag is enabled
<FeatureGate flag="wallet_v2">
  <NewWalletDashboard />
</FeatureGate>

// With fallback — render alternative when flag is disabled
<FeatureGate flag="wallet_v2" fallback={<OldWalletDashboard />}>
  <NewWalletDashboard />
</FeatureGate>

Props

typescript
interface FeatureGateProps {
  flag: string              // The flag key to evaluate
  children: React.ReactNode // Content when flag is enabled
  fallback?: React.ReactNode // Content when flag is disabled (default: null)
}

Admin Management

Feature flags are managed at /admin/feature-flags (requires platform_admin role). The admin UI allows:

  • Viewing all flags with status, type, and targeting rules
  • Creating new flags with key, name, description, type, and targeting rules
  • Editing existing flags (key uniqueness is enforced)
  • Toggling flags between active/inactive
  • Deleting flags

Defining a New Flag

Via the Admin UI

  1. Navigate to /admin/feature-flags
  2. Click "Create Flag"
  3. Fill in: key (unique, snake_case), name, description, type
  4. Add targeting rules if needed
  5. Set status to active or inactive

Programmatically

typescript
import { useFeatureFlagStore } from '../store'

const createFlag = useFeatureFlagStore.getState().createFlag

createFlag({
  key: 'my_new_feature',
  name: 'My New Feature',
  description: 'Description of what this flag controls',
  type: 'boolean',
  status: 'inactive',
  defaultValue: false,
  targetingRules: [
    { id: 'rule-1', type: 'role', operator: 'in', values: ['platform_admin'] },
  ],
  tags: ['beta'],
})

Evaluation Logic

When evaluateFlag is called:

  1. If the flag is not found or status !== 'active', return { enabled: false, value: defaultValue }
  2. If no targeting rules exist, return { enabled: true, value: defaultValue }
  3. All targeting rules must pass (AND logic)
  4. For multivariate flags, a deterministic hash selects a variant based on cumulative weights
  5. For JSON flags, the jsonValue payload is returned
  6. For boolean flags, the defaultValue is returned

Backend Integration

Currently, flags are persisted to localStorage. When a real backend is available:

  1. Replace the persist middleware with API calls in fetchFlags
  2. Replace createFlag/updateFlag/deleteFlag with API mutations
  3. The evaluateFlag logic can remain client-side or move to a server-side evaluation endpoint

See Also

DoCurious Platform Documentation