featurectl
v0.4.1
Published
Remote control for your live app. Feature flags, remote config, kill switches — one line of code.
Downloads
42
Maintainers
Readme
featurectl
Remote control for your live app. Feature flags, remote config, kill switches -- one line of code.
Table of Contents
Installation
npm install featurectlyarn add featurectlpnpm add featurectlIf you plan to use React hooks, make sure you have React 18 or later installed. It is an optional peer dependency.
Quick Start
import { initFeaturectl, flag } from "featurectl";
await initFeaturectl({ apiKey: "your-api-key" });
if (await flag("new-checkout")) {
showNewCheckout();
}That is all you need. initFeaturectl connects to the featurectl API, fetches your flags and configs, and keeps them in sync via real-time streaming. The flag function returns a boolean you can use anywhere in your code.
Configuration
Both initFeaturectl and createFeaturectl accept a FeaturectlOptions object with the following properties:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| apiKey | string | required | Your project's SDK key from the featurectl dashboard. |
| apiUrl | string | "https://api.featurectl.app" | Base URL of the featurectl API. Override this for self-hosted instances or local development. |
| pollInterval | number | 30000 | Interval in milliseconds between background polls. Set to 0 to disable polling entirely. |
| streaming | boolean | true | Enable real-time updates via Server-Sent Events (SSE). |
| defaultContext | Record<string, string> | undefined | Key-value pairs merged into every evaluation request. Useful for passing a user ID or environment. |
| onRefresh | (data: EvaluateResponse) => void | undefined | Called every time flag and config data is refreshed from the server. |
| onError | (error: unknown) => void | undefined | Called when a fetch to the API fails. If not provided, errors are silently swallowed and the SDK falls back to cached values. |
Full example
import { initFeaturectl, flag, config } from "featurectl";
await initFeaturectl({
apiKey: "your-api-key",
apiUrl: "https://api.featurectl.app",
pollInterval: 60000,
streaming: true,
defaultContext: {
userId: "user_123",
environment: "production",
},
onRefresh: (data) => {
console.log("Flags refreshed:", Object.keys(data.flags).length);
},
onError: (error) => {
console.error("featurectl fetch failed:", error);
},
});Streaming vs Polling
By default, featurectl uses both streaming and polling to keep your flags up to date:
- Streaming (
streaming: true) opens a persistent SSE connection to the server. When you change a flag in the dashboard, the SDK receives an update event and refreshes immediately. If the connection drops, it automatically reconnects after 5 seconds. - Polling (
pollInterval: 30000) acts as a safety net. Every 30 seconds (with a random jitter of +/-20% to avoid thundering herd), the SDK fetches the latest data from the server. This ensures your app stays in sync even if the streaming connection is interrupted.
You can use them independently or together:
| Setup | Config | Best for |
|-------|--------|----------|
| Both (default) | streaming: true, pollInterval: 30000 | Production apps that need fast updates with a reliable fallback. |
| Streaming only | streaming: true, pollInterval: 0 | Apps that need instant updates and can tolerate brief gaps if SSE drops. |
| Polling only | streaming: false, pollInterval: 30000 | Environments where SSE is not supported (some proxies, serverless). |
| Manual only | streaming: false, pollInterval: 0 | Full control. Call refresh() yourself when needed. |
Error Handling
When a fetch to the API fails, the SDK does not throw. Instead, it falls back to the last cached values and calls your onError callback if one is provided:
await initFeaturectl({
apiKey: "your-api-key",
onError: (error) => {
// Send to your error tracking service
Sentry.captureException(error);
},
});If the very first fetch fails (during initialization), the SDK still resolves and marks itself as ready. Flags will return false and configs will return their default values until a successful fetch occurs.
Client API
The client is the core of the SDK. It manages the connection to the featurectl API, caches flag and config data, and keeps everything in sync.
Creating a Client
import { createFeaturectl } from "featurectl";
const client = createFeaturectl({
apiKey: "your-api-key",
});createFeaturectl(options: FeaturectlOptions): FeaturectlClient creates a new client instance. It immediately starts fetching data from the API and sets up streaming and polling based on your configuration. You can also use new FeaturectlClient(options) directly.
Async vs Sync Methods
The client provides two sets of methods for reading flags and configs:
- Async methods (
flag,flagDetails,config) automatically wait for the client to be ready before returning a value. When called with acontextparameter,flagandflagDetailsmake a fresh server call to evaluate the flag with that context. Without context, they read from the local cache. - Sync methods (
getFlag,getConfig,getSnapshot) read directly from the in-memory cache and return immediately. They do not wait for the client to be ready. If called before the first fetch completes, flags returnfalseand configs return their default values.
Use async methods when you need the most accurate evaluation (especially with per-request context). Use sync methods when you need synchronous access, such as inside React render functions or hot paths where you cannot await.
Async Methods
flag(key, context?)
flag(key: string, context?: Record<string, string>): Promise<boolean>Returns whether a feature flag is enabled.
- Without
context: waits for the client to be ready, then reads from the cache. - With
context: waits for the client to be ready, then makes a fresh server call with the provided context merged withdefaultContext. This is useful for user-specific or request-specific evaluation (e.g., percentage rollouts).
const enabled = await client.flag("new-checkout");
const enabledForUser = await client.flag("new-checkout", {
userId: "user_123",
});If the flag is not found or the server call fails, returns false.
flagDetails(key, context?)
flagDetails(key: string, context?: Record<string, string>): Promise<FlagEvaluation>Returns the full evaluation result for a feature flag, including the reason for the evaluation.
- Without
context: reads from the cache. - With
context: makes a fresh server call.
const details = await client.flagDetails("new-checkout");
// { key: "new-checkout", enabled: true, reason: "rule_match" }
const userDetails = await client.flagDetails("new-checkout", {
userId: "user_123",
});If the flag is not found, returns { key, enabled: false, reason: "not_found" }. If the server call fails, returns { key, enabled: false, reason: "error" }.
config<T>(key, defaultValue)
config<T>(key: string, defaultValue: T): Promise<T>Returns the value of a remote config entry, coerced to the appropriate type. If the config is not found or parsing fails, returns defaultValue.
The SDK automatically coerces values based on the valueType stored in the server:
"number"values are converted withNumber()"boolean"values are converted fromtrueor"true""json"values are parsed withJSON.parse()- All other types are returned as-is
const maxItems = await client.config<number>("max-items", 10);
const theme = await client.config<string>("theme", "light");
const features = await client.config<string[]>("enabled-features", []);refresh()
refresh(): Promise<void>Forces an immediate fetch of all flags and configs from the server. The cache is replaced with the new data, all subscribers are notified, and the onRefresh callback is called if provided.
You do not normally need to call this. Streaming and polling handle updates automatically. Use it when you need a guaranteed fresh state, for example after a user action that you know will change flag values.
await client.refresh();waitUntilReady()
waitUntilReady(): Promise<void>Returns a promise that resolves once the client has completed its first fetch. If the client is already ready, it resolves immediately.
const client = createFeaturectl({ apiKey: "your-api-key" });
await client.waitUntilReady();
// Cache is now populated, sync methods are safe to useSync Methods
getFlag(key)
getFlag(key: string): booleanReads a flag value directly from the cache. Returns false if the flag is not found or the client has not completed its first fetch.
const enabled = client.getFlag("new-checkout");getConfig<T>(key, defaultValue)
getConfig<T>(key: string, defaultValue: T): TReads a config value directly from the cache with the same type coercion as the async config() method. Returns defaultValue if the config is not found or parsing fails.
const maxItems = client.getConfig<number>("max-items", 10);getSnapshot()
getSnapshot(): {
flags: Record<string, FlagEvaluation>;
configs: Record<string, ConfigEvaluation>;
version: number;
}Returns the entire cache as a plain object. The version field increments each time the cache is updated. This is used internally by the React hooks to detect changes via useSyncExternalStore.
const snapshot = client.getSnapshot();
console.log(snapshot.flags["new-checkout"]?.enabled);
console.log(snapshot.configs["max-items"]?.value);
console.log("Cache version:", snapshot.version);isReady()
isReady(): booleanReturns true if the client has completed its first fetch, false otherwise.
if (client.isReady()) {
const enabled = client.getFlag("new-checkout");
}subscribe(listener)
subscribe(listener: () => void): () => voidRegisters a callback that is called whenever the cache is updated (after a successful fetch, poll, or streaming event). Returns an unsubscribe function.
const unsubscribe = client.subscribe(() => {
console.log("Cache updated, version:", client.getSnapshot().version);
});
// Later, stop listening
unsubscribe();destroy()
destroy(): voidStops all background activity: cancels polling timers and closes the streaming connection. Call this when the client is no longer needed to avoid resource leaks.
client.destroy();Singleton API
The singleton API provides a convenient way to use featurectl without passing a client instance around. It creates a single global instance that you initialize once at startup and then access from anywhere in your codebase.
Initialization
import { initFeaturectl } from "featurectl";
await initFeaturectl({ apiKey: "your-api-key" });initFeaturectl(options: FeaturectlOptions): Promise<FeaturectlClient> creates a global FeaturectlClient and waits until its first fetch completes. It accepts the same options as createFeaturectl (see Configuration). The returned promise resolves with the underlying client instance, which you can ignore in most cases.
You must call initFeaturectl before using any of the convenience functions below. If you call flag, config, flagDetails, refresh, or destroy before initialization, they throw an error:
Error: featurectl not initialized. Call initFeaturectl() first.Convenience Functions
All convenience functions delegate to the global client instance created by initFeaturectl. They have the same signatures and behavior as their client counterparts.
flag(key, context?)
import { flag } from "featurectl";
const enabled = await flag("new-checkout");
const enabledForUser = await flag("new-checkout", { userId: "user_123" });Returns Promise<boolean>. See client.flag() for full details on context behavior.
config<T>(key, defaultValue)
import { config } from "featurectl";
const maxItems = await config<number>("max-items", 10);
const theme = await config<string>("theme", "light");Returns Promise<T>. See client.config() for type coercion details.
flagDetails(key, context?)
import { flagDetails } from "featurectl";
const details = await flagDetails("new-checkout");
// { key: "new-checkout", enabled: true, reason: "rule_match" }Returns Promise<FlagEvaluation>. See client.flagDetails() for the full return type.
refresh()
import { refresh } from "featurectl";
await refresh();Forces an immediate fetch of all flags and configs. Returns Promise<void>.
destroy()
import { destroy } from "featurectl";
destroy();Stops all background activity (polling, streaming) and clears the global instance. After calling destroy, you must call initFeaturectl again before using any other singleton function.
Complete Example
// app-init.ts -- call once at startup
import { initFeaturectl } from "featurectl";
await initFeaturectl({
apiKey: "your-api-key",
defaultContext: { environment: "production" },
onError: (error) => console.error("featurectl error:", error),
});
// anywhere-else.ts -- use from any file
import { flag, config } from "featurectl";
export async function getCheckoutPage() {
const useNewCheckout = await flag("new-checkout");
const maxCartItems = await config<number>("max-cart-items", 50);
return {
variant: useNewCheckout ? "v2" : "v1",
maxCartItems,
};
}Singleton vs Client
| | Singleton | Client |
|---|---|---|
| Setup | initFeaturectl(options) once | createFeaturectl(options) per instance |
| Access | Import flag, config, etc. from "featurectl" | Pass the client instance around |
| Number of instances | One global instance | As many as you need |
| Best for | Most apps with a single API key | Multi-tenant apps, testing, or when you need multiple clients with different configs |
| React | Not used with React hooks (use the client directly) | Pass to FeaturectlProvider |
For most applications, the singleton API is the simplest choice. Use the client directly when you need more than one instance, want explicit dependency injection, or are building a React app with the provider pattern.
React
The React integration provides hooks that subscribe to flag and config changes and automatically re-render your components when values update. It is available as a separate import path.
import { FeaturectlProvider, useFlag, useConfig } from "featurectl/react";React 18 or later is required. It is listed as an optional peer dependency, so you need to have it installed in your project already.
Setup
Create a client with createFeaturectl and pass it to FeaturectlProvider at the root of your component tree:
import { createFeaturectl } from "featurectl";
import { FeaturectlProvider } from "featurectl/react";
const client = createFeaturectl({ apiKey: "your-api-key" });
function App() {
return (
<FeaturectlProvider client={client}>
<YourApp />
</FeaturectlProvider>
);
}FeaturectlProvider accepts a single client prop (a FeaturectlClient instance) and makes it available to all hooks below via React context.
Hooks
useFlag(key)
useFlag(key: string): booleanReturns whether a feature flag is enabled. The component re-renders whenever the flag value changes.
Returns false if the flag is not found or the client has not yet completed its first fetch.
import { useFlag } from "featurectl/react";
function Checkout() {
const useNewCheckout = useFlag("new-checkout");
return useNewCheckout ? <NewCheckout /> : <OldCheckout />;
}useConfig(key, defaultValue)
useConfig<T>(key: string, defaultValue: T): TReturns the value of a remote config entry. The component re-renders whenever the config value changes.
Returns defaultValue if the config is not found or the client has not yet completed its first fetch.
import { useConfig } from "featurectl/react";
function ProductList() {
const maxItems = useConfig<number>("max-items", 10);
return <Grid items={products.slice(0, maxItems)} />;
}useFlagDetails(key)
useFlagDetails(key: string): FlagEvaluationReturns the full evaluation result for a flag, including the reason. The return type is FlagEvaluation:
{
key: string;
enabled: boolean;
reason: string;
}This hook uses a ref-based cache internally to prevent unnecessary re-renders. The component only re-renders when the key, enabled, or reason fields actually change, not on every cache update.
Returns { key, enabled: false, reason: "not_found" } if the flag does not exist.
import { useFlagDetails } from "featurectl/react";
function FeatureDebug({ flagKey }: { flagKey: string }) {
const details = useFlagDetails(flagKey);
return (
<div>
<p>Flag: {details.key}</p>
<p>Enabled: {details.enabled ? "Yes" : "No"}</p>
<p>Reason: {details.reason}</p>
</div>
);
}useFeaturectl()
useFeaturectl(): FeaturectlClientReturns the underlying FeaturectlClient instance from context. Use this when you need direct access to the client, for example to call refresh() or destroy().
Throws an error if called outside of a FeaturectlProvider.
import { useFeaturectl } from "featurectl/react";
function RefreshButton() {
const client = useFeaturectl();
return <button onClick={() => client.refresh()}>Refresh flags</button>;
}Complete Example
// client.ts
import { createFeaturectl } from "featurectl";
export const featurectlClient = createFeaturectl({
apiKey: "your-api-key",
});
// App.tsx
import { FeaturectlProvider } from "featurectl/react";
import { featurectlClient } from "./client";
function App() {
return (
<FeaturectlProvider client={featurectlClient}>
<Dashboard />
</FeaturectlProvider>
);
}
// Dashboard.tsx
import { useFlag, useConfig } from "featurectl/react";
function Dashboard() {
const showBetaBanner = useFlag("beta-banner");
const maxWidgets = useConfig<number>("max-widgets", 5);
return (
<div>
{showBetaBanner && <BetaBanner />}
<WidgetGrid max={maxWidgets} />
</div>
);
}Next.js and SSR
All hooks are built on React's useSyncExternalStore, which supports server-side rendering out of the box. During server rendering, the hooks return safe default values since the client cannot connect to the featurectl API on the server:
| Hook | Server snapshot |
|------|----------------|
| useFlag(key) | false |
| useConfig(key, defaultValue) | defaultValue |
| useFlagDetails(key) | { key, enabled: false, reason: "not_found" } |
This means your server-rendered HTML will always use the fallback values. Once the app hydrates on the client and the featurectl client completes its first fetch, the hooks will re-render with the real values.
Next.js App Router
In the Next.js App Router, all components are server components by default. Since FeaturectlProvider uses React context and hooks, it must be placed in a client component. Create a wrapper with the "use client" directive:
// providers.tsx
"use client";
import { FeaturectlProvider } from "featurectl/react";
import { createFeaturectl } from "featurectl";
const client = createFeaturectl({ apiKey: "your-api-key" });
export function Providers({ children }: { children: React.ReactNode }) {
return (
<FeaturectlProvider client={client}>
{children}
</FeaturectlProvider>
);
}
// layout.tsx
import { Providers } from "./providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}Any component that uses featurectl hooks (useFlag, useConfig, etc.) must also be a client component or be rendered inside one.
Types
The SDK exports the following TypeScript types from featurectl:
import type {
FeaturectlOptions,
FlagEvaluation,
ConfigEvaluation,
EvaluateResponse,
} from "featurectl";FeaturectlOptions
Configuration object passed to initFeaturectl or createFeaturectl. See Configuration for a full description of each field.
| Field | Type |
|-------|------|
| apiKey | string |
| apiUrl | string \| undefined |
| pollInterval | number \| undefined |
| streaming | boolean \| undefined |
| defaultContext | Record<string, string> \| undefined |
| onRefresh | (data: EvaluateResponse) => void \| undefined |
| onError | (error: unknown) => void \| undefined |
FlagEvaluation
Returned by flagDetails, useFlagDetails, and present in the flags map of EvaluateResponse.
| Field | Type | Description |
|-------|------|-------------|
| key | string | The flag key. |
| enabled | boolean | Whether the flag is on or off. |
| reason | string | Why the flag resolved to this value (e.g. "rule_match", "not_found", "error"). |
ConfigEvaluation
Present in the configs map of EvaluateResponse.
| Field | Type | Description |
|-------|------|-------------|
| key | string | The config key. |
| value | unknown | The raw value stored on the server. |
| valueType | string | The type hint used for coercion ("number", "boolean", "json", or "string"). |
EvaluateResponse
The full response shape returned from the featurectl API. Passed to the onRefresh callback and available via getSnapshot().
| Field | Type | Description |
|-------|------|-------------|
| flags | Record<string, FlagEvaluation> | All flag evaluations keyed by flag name. |
| configs | Record<string, ConfigEvaluation> | All config evaluations keyed by config name. |
Patterns
Real-world examples showing common use cases.
Feature Toggle
Show or hide a feature based on a flag. Toggle it from the dashboard with zero downtime.
import { flag } from "featurectl";
if (await flag("new-checkout")) {
showNewCheckout();
}In React:
import { useFlag } from "featurectl/react";
function Checkout() {
const newCheckout = useFlag("new-checkout");
return newCheckout ? <NewCheckout /> : <OldCheckout />;
}Remote Configuration
Change values in production without redeploying. Useful for limits, URLs, copy, and other settings you want to adjust on the fly.
import { config } from "featurectl";
const maxRetries = await config("max-retries", 1);
const apiUrl = await config("api-url", "https://fallback.example.com");Kill Switch
Disable a broken feature from anywhere, even from your phone at 3am. Flip the flag in the dashboard and the change takes effect within seconds.
import { flag } from "featurectl";
if (!(await flag("payments-enabled"))) {
return showMaintenancePage();
}
processPayment();Percentage Rollout
Roll out a feature to a percentage of users by passing a context with a user identifier. The server uses this to determine consistent assignment.
import { flag } from "featurectl";
if (await flag("new-checkout", { userId })) {
return renderNewCheckout();
}
return renderOldCheckout();You can also set the user context globally so every evaluation includes it automatically:
import { initFeaturectl, flag } from "featurectl";
await initFeaturectl({
apiKey: "your-api-key",
defaultContext: { userId: currentUser.id },
});
// No need to pass context on every call
if (await flag("new-checkout")) {
return renderNewCheckout();
}Cleanup
The SDK runs background tasks (polling timers, SSE connections) to keep your flags in sync. When you no longer need the client, call destroy() to stop all background activity and free resources.
Single-page applications
If your app creates a client on mount, make sure to destroy it on unmount to prevent memory leaks:
// Vanilla JS
const client = createFeaturectl({ apiKey: "your-api-key" });
// When the app is shutting down
client.destroy();React
When using the client with FeaturectlProvider, clean up in a useEffect return:
import { createFeaturectl } from "featurectl";
import { FeaturectlProvider } from "featurectl/react";
import { useEffect, useRef } from "react";
function App() {
const clientRef = useRef(createFeaturectl({ apiKey: "your-api-key" }));
useEffect(() => {
return () => clientRef.current.destroy();
}, []);
return (
<FeaturectlProvider client={clientRef.current}>
<YourApp />
</FeaturectlProvider>
);
}Singleton
For the singleton API, call the exported destroy function:
import { destroy } from "featurectl";
destroy();After calling destroy, you must call initFeaturectl again before using any other singleton function.
