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 stateisLoading and error signals surface strategy and parse failures.
  • User targetingidentifyUser() 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);

| Export | Kind | Description | | ------------------------------ | -------- | --------------------------------------------------------------------------------------------------------- | | provideRemoteConfig(feature) | function | EnvironmentProviders — registers the service and wires the chosen strategy | | withConfigCat(options) | function | ConfigCat strategy. Options: sdkKey, pollIntervalSeconds (default 60), logLevel (default 'error') | | withLocalStorage() | function | localStorage strategy with cross-tab sync and devtools global | | withInMemory(overrides?) | function | In-memory strategy. Accepts a plain object or a Signal | | REMOTE_CONFIG_STORAGE_KEY | const | 'presence.remoteConfig' | | UntypedRemoteConfig | service | Raw signal access — config, isLoading, error, get(key), identifyUser(user) | | zodRemoteConfig(schema) | function | Returns an injection helper for a typed accessor | | RemoteConfigUser | type | { id, email?, custom? } | | ConfigCatOptions | type | Options for withConfigCat | | RemoteConfigError | type | { kind: 'strategy' \| 'parse', cause } | | RemoteConfigFeature | type | Return type of the with* helpers | | TypedRemoteConfig<T> | type | Shape of the object zodRemoteConfig’s accessor returns |

| Member | Type | Description | | ----------------------- | ----------------------------------- | -------------------------------------------------- | | rc.someKey | Signal<T[key]> | Direct signal for each schema key | | rc.get('key') | Signal<T[key]> | Method form | | rc.config | Signal<z.infer<T>> | All parsed values as one object | | rc.isLoading | Signal<boolean> | Delegates to the active strategy | | rc.error | Signal<RemoteConfigError \| null> | Strategy error merged with Zod parse error | | rc.identifyUser(user) | void | Passes 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