react-feature-flagkit
v1.0.1
Published
Lightweight, type-safe feature flag system for React and React Native. Zero runtime deps, offline-first, remote config sync, and A/B test primitives.
Maintainers
Readme
react-feature-flagkit
A lightweight, type-safe feature flag system for React and React Native.
Zero runtime dependencies. Offline-first. Remote config sync. A/B test primitives built in.
Features
| Feature | Details |
|---|---|
| Zero runtime deps | Only peer dep is React ≥ 18 |
| Type-safe | Full TypeScript generics — useFlag<string>('theme', 'light') |
| Offline-first | Pluggable storage adapter; restores from cache before network |
| Remote sync | Fetch + optional polling from any URL |
| Targeting | 11 operators: eq, neq, in, nin, gt, gte, lt, lte, contains, startsWith, endsWith |
| Gradual rollout | Deterministic FNV-1a hash — same user always in/out |
| A/B variants | Weighted variant assignment, stable per user |
| Local overrides | setOverride() for DevTools, Storybook, and testing |
| Auto-expiry | expiresAt timestamp — flag disables itself |
| React Native | Works with AsyncStorage/MMKV via StorageAdapter interface |
Installation
npm install react-feature-flagkit
# or
yarn add react-feature-flagkit
# or
pnpm add react-feature-flagkitPeer dependency: React ≥ 18.0.0
Quick Start
import { FeatureFlagProvider, useFlag, FeatureFlag } from 'react-feature-flagkit';
const flags = [
{ key: 'new-dashboard', defaultValue: false, enabled: true },
{ key: 'ui-theme', defaultValue: 'light', enabled: true },
];
function App() {
return (
<FeatureFlagProvider flags={flags} user={{ id: 'user-123' }}>
<MyApp />
</FeatureFlagProvider>
);
}
// Hook usage
function Header() {
const theme = useFlag('ui-theme', 'light'); // → 'light' | 'dark'
const isDashboardEnabled = useFlag('new-dashboard', false); // → boolean
return <header className={theme}>{...}</header>;
}
// Component usage
function Home() {
return (
<FeatureFlag name="new-dashboard" fallback={<OldDashboard />}>
<NewDashboard />
</FeatureFlag>
);
}API Reference
<FeatureFlagProvider>
Wraps your app. All configuration lives here.
<FeatureFlagProvider
flags={staticFlags} // FlagDefinition[] — static flags
remote={remoteConfig} // RemoteConfig — fetch from URL
user={{ id: 'u1', attributes: { plan: 'pro', country: 'IN' } }}
overrides={{ 'debug-mode': true }} // local overrides (highest priority)
storage={storageAdapter} // StorageAdapter — for persistence
fallbackFlags={fallbackFlags} // used when remote fails and no cache
debug={true} // logs evaluations to console
>
{children}
</FeatureFlagProvider>Hooks
useFlag(key, defaultValue) → T
Evaluate a flag. Returns the resolved value.
const isEnabled = useFlag('new-feature', false);
const theme = useFlag<'light' | 'dark'>('theme', 'light');
const limit = useFlag('rate-limit', 100);useFlagResult(key, defaultValue) → EvaluationResult<T>
Like useFlag but returns the full result including reason and variant key.
const { value, reason, isEnabled, variantKey } = useFlagResult('ab-test', 'control');
// reason: 'VARIANT' | 'TARGETING' | 'ROLLOUT' | 'DEFAULT' | 'OVERRIDE' | 'EXPIRED' | 'ENABLED'useFlags(keysMap) → Record<K, V>
Evaluate multiple flags in one call.
const { 'new-ui': newUi, 'dark-mode': darkMode } = useFlags({
'new-ui': false,
'dark-mode': false,
'api-version': 'v1',
});useFlagVariant(key) → string | null
Get the active A/B variant key, or null if no variant is active.
const variant = useFlagVariant('checkout-experiment');
// → 'control' | 'treatment-a' | 'treatment-b' | nulluseFeatureFlagOverride(key)
Programmatic overrides — great for DevTools and Storybook.
const { enable, disable, set, remove } = useFeatureFlagOverride('new-feature');
<button onClick={enable}>Enable</button>
<button onClick={disable}>Disable</button>
<button onClick={() => set('dark')}>Set Dark</button>useFlagStatus()
Loading / error / freshness state.
const { isLoading, error, lastUpdated } = useFlagStatus();
if (isLoading) return <Spinner />;useSetUser()
Update user context at runtime (e.g. after login).
const setUser = useSetUser();
// After login:
setUser({ id: user.id, attributes: { plan: user.plan, country: user.country } });useRefreshFlags()
Manually trigger a remote refresh.
const refresh = useRefreshFlags();
<button onClick={refresh}>Reload Flags</button><FeatureFlag> Component
// Basic
<FeatureFlag name="new-checkout">
<NewCheckout />
</FeatureFlag>
// With fallback
<FeatureFlag name="new-checkout" fallback={<OldCheckout />}>
<NewCheckout />
</FeatureFlag>
// Match specific value (for config flags)
<FeatureFlag name="ui-theme" whenValue="dark">
<DarkModeStyles />
</FeatureFlag>withFeatureFlag(Component, options) HOC
const ProtectedPage = withFeatureFlag(AdminPage, {
flagKey: 'admin-access',
fallback: AccessDeniedPage,
});Flag Definition
interface FlagDefinition<T = FlagValue> {
key: string; // unique identifier
defaultValue: T; // value when disabled / not matched
enabled: boolean; // master on/off switch
// Optional
description?: string;
expiresAt?: number; // Unix ms — auto-disables after this time
rollout?: {
percentage: number; // 0-100, deterministic per userId
seed?: string; // custom hash seed (defaults to flag key)
};
targeting?: TargetingRule[]; // ALL rules must match
variants?: Variant<T>[]; // A/B test variants
metadata?: Record<string, unknown>;
}Recipes
Remote config with polling
<FeatureFlagProvider
remote={{
url: 'https://your-api.com/flags',
pollingInterval: 60_000, // refresh every 60s
headers: { Authorization: `Bearer ${token}` },
timeout: 5000,
onError: (err) => console.error('Flag fetch failed', err),
}}
>
{children}
</FeatureFlagProvider>Gradual rollout (10% → 50% → 100%)
const flags = [{
key: 'new-checkout',
defaultValue: false,
enabled: true,
rollout: { percentage: 10 }, // bump to 50, then 100 over time
}];Targeting: show only to Pro users in India
const flags = [{
key: 'beta-export',
defaultValue: false,
enabled: true,
targeting: [
{ attribute: 'plan', operator: 'eq', value: 'pro' },
{ attribute: 'country', operator: 'eq', value: 'IN' },
],
}];A/B test with weighted variants
const flags = [{
key: 'checkout-button',
defaultValue: 'blue',
enabled: true,
variants: [
{ key: 'control', value: 'blue', weight: 50 },
{ key: 'treatment-a', value: 'green', weight: 30 },
{ key: 'treatment-b', value: 'red', weight: 20 },
],
}];
function CheckoutButton() {
const color = useFlag('checkout-button', 'blue');
const variant = useFlagVariant('checkout-button');
// Track exposure: analytics.track('flag_exposure', { variant })
return <Button color={color} />;
}Persist flags with localStorage (web)
<FeatureFlagProvider
remote={{ url: '/api/flags' }}
storage={{
getItem: (k) => localStorage.getItem(k),
setItem: (k, v) => localStorage.setItem(k, v),
removeItem: (k) => localStorage.removeItem(k),
}}
>React Native with AsyncStorage
import AsyncStorage from '@react-native-async-storage/async-storage';
<FeatureFlagProvider
remote={{ url: 'https://your-api.com/flags' }}
storage={AsyncStorage} // AsyncStorage matches StorageAdapter interface
>React Native with MMKV (faster)
import { MMKV } from 'react-native-mmkv';
const storage = new MMKV();
<FeatureFlagProvider
storage={{
getItem: (k) => storage.getString(k) ?? null,
setItem: (k, v) => storage.set(k, v),
removeItem: (k) => storage.delete(k),
}}
>Auto-expiring launch flag
const flags = [{
key: 'black-friday-banner',
defaultValue: false,
enabled: true,
expiresAt: new Date('2025-11-30T23:59:59Z').getTime(),
}];
// After Nov 30, evaluates to { reason: 'EXPIRED', isEnabled: false }Override in tests
import { FeatureFlagProvider } from 'react-feature-flagkit';
function renderWithFlags(ui, overrides = {}) {
return render(
<FeatureFlagProvider flags={[]} overrides={overrides}>
{ui}
</FeatureFlagProvider>
);
}
test('shows new dashboard when flag enabled', () => {
renderWithFlags(<Home />, { 'new-dashboard': true });
expect(screen.getByTestId('new-dashboard')).toBeInTheDocument();
});Evaluation Priority
Flags are evaluated in this order (highest wins):
1. Local override → reason: 'OVERRIDE'
2. Flag not found → reason: 'DEFAULT'
3. Flag expired → reason: 'EXPIRED'
4. Flag disabled → reason: 'DEFAULT'
5. A/B variants → reason: 'VARIANT'
6. Targeting rules → reason: 'TARGETING' | 'DEFAULT'
7. Rollout % → reason: 'ROLLOUT' | 'ROLLOUT_EXCLUDED'
8. Simple enabled → reason: 'ENABLED'Remote API Response Format
The default transform expects an array of FlagDefinition objects:
[
{ "key": "new-dashboard", "defaultValue": false, "enabled": true },
{ "key": "ui-theme", "defaultValue": "light", "enabled": true, "rollout": { "percentage": 50 } }
]For a different shape, provide a transform function:
remote={{
url: '/api/flags',
transform: (raw) => raw.data.featureFlags.map(f => ({
key: f.name,
defaultValue: f.fallback,
enabled: f.active,
})),
}}TypeScript
All APIs are fully typed. Generics flow through:
const theme = useFlag<'light' | 'dark'>('ui-theme', 'light');
// theme: 'light' | 'dark' ✓
const { value } = useFlagResult<number>('rate-limit', 100);
// value: number ✓Contributing
git clone https://github.com/your-username/react-feature-flagkit
cd react-feature-flagkit
npm install
node run-tests.js # run test suite (zero deps, uses node:test)License
MIT
