Skip to content

@presencelearning/remote-config

Signal-based remote configuration and feature flags. Wraps ConfigCat (or localStorage, or an in-memory map) behind one provider, and gives you a Zod-typed accessor so flags are checked at compile time.

Current version: 0.2.2. Single entry point — everything is imported from @presencelearning/remote-config.

  • Signal-based — every flag is an Angular signal, so templates react without subscriptions.
  • Typed via Zod — declare a schema once, get typed signals per key.
  • Swappable strategies — ConfigCat in production, localStorage for local dev, in-memory for tests and Storybook.
  • Loading and error state — isLoading and error signals surface strategy and parse failures.
  • User targeting — identifyUser() feeds identity to ConfigCat’s targeting rules.
  • SSR-safe — all browser access is guarded by isPlatformBrowser.
Terminal window
npm install @presencelearning/remote-config

Both heavyweight dependencies are optional peers — install only what you use:

Terminal window
npm install @configcat/sdk # required for withConfigCat
npm install zod # required for zodRemoteConfig
  • @angular/core >=18.0.0 <22.0.0
  • @angular/common >=18.0.0 <22.0.0
  • @configcat/sdk >=1.0.2 <2 (optional)
  • zod >=4.0.0 (optional)
app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRemoteConfig, withConfigCat } from '@presencelearning/remote-config';
export const appConfig: ApplicationConfig = {
providers: [provideRemoteConfig(withConfigCat({ sdkKey: environment.configCatKey }))],
};
remote-config.ts
import { zodRemoteConfig } from '@presencelearning/remote-config';
import { z } from 'zod';
const AppSchema = z.object({
showNewDashboard: z.boolean().default(false),
maxUploadMb: z.number().default(10),
maintenanceBanner: z.string().default(''),
});
export const injectAppConfig = zodRemoteConfig(AppSchema);

All schema fields must have .default() or be .optional(). If a required field has no default, DI throws at injection time with a descriptive error naming the offending fields.

Call zodRemoteConfig() once at module level; call the returned accessor inside an injection context.

@Component({
template: `
@if (rc.showNewDashboard()) {
<app-new-dashboard />
}
<p>Max upload: {{ rc.maxUploadMb() }} MB</p>
`,
})
export class DashboardComponent {
protected readonly rc = injectAppConfig();
}
provideRemoteConfig(
withConfigCat({
sdkKey: 'your-sdk-key',
pollIntervalSeconds: 60, // default
logLevel: 'error', // 'warn' | 'error' (default)
})
);

Auto-polls, refreshes on ConfigCat’s configChanged hook, and disposes the client on destroy. Strategy failures land in the error signal as { kind: 'strategy', cause }.

ConfigCat emits anonymous/default values immediately after SDK init, then a second set of values once identifyUser() has run. Call identifyUser as early as possible (right after the OIDC token resolves), and gate on isLoading wherever a flag must never briefly show its anonymous value.

provideRemoteConfig(withLocalStorage());

Reads a JSON object from the presence.remoteConfig key (exported as REMOTE_CONFIG_STORAGE_KEY). isLoading is always false and error always null. It listens for cross-tab storage events and installs a devtools global:

window.__remoteConfig.getConfig();
window.__remoteConfig.setConfig((current) => ({ ...current, showNewDashboard: true }));

setConfig takes an updater function, not an object.

// Plain object
provideRemoteConfig(withInMemory({ showNewDashboard: true }));
// Writable signal — useful in Storybook or feature harnesses
const flags = signal<Record<string, unknown>>({ showNewDashboard: false });
provideRemoteConfig(withInMemory(flags));
// Later:
flags.set({ showNewDashboard: true });

When you don’t want a schema, inject the raw service directly:

import { UntypedRemoteConfig } from '@presencelearning/remote-config';
const rc = inject(UntypedRemoteConfig);
rc.config; // Signal<Record<string, unknown>>
rc.config(); // Record<string, unknown> — all raw values
rc.get('someFlag'); // Signal<unknown>
rc.get('someFlag')(); // unknown — current value
rc.get<boolean>('flag')(); // boolean — cast via generic
rc.isLoading(); // boolean
rc.error(); // RemoteConfigError | null
rc.identifyUser(user);

get, config, isLoading, error, and identifyUser do not get a direct signal property on the typed accessor, because they collide with the accessor’s own members. Reach them through the method form instead:

rc.get('config')(); // ✓
rc.config; // ✗ — this is the accessor's own config signal

Renaming the flag remotely is the better fix.

TestBed.configureTestingModule({
providers: [provideRemoteConfig(withInMemory({ showNewDashboard: true }))],
});
const rc = TestBed.runInInjectionContext(() => injectAppConfig());
expect(rc.showNewDashboard()).toBe(true);
ExportKindDescription
provideRemoteConfig(feature)functionEnvironmentProviders — registers the service and wires the chosen strategy
withConfigCat(options)functionConfigCat strategy. Options: sdkKey, pollIntervalSeconds (default 60), logLevel (default 'error')
withLocalStorage()functionlocalStorage strategy with cross-tab sync and devtools global
withInMemory(overrides?)functionIn-memory strategy. Accepts a plain object or a Signal
REMOTE_CONFIG_STORAGE_KEYconst'presence.remoteConfig'
UntypedRemoteConfigserviceRaw signal access — config, isLoading, error, get(key), identifyUser(user)
zodRemoteConfig(schema)functionReturns an injection helper for a typed accessor
RemoteConfigUsertype{ id, email?, custom? }
ConfigCatOptionstypeOptions for withConfigCat
RemoteConfigErrortype{ kind: 'strategy' | 'parse', cause }
RemoteConfigFeaturetypeReturn type of the with* helpers
TypedRemoteConfig<T>typeShape of the object zodRemoteConfig’s accessor returns
MemberTypeDescription
rc.someKeySignal<T[key]>Direct signal for each schema key
rc.get('key')Signal<T[key]>Method form
rc.configSignal<z.infer<T>>All parsed values as one object
rc.isLoadingSignal<boolean>Delegates to the active strategy
rc.errorSignal<RemoteConfigError | null>Strategy error merged with Zod parse error
rc.identifyUser(user)voidPasses user to the strategy for personalised flags

On a Zod parse failure the accessor falls back to the schema defaults and exposes { kind: 'parse', cause } — flags degrade rather than throw.

A standalone Angular app under packages/remote-config/example exercises the full public API — env-based strategy selection, the typed accessor, UntypedRemoteConfig, loading and error states, and feature-gating patterns. It runs on withLocalStorage() out of the box.

Terminal window
cd packages/remote-config/example
npm run setup # builds + packs the package, then installs it
npm run dev

Source: packages/remote-config