A thin reactive layer over the LaunchDarkly JS Client SDK for Ember.js applications.
What it gives you:
- Reactive flags — powered by Glimmer's
TrackedMap, flag changes automatically re-render templates and recompute getters. {{variation}}helper — read flags directly in templates.- Test helpers —
setupLaunchDarkly,withVariation, andwithInitStatusfor deterministic tests. - Structured results —
initialize()andidentify()return result objects instead of throwing.
What it does not do:
- Hide the SDK — the full
LDClientis accessible viacontext.clientwhenever you need it. - Re-implement SDK features —
track(),variationDetail(),flush(),close()are thin passthrough. - Bundle the SDK —
launchdarkly-js-client-sdkis a peer dependency. You control the version.
| Addon version | Ember version | |
|---|---|---|
| v6.0 | >= v4.12 | README |
| v5.0 | >= v4.12 | UPGRADING |
| v4.0 | >= v4.12 | README |
| v3.0 | >= v3.28 and v4.4 | README |
| v2.0 | >= v3.17 | README |
| <= v1.0 | <= v3.16 | README |
- Ember Launch Darkly
# Install the addon and the SDK peer dependency
ember install ember-launch-darkly
npm install launchdarkly-js-client-sdk@^3Or with pnpm:
pnpm add ember-launch-darkly launchdarkly-js-client-sdk@^3Configure from config/environment.js:
module.exports = function (environment) {
let ENV = {
launchDarkly: {
clientSideId: "your-client-side-id", // required for remote mode
mode: environment === "production" ? "remote" : "local",
localFlags: {
"new-pricing-plan": false,
"apply-discount": false,
},
},
};
return ENV;
};| Option | Default | Description |
|---|---|---|
clientSideId |
— | Your LaunchDarkly client-side ID. Required for remote mode. |
mode |
'local' |
'local' or 'remote'. Local mode uses localFlags instead of the LD service. |
localFlags |
{} |
Initial flag values for local mode (also used as bootstrap values when bootstrap: 'localFlags'). |
timeout |
5 |
Seconds to wait for waitForInitialization() before treating init as failed. |
streamingFlags |
false |
Subscribe to real-time flag updates. See streaming section. |
bootstrap |
— | Bootstrap configuration. Set to 'localFlags' to use localFlags as bootstrap values. |
onStatusChange |
— | (newStatus, previousStatus) => void callback for status transitions. |
onError |
— | (error) => void callback for runtime SDK errors. |
sendEventsOnlyForVariation |
true |
See note below. |
| Other | — | Any other LDOptions are passed through to the SDK. |
When false, events are sent for every feature flag when client.allFlags() is called. This can be misleading — a flag may appear as "requested" in the LD dashboard even though your code doesn't use it. We default this to true to avoid that. You can set it to false if you want those events.
Initialize LaunchDarkly early in your app's lifecycle — typically in the application route:
// app/routes/application.js
import Route from "@ember/routing/route";
import { initialize } from "ember-launch-darkly";
import config from "my-app/config/environment";
export default class ApplicationRoute extends Route {
async beforeModel() {
let { clientSideId, ...options } = config.launchDarkly;
let user = { key: "aa0ceb", anonymous: true };
const { isOk, error, context } = await initialize(
clientSideId,
user,
options,
);
if (!isOk) {
console.warn("LaunchDarkly failed to initialize:", error);
// Option A: Continue with default/bootstrap flag values.
// context is still usable — flags will update if the SDK recovers.
// Option B: Tear down and fall back to local mode.
await context.destroy({ force: true });
await initialize(clientSideId, user, {
mode: "local",
localFlags: { "my-flag": false },
});
}
}
}initialize() never throws. It returns an InitializeResult:
interface InitializeResult {
isOk: boolean; // true for success or local mode
status: "initialized" | "failed" | "local";
error?: unknown; // the error, if failed
context: Context; // the reactive flag context
}Switch the user context after initialization (e.g. after login):
import { identify } from "ember-launch-darkly";
const { isOk, error } = await identify({
key: session.user.id,
firstName: session.user.firstName,
email: session.user.email,
});
if (!isOk) {
console.error("identify failed:", error);
}Multivariate flags:
import Component from "@glimmer/component";
import { variation } from "ember-launch-darkly";
export default class PriceDisplay extends Component {
get price() {
if (variation("new-pricing-plan")) {
return 99.0;
}
return 199.0;
}
}For strict mode templates, import the helper explicitly:
import { variation } from "ember-launch-darkly/helpers";
<template>
{{#if (variation "show-banner" defaultValue=false)}}
<Banner />
{{/if}}
</template>Or use the SDK function directly (positional args only, no defaultValue=):
import { variation } from "ember-launch-darkly";
<template>
{{variation "flag-key"}}
</template>Flag values are reactive (TrackedMap-backed). When a flag changes, code that reads it re-renders automatically.
The context exposes reactive properties for initialization state:
const { context } = await initialize(clientSideId, user, options);
context.initStatus; // 'initialized' | 'failed' | 'local'
context.initSucceeded; // boolean
context.initError; // the error from waitForInitialization(), if anyThese are @tracked, so templates that read them auto-update. When the SDK
recovers after a failed initialization (e.g. reconnects), initStatus
automatically transitions to 'initialized'.
You can listen for transitions:
await initialize(clientSideId, user, {
onStatusChange(newStatus, previousStatus) {
if (newStatus === "initialized" && previousStatus === "failed") {
console.log("LaunchDarkly recovered!");
}
},
});Runtime errors (stream disconnections, network failures) are captured:
const { context } = await initialize(clientSideId, user, {
onError(error) {
Sentry.captureException(error);
},
});
// Most recent error — reactive
context.lastError; // Error | undefinedThese methods delegate directly to the underlying LDClient. They are no-ops
in local mode:
// Evaluation reasons (requires evaluationReasons: true in options)
const detail = context.variationDetail("my-flag");
// { value: true, variationIndex: 0, reason: { kind: 'FALLTHROUGH' } }
// Track custom events for Experimentation
context.track("purchase", { item: "shirt" }, 42.0);
// Flush pending events (e.g. before page navigation)
await context.flush();
// Shut down the client and release resources
await context.close();
// Force-close without waiting for flush (useful when endpoint is unresponsive)
await context.close({ force: true });
// Shut down AND remove the context from global state, allowing re-initialization
await context.destroy();
await context.destroy({ force: true }); // force variant
// Direct access to the LDClient for anything else
context.client?.on("change:my-flag", () => {
/* ... */
});When mode: 'local', flags come from config/environment.js instead of the
LaunchDarkly service. The context is available at window.__LD__ for console
debugging:
// config/environment.js
launchDarkly: {
mode: 'local',
localFlags: {
'apply-discount': true,
'pricing-plan': 'plan-a',
},
}// Browser console
window.__LD__.get("pricing-plan"); // 'plan-a'
window.__LD__.set("pricing-plan", "plan-b"); // change it
window.__LD__.enable("apply-discount"); // shorthand for set(key, true)
window.__LD__.disable("apply-discount"); // shorthand for set(key, false)
window.__LD__.allFlags; // { 'apply-discount': true, ... }
window.__LD__.user; // { key: 'local-mode-no-user-specified' }
// Persist to localStorage (survives refresh)
window.__LD__.persist();
window.__LD__.resetPersistence();Subscribe to real-time flag updates via the streamingFlags configuration:
// Stream all flags
streamingFlags: true
// Stream all except specific flags
streamingFlags: { allExcept: ['apply-discount', 'new-login'] }
// Stream specific flags only
streamingFlags: { 'apply-discount': true }
// Disable streaming (default)
streamingFlags: falseReal-time updates use the EventSource API. Ensure your target browsers support it or include a polyfill.
If CSP is enabled, add LaunchDarkly to connect-src:
// config/environment.js
contentSecurityPolicy: {
'connect-src': ['https://*.launchdarkly.com'],
},setupLaunchDarkly resets all flags to false and provides withVariation:
import { module, test } from "qunit";
import { visit, click } from "@ember/test-helpers";
import { setupApplicationTest } from "ember-qunit";
import { setupLaunchDarkly } from "ember-launch-darkly/test-support";
module("Acceptance | Pricing", function (hooks) {
setupApplicationTest(hooks);
setupLaunchDarkly(hooks);
test("shows new pricing when flag is on", async function (assert) {
await this.withVariation("new-pricing-plan", "plan-a");
await visit("/pricing");
assert.dom(".price").hasText("£ 99");
});
});// variation-test.gts
import { module, test } from "qunit";
import { setupRenderingTest } from "ember-qunit";
import { render } from "@ember/test-helpers";
import { setupLaunchDarkly } from "ember-launch-darkly/test-support";
import { variation } from "ember-launch-darkly/helpers";
import type { LDTestContext } from "ember-launch-darkly/test-support";
module("Integration | Helper | variation", function (hooks) {
setupRenderingTest(hooks);
setupLaunchDarkly(hooks);
test("shows discount badge", async function (this: LDTestContext, assert) {
await this.withVariation?.("apply-discount", true);
await render(
<template>
{{#if (variation "apply-discount")}}
<span data-test-discount-badge>Discount!</span>
{{/if}}
</template>,
);
assert.dom("[data-test-discount-badge]").exists();
});
});Use withInitStatus to simulate degraded states:
test("shows error banner when LD fails", async function (assert) {
await this.withInitStatus("failed", new Error("timeout"));
await render(hbs`<StatusBanner />`);
assert.dom("[data-test-error-banner]").exists();
});If you prefer not to use this addon, here's how to get reactive feature flags with the LaunchDarkly SDK and Ember's tracking system directly:
// app/services/feature-flags.ts
import Service from "@ember/service";
import { tracked } from "@glimmer/tracking";
import { TrackedMap } from "tracked-built-ins";
import * as LDClient from "launchdarkly-js-client-sdk";
export default class FeatureFlagsService extends Service {
flags = new TrackedMap<string, unknown>();
@tracked isReady = false;
@tracked error?: unknown;
client?: LDClient.LDClient;
async initialize(clientSideId: string, context: LDClient.LDContext) {
this.client = LDClient.initialize(clientSideId, context, {
sendEventsOnlyForVariation: true,
});
try {
await this.client.waitForInitialization(5);
this.isReady = true;
} catch (e) {
this.error = e;
// Continue with default values
}
// Populate initial flags
const allFlags = this.client.allFlags();
for (const [key, value] of Object.entries(allFlags)) {
this.flags.set(key, value);
}
// Subscribe to changes for reactive updates
this.client.on("change", (changes) => {
for (const [key, { current }] of Object.entries(changes)) {
this.flags.set(key, current);
}
});
}
variation(key: string, defaultValue?: unknown): unknown {
if (this.flags.has(key)) {
return this.flags.get(key);
}
return defaultValue;
}
async identify(context: LDClient.LDContext) {
const flags = await this.client?.identify(context);
if (flags) {
this.flags.clear();
for (const [key, value] of Object.entries(flags)) {
this.flags.set(key, value);
}
}
}
willDestroy() {
super.willDestroy();
this.client?.close();
}
}// app/routes/application.ts
import Route from "@ember/routing/route";
import { service } from "@ember/service";
import type FeatureFlagsService from "my-app/services/feature-flags";
import config from "my-app/config/environment";
export default class ApplicationRoute extends Route {
@service declare featureFlags: FeatureFlagsService;
async beforeModel() {
await this.featureFlags.initialize(config.launchDarkly.clientSideId, {
kind: "user",
key: "anonymous",
anonymous: true,
});
}
}The core idea is TrackedMap — it gives you Glimmer reactivity for flag values.
That's essentially what this addon does, plus convention-based config, test
helpers, streaming subscriptions, and the {{variation}} template helper.
- From v5.x to v6.x — See UPGRADING_TO_v6.x.md
- From v1.x to v2.x — See UPGRADING_TO_v2.x.md
Made with ❤️ by The Ember Launch Darkly Team