Vue 3 Composition API Flag Integration
Vue 3’s Composition API gives you the same reactive primitives that power React hooks for feature flag state, but the wiring is different: provide/inject replaces React context, and ref/computed replace useState/useMemo. This guide walks through each layer — from SDK installation to SSR-safe hydration — so you end up with a single composable tree your entire Vue app can consume without prop drilling.
Problem. Vue’s reactivity proxy tracks the original object reference, not the data inside it. When an async SDK resolves and you write flags.value = sdkPayload, Vue wraps the new object in a fresh proxy and discards the one that computed properties already subscribed to. The result is stale flag values that never update, even though flags.value looks correct in DevTools.
Prerequisites
@openfeature/web-sdk≥ 1.x installednamespace.service.featureconvention
provide(); every component calls inject() to read from the same reactive source.Step-by-Step Integration
Step 1. Install and wire the OpenFeature Web SDK provider for Vue
npm install @openfeature/web-sdk
# Plus your provider, e.g.:
# npm install @openfeature/flagd-web-provider
// src/flags/provider.ts
import { OpenFeature } from '@openfeature/web-sdk';
import { FlagdWebProvider } from '@openfeature/flagd-web-provider';
export async function registerFlagProvider(): Promise<void> {
await OpenFeature.setProviderAndWait(
new FlagdWebProvider({ host: 'flags.example.internal', port: 8013, tls: true })
);
}
The OpenFeature provider architecture separates evaluation logic from delivery, so swapping vendors later only requires changing the provider line, not any composable code. Call registerFlagProvider() before creating the Vue app.
Step 2. Build the useFeatureFlags composable with reactive provide/inject
// src/flags/useFeatureFlags.ts
import { ref, provide, inject, computed, readonly } from 'vue';
import type { InjectionKey, Ref, ComputedRef } from 'vue';
import { OpenFeature } from '@openfeature/web-sdk';
export interface FlagContext {
flags: Ref<Record<string, boolean | string>>;
isReady: Ref<boolean>;
getFlag: (key: string, fallback: boolean | string) => ComputedRef<boolean | string>;
}
export const FLAG_KEY: InjectionKey<FlagContext> = Symbol('feature_flags');
export function useFeatureFlags() {
const flags = ref<Record<string, boolean | string>>({});
const isReady = ref(false);
const initFlags = async (): Promise<void> => {
try {
const client = OpenFeature.getClient();
const payload: Record<string, boolean | string> = {
'web.dashboard.new-nav': await client.getBooleanValue('web.dashboard.new-nav', false),
'checkout.payments.express-pay': await client.getBooleanValue('checkout.payments.express-pay', false),
'web.nav.beta-header': await client.getStringValue('web.nav.beta-header', 'control'),
};
// Spread into the existing reactive object — never replace flags.value outright
Object.assign(flags.value, payload);
isReady.value = true;
} catch (err) {
console.error('[FLAG_SDK] Initialization failed — defaults active.', err);
isReady.value = true;
}
};
const getFlag = (key: string, fallback: boolean | string): ComputedRef<boolean | string> =>
computed(() => (isReady.value ? (flags.value[key] ?? fallback) : fallback));
provide(FLAG_KEY, { flags, isReady, getFlag });
return { initFlags, flags, isReady };
}
export function useFlagContext(): Readonly<FlagContext> {
const ctx = inject(FLAG_KEY);
if (!ctx) {
throw new Error('[FLAG_CTX] No flag context found. Call useFeatureFlags() at the app root.');
}
return readonly(ctx) as Readonly<FlagContext>;
}
provide(FLAG_KEY, ...) registers the reactive context once for the entire component tree. Because flags is a single ref object whose properties are mutated in place (never replaced), all inject consumers share the same Vue proxy and receive updates automatically.
Step 3. Write the per-flag useFlag composable
// src/flags/useFlag.ts
import { computed } from 'vue';
import type { ComputedRef } from 'vue';
import { useFlagContext } from './useFeatureFlags';
export function useFlag(key: string, fallback: boolean): ComputedRef<boolean>;
export function useFlag(key: string, fallback: string): ComputedRef<string>;
export function useFlag(
key: string,
fallback: boolean | string
): ComputedRef<boolean | string> {
const { flags, isReady } = useFlagContext();
return computed(() => (isReady.value ? (flags.value[key] ?? fallback) : fallback));
}
Usage inside any component:
useFlag delegates to useFlagContext(), which calls inject() — a Vue compile-time check ensures this only runs inside setup() or another composable, preventing accidental misuse outside the component tree.
Step 4. Initialize at the app root to avoid race conditions on SPA navigation
// src/main.ts
import { createApp } from 'vue';
import App from './App.vue';
import { registerFlagProvider } from './flags/provider';
import { useFeatureFlags } from './flags/useFeatureFlags';
import { createRouter, createWebHistory } from 'vue-router';
import routes from './router/routes';
const router = createRouter({ history: createWebHistory(), routes });
async function bootstrap(): Promise<void> {
// 1. Register the OpenFeature provider before any component mounts
await registerFlagProvider();
const app = createApp(App);
// 2. useFeatureFlags must be called in app.runWithContext so provide() binds to this app
const { initFlags } = app.runWithContext(() => useFeatureFlags());
// 3. Resolve flags before mounting so every route sees settled state from the start
await initFlags();
app.use(router);
app.mount('#app');
}
bootstrap();
Initializing inside onMounted is the most frequent source of race conditions: the component renders with defaults, the SDK resolves, and then a route change unmounts the component before the update cycle completes. Module-level initialization in main.ts ensures one settled state that outlives every route. Follow client SDK initialization best practices for timeout budgets and fallback sequencing.
Step 5. Guard SSR boundaries with hydration-safe defaults
// src/flags/ssrSnapshot.ts
export interface FlagSnapshot {
'web.dashboard.new-nav': boolean;
'checkout.payments.express-pay': boolean;
'web.nav.beta-header': string;
'web.theme.dark-mode': boolean;
'web.user.beta-access': boolean;
}
/**
* Returns the flag values the server used during rendering.
* These MUST match what the server injected into the HTML payload.
* Pass the result as initialFlags when constructing the flag context on the client.
*/
export function getServerSafeSnapshot(
serverInjectedState: Partial<FlagSnapshot>
): FlagSnapshot {
return {
'web.dashboard.new-nav': false,
'checkout.payments.express-pay': false,
'web.nav.beta-header': 'control',
'web.theme.dark-mode': false,
'web.user.beta-access': false,
...serverInjectedState,
};
}
// In your SSR entry (entry-server.ts), pass the snapshot to initFlags
const serverFlags = getServerSafeSnapshot(ctx.ssrFlagPayload ?? {});
Object.assign(flags.value, serverFlags);
isReady.value = true;
The snapshot ensures the client hydrates with the exact same flag values the server rendered — eliminating the visible layout shift that hydration flicker causes when the client re-evaluates flags asynchronously after mount. See server-side rendering flag consistency for the full SSR flag pipeline.
Verification
Add a Vitest + Vue Test Utils test to confirm the composable resolves and the computed value updates:
// tests/useFlag.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createApp, defineComponent } from 'vue';
import { mount } from '@vue/test-utils';
import { useFeatureFlags, useFlagContext } from '@/flags/useFeatureFlags';
import { useFlag } from '@/flags/useFlag';
// Stub OpenFeature client
vi.mock('@openfeature/web-sdk', () => ({
OpenFeature: {
getClient: () => ({
getBooleanValue: async (key: string, fallback: boolean) =>
key === 'web.dashboard.new-nav' ? true : fallback,
getStringValue: async (_key: string, fallback: string) => fallback,
}),
},
}));
describe('useFlag', () => {
it('returns resolved flag value after initFlags()', async () => {
const RootComponent = defineComponent({
setup() {
const { initFlags } = useFeatureFlags();
return { initFlags };
},
template: '<slot />',
});
const ConsumerComponent = defineComponent({
setup() {
const showNewNav = useFlag('web.dashboard.new-nav', false);
return { showNewNav };
},
template: '<div :data-flag="showNewNav" />',
});
const app = createApp(RootComponent);
const wrapper = mount(app, { attachTo: document.body });
// initFlags resolves the SDK
await wrapper.vm.initFlags();
await wrapper.vm.$nextTick();
const consumer = mount(ConsumerComponent, { global: { plugins: [app] } });
expect(consumer.attributes('data-flag')).toBe('true');
});
});
Gotchas & Edge Cases
- Never replace
flags.valuewith a new object reference. Writingflags.value = sdkPayloadbreaks the proxy chain thatcomputedproperties subscribed to. Always useObject.assign(flags.value, sdkPayload)to mutate properties in place, or spread withflags.value = { ...flags.value, ...sdkPayload }only inside areactive()wrapper where Vue can re-track the new reference. - Initialize at the app root (
main.ts), not inonMounted. Component lifecycle hooks run after the first render, so a flag-gated layout shift is baked in if you initialize there. Worse,onMountedre-runs on keep-alive component reactivation, which can re-initialize the SDK mid-session and briefly flash defaults. getServerSafeSnapshot()must return the same values the server used when rendering. If your server resolvesweb.dashboard.new-nav: truebut the client snapshot defaults tofalse, Vue’s hydration algorithm detects a DOM mismatch and re-renders the entire subtree — re-introducing the flicker you were avoiding.
Troubleshooting & FAQ
Why does my computed flag value not update after the SDK resolves?
The SDK resolved and wrote flags.value = newPayload, replacing the reactive object reference. Vue’s proxy tracked the original object; the new one is untracked. Fix: always merge into the existing ref with Object.assign(flags.value, newPayload). If you need to watch for external SDK change events (provider re-fetches), subscribe to the OpenFeature event bus inside initFlags and call Object.assign on each event:
import { OpenFeature, ProviderEvents } from '@openfeature/web-sdk';
OpenFeature.addHandler(ProviderEvents.ConfigurationChanged, (event) => {
Object.assign(flags.value, event.flagsChanged ?? {});
});
The flag resets on every SPA route change — how do I fix it?
The useFeatureFlags() call (and its provide()) is inside a component’s setup() function instead of at the app root. When the route changes, the component unmounts and the context is destroyed. Move the useFeatureFlags() call into main.ts using app.runWithContext() so the context lives at the application level and survives every navigation.
How do I use the composable with Pinia or Vuex?
Call useFlagContext() inside a Pinia action to read the injected flags ref, then copy only the values you need into store state:
// stores/checkout.ts (Pinia)
import { defineStore } from 'pinia';
import { useFlagContext } from '@/flags/useFeatureFlags';
export const useCheckoutStore = defineStore('checkout', () => {
const { flags, isReady } = useFlagContext();
const expressPayEnabled = computed(
() => isReady.value && (flags.value['checkout.payments.express-pay'] as boolean)
);
return { expressPayEnabled };
});
Avoid storing the entire flags ref inside Pinia state — two reactive sources tracking the same data create sync drift when one updates before the other.