preact-signal-feature-query-param
preact-signal-feature-query-param 1.0.0

Feature flags driven by a URL query param, persisted in localStorage and exposed as a typed Preact signal.

Contents

Contents

preact-signal-feature-query-param

Feature flags driven by a URL query param, persisted in localStorage and exposed as a typed Preact signal — validated, mapped, zero re-render.

?language=fr, ?image-upload=true, ?resolution=4k — the oldest feature-flag mechanism there is, and the one QA, support and demos actually reach for. Written by hand it is always the same sprawl: read the param, decide whether the value is one you accept, remember it so the next page view doesn't lose it, coerce the string into something the app can use, and give the rest of the codebase a reactive handle on it. Per flag. And never quite the same way twice.

This library is that sprawl, done once. One call declares a flag — its accepted values, its default, what it maps to — and what comes back is a ReadonlySignal you bind straight into JSX, plus the few methods a flag actually needs: init, update, reset.

URL → storage → default validated at every entry typed end to end zero re-render SSR-safe
Nothing resolves until init() runs

A freshly created flag holds its defaultValue and nothing else — the URL has not been read yet. That is deliberate: the query string is known at different moments in different apps (immediately, after the router settles, per request on a server), and a library that guesses gets it wrong somewhere. Call init() once, as early as the URL is known, and let anything that must not run before it await feature.afterInit(). See Init & timing.

Getting started

Install#

sh
npm i @dmytromykhailiuk/preact-signal-feature-query-param
Requirements

@preact/signals ≥ 2 and preact ≥ 10.11 as peers — the ones your app already has. Persistence and the afterInit promise come from @dmytromykhailiuk/typed-local-storage and @dmytromykhailiuk/preact-signal-utils, which install with the package. Ships ESM and CJS with type declarations for both.

Quick start#

Declare every flag once, at module scope. Nothing here reads the URL yet — this file is safe to import from anywhere, including a server bundle.

tsfeatures.ts — the single place flags live
import { createFeatureQueryParam } from "@dmytromykhailiuk/preact-signal-feature-query-param";

export const language = createFeatureQueryParam("language", {
  availableValues: ["en", "pt", "fr"],
  defaultValue: "en",
});

export const imageUpload = createFeatureQueryParam<"true" | "false", boolean>("image-upload", {
  availableValues: ["true", "false"],
  defaultValue: "false",
  valueMapper: (value) => value === "true",
});

Resolve them once, as early as the URL is known — for most apps that is the first line of the entry point.

tsmain.ts
import { imageUpload, language } from "./features";

language.init();
imageUpload.init();

From there a flag is just a signal. Bind it — never unwrap .value while rendering, or the component starts re-rendering on every change:

tsxanywhere in the app
import { Show } from "@preact/signals/utils";
import { imageUpload, language } from "./features";

function Toolbar() {
  return (
    <>
      <span>{language.signal$}</span>
      <Show when={imageUpload.signal$}>
        <UploadButton />
      </Show>
    </>
  );
}

Open ?language=fr&image-upload=true once and both flags stick: the values are persisted, so they survive the next reload with a clean address bar. That is the whole point of the resolution order.

How a value is resolved#

init() looks in three places, in order, and takes the first valid value it finds:

source wins when persisted?
the query param it is present and valid yes — an explicit, shareable choice
localStorage the URL carried nothing usable already there
defaultValue nothing else was found no
ts
const language = createFeatureQueryParam("language", {
  availableValues: ["en", "pt", "fr"],
  defaultValue: "en",
});

// /app?language=fr      → "fr"  — from the URL, and written to storage
// /app  (storage: "fr") → "fr"  — the URL is silent, the choice survives
// /app  (storage empty) → "en"  — the default, and nothing is written
// /app?language=klingon → falls through: storage, then the default

An invalid value never lands anywhere. A param outside availableValues, or one the validator turns down, is not an error and not a fallback either — resolution simply carries on to the next source, and storage is left as it was.

What gets persisted#

Only values somebody chose: a valid query param, or an update() at runtime. The default is never written.

Why the default stays out of storage

A flag nobody touched should keep following the app. If init() wrote defaultValue to storage on first visit, every returning user would be pinned to the default of the release they happened to load first — and shipping a new default would reach nobody. Leaving the key absent keeps "unset" meaning unset.

A persisted value that stops being valid is dropped on the next init() — a variant you removed from availableValues, a value a stricter validator now rejects. Storage is shared, hand-editable state that outlives deployments; the flag heals itself rather than carrying it into the session.

Starting clean#

init({ withReset: true }) skips the persisted value and drops it — "start from the default unless this URL says otherwise". The URL still wins:

ts
// storage: "fr"
language.init({ withReset: true });                  // → "en", storage cleared
language.init({ withReset: true, search: "?language=pt" }); // → "pt", stored

It is the right call for entry points that must not inherit an earlier session — a logout redirect, a fresh onboarding flow, a kiosk that starts every visitor from the same place.

Reference

createFeatureQueryParam#

ts
function createFeatureQueryParam<T extends string, K = T>(
  queryParamName: string,
  options: {
    defaultValue: T;
    availableValues?: ReadonlyArray<T>;
    validator?: (value: T) => boolean;
    valueMapper?: (value: T) => K;
    persist?: boolean;
    storageKey?: string;
  },
): FeatureQueryParam<T, K>;

T is the raw value — always a string, because that is what a URL and localStorage can carry. K is what the app consumes: T itself, or whatever valueMapper turns it into.

Options#

defaultValueT  · required

What the flag holds before init(), and whenever neither the URL nor storage produced a valid value. Restored by reset().

It must pass validation itself — a default outside its own availableValues leaves the flag with no reachable fallback, so it throws at creation.

availableValuesReadonlyArray<T>

The accepted set. Anything outside it is rejected wherever it comes from — URL, storage or update(). It is also what T is inferred from, so a plain array literal is usually all the typing a flag needs.

validator(value: T) => boolean

An extra check, applied after availableValues — both must pass. Use it when the accepted values are a shape rather than a list: a version string, a percentage, a build tag. See Validation.

valueMapper(value: T) => K

Turns the raw string into what the app actually wants — "true" into true, "4k" into a resolution object. It runs inside a computed, so it must be pure. See Mapping.

persistboolean  · default true

Whether a resolved value is written to localStorage. With false the flag lives for one page view: the URL still decides it, nothing survives the reload, and no storage key is claimed. See Storage & keys.

storageKeystring

The key to persist under. Defaults to the query param name — set it when the param is short and public (?lang=fr) but the key should be namespaced (app:language), or when the name is already taken.

The feature object#

Everything below is a stable reference on a frozen object — export the flag and every consumer gets exactly what features.ts declared.

signal$ReadonlySignal<K>

The mapped value. Bind it into JSX, or read .value inside a computed / effect — never during render. See Rendering.

raw$ReadonlySignal<T>

The raw string behind signal$, before valueMapper — the value as it appears in the URL and in storage. Handy for analytics, debug panels and "which variant is active" UI.

peek() => K

The current mapped value, read without subscribing — for code that runs once and does not want a dependency, such as a request header or a one-off log line.

init(options?: { withReset?: boolean; search?: string }) => void

Resolve the value: URL, then storage, then the default. Safe to call again when the URL changes. See Init & timing.

afterInit() => Promise<void>

Resolves on the exact write that finishes init() — immediately if it already ran. Nothing polls, nothing races.

isInitialized() => boolean

Whether init() has run. A plain read, with no subscription attached.

update(value: T) => boolean

Set the value at runtime and persist it. Invalid values change nothing; the return value says whether the write went through. See Changing a flag at runtime.

reset() => void

Back to defaultValue, with the persisted value dropped — so the next init() falls back to the default again.

isValid(value: unknown) => value is T

The flag's own validation, exposed as a type guard — useful for a settings form that has to reject a value before offering it to update().

queryParamNamestring

The query param this flag is read from.

storageKeystring | null

The key it persists under — null when persist is false.

defaultValueT

The default value, as passed at creation.

Validation#

A query param is user input: anything at all can arrive in it, from a typo to a stale bookmark to a value a previous release accepted. Every value that reaches a flag — from the URL, from storage, from update() — is checked the same way, and there is exactly one way to fail: the value is ignored.

ts
const rollout = createFeatureQueryParam<string>("rollout", {
  defaultValue: "off",
  validator: (value) => value === "off" || /^\d{1,3}%$/.test(value),
});

rollout.isValid("25%");   // true
rollout.isValid("25");    // false
rollout.update("25%");    // true  — accepted and persisted
rollout.update("later");  // false — nothing changed

availableValues and validator compose: when both are given, both must pass, and the validator is only asked about values that made it through the list. A flag with neither accepts any string — which is a reasonable choice for a free-form value such as a theme name coming from a design system.

Rejection is not an error

Nothing throws and nothing is logged when a value is turned down — a broken link in a chat thread should not break the app, it should render the default. The one thing that does throw is a defaultValue that fails its own validation, because that is a bug in the declaration rather than in the URL.

Mapping#

The URL speaks strings; the app rarely wants one. valueMapper is the single boundary where "true" becomes a boolean and "4k" becomes something you can pass to a camera:

ts
export const imageUpload = createFeatureQueryParam<"true" | "false", boolean>("image-upload", {
  availableValues: ["true", "false"],
  defaultValue: "false",
  valueMapper: (value) => value === "true",
});

export const resolution = createFeatureQueryParam<"fullhd" | "4k", Resolution>("resolution", {
  availableValues: ["fullhd", "4k"],
  defaultValue: "4k",
  valueMapper: (value) =>
    value === "4k" ? { width: 3840, height: 2160 } : { width: 1920, height: 1080 },
});

imageUpload.signal$.value; // boolean
resolution.signal$.value;  // Resolution
resolution.raw$.value;     // "4k" — still there when you need the raw form

The mapper runs inside a computed: lazily, and only when the raw value actually changed. Reading signal$ a hundred times maps once; writing the same value twice maps nothing. It is also never handed a value that failed validation, so it can be written for the values it declares and nothing else.

Keep it pure

A mapper that reads other signals, mutates state or returns a fresh object for the same input turns a cached derivation into a surprise. If a flag needs to combine with something else, do that in a computed of your own — computed(() => imageUpload.signal$.value && user.signal$.value.canUpload).

Init & timing#

init() is the one call that reads the outside world. Everything before it is declaration; everything after it is a signal like any other.

tsmain.ts — before the app renders
import { imageUpload, language, resolution } from "./features";

for (const feature of [language, imageUpload, resolution]) {
  feature.init();
}

Code that must not run against a half-resolved flag waits for afterInit() — it settles on the exact write that finishes init(), so there is nothing to poll:

ts
await Promise.all([language.afterInit(), imageUpload.afterInit()]);

// the flags are resolved — safe to fetch, to render, to report
loadTranslations(language.peek());

The value and the "initialized" flag move together, in one batch: an effect woken by init() never sees a flag that claims to be initialized while still holding the default.

Calling init() again

It re-resolves against the URL of the moment, which is what a client-side navigation to a link carrying the param should do. Nothing is reset in the process: a URL without the param leaves the persisted value in place, so re-initialising on every route change is safe.

Where the query string comes from#

By default location.search. Pass search to take it from anywhere else — a request URL on the server, a router's own location, a hash for hash-based routing. Every shape is accepted:

ts
language.init();                                        // location.search
language.init({ search: "?language=fr" });              // a search string
language.init({ search: "language=fr" });               // …with or without the "?"
language.init({ search: "https://app.dev/x?language=fr" }); // a full URL
language.init({ search: "#/settings?language=fr" });    // a hash route
language.init({ search: request.url });                 // on the server

Values are percent-decoded, everything before the first ? is treated as a path and everything after a # as a fragment. Where there is no location at all and nothing was passed, the URL step is simply skipped — see SSR.

The URL is an input, not a mirror

The query param is read at init() and never written back: update() does not rewrite the address bar, and a param left in the URL does not re-apply on the next update(). That keeps the library out of your router's way — if you want a shareable link to reflect the current flags, build it from raw$ and push it yourself.

Changing a flag at runtime#

A flag is not only a launch switch — a language picker, a quality selector and a debug panel are all "change this flag, remember it, re-render whatever depends on it". That is update():

tsx
import { useComputed } from "@preact/signals";
import { language } from "./features";

function LanguagePicker() {
  return (
    <div>
      {(["en", "pt", "fr"] as const).map((value) => (
        <LanguageButton key={value} value={value} />
      ))}
    </div>
  );
}

function LanguageButton({ value }: { value: "en" | "pt" | "fr" }) {
  // derive in a computed, bind the computed — no .value in render
  const className = useComputed(() => (language.raw$.value === value ? "on" : ""));

  return (
    <button class={className} onClick={() => language.update(value)}>
      {value}
    </button>
  );
}

An accepted write moves signal$ and persists the value, so it is still there after a reload — the same channel the URL writes to. A rejected one returns false and changes nothing at all, which makes it safe to feed update() straight from an input:

ts
if (!rollout.update(input.value)) {
  showError("Not a valid rollout — try 25%");
}

reset() is the counterpart: back to defaultValue, persisted value dropped. After it, the flag is indistinguishable from one that was never chosen — the next init() falls back to the default again.

Rendering#

signal$ and raw$ are ReadonlySignals, which means a flag change updates the text node or attribute it is bound to and nothing else re-renders — whether the change came from init(), from update() or from a debug panel three routes away.

That guarantee is easy to throw away. Unwrapping .value while a component renders subscribes the whole component, and it starts re-rendering on every change:

tsxthe difference in one screen
// ✗ the component re-renders on every change
function Toolbar() {
  return (
    <div class={language.signal$.value}>
      {imageUpload.signal$.value && <UploadButton />}
    </div>
  );
}

// ✓ bind the signal; branch with <Show>; derive with useComputed
import { Show } from "@preact/signals/utils";

function Toolbar() {
  return (
    <div class={language.signal$}>
      <Show when={imageUpload.signal$}>
        <UploadButton />
      </Show>
    </div>
  );
}

Anything derived from a flag belongs in a computed / useComputed, which you then bind the same way:

tsx
const label = useComputed(() => `${resolution.signal$.value.width}p`);

return <span>{label}</span>;

Outside components the same rules apply, minus the hooks: read .value in a computed or effect, and peek() where you want the value without a subscription.

ts
import { effect } from "@preact/signals";

// a flag driving the page itself — one effect, no component involved
effect(() => {
  document.documentElement.dataset.theme = theme.raw$.value;
});

Storage & keys#

Persistence goes through typed-local-storage, in string mode: the value is written raw, so localStorage["language"] reads fr rather than "fr" — legible in devtools, and compatible with whatever wrote the key before.

One flag owns one key. Declaring a second flag on a key that is already claimed throws — two owners of one storage key, writing under different rules, is a bug worth failing loudly on:

ts
createFeatureQueryParam("language", { defaultValue: "en" });
createFeatureQueryParam("language", { defaultValue: "pt" });
// Error: Storage "language" already exists

// same param, different keys — fine
createFeatureQueryParam("lang", { defaultValue: "en", storageKey: "app:language" });

A flag that should not outlive the page view opts out entirely:

ts
const debugPanel = createFeatureQueryParam<"true" | "false", boolean>("debug", {
  availableValues: ["true", "false"],
  defaultValue: "false",
  valueMapper: (value) => value === "true",
  persist: false,
});

// ?debug=true turns it on for this page view only — no key is claimed,
// nothing is written, and a reload without the param starts clean.
Don't write the key by hand

A direct localStorage.setItem bypasses validation and leaves signal$ stale until the next init(). Everything a flag needs is on the flag: update() to write, reset() to clear, init({ withReset: true }) to start over.

SSR#

No window, no location, no localStorage — none of it is required. Flags can be declared at module scope in a file that also runs on the server: location is read through a guard, and persistence falls back to an in-process store, so the whole API keeps working with nothing to persist to.

tsthe same features.ts, imported on the server
language.init({ search: request.url }); // the request URL is the only source there is
language.peek();                        // "fr" — ready for the render pass

Nothing in your code needs a typeof window check or a dynamic import: the environment decision is made once, inside the library. On the client, the same init() runs again with the real location and the real storage behind it.

Module scope is shared per process

A flag declared at module scope is one object for the whole server process — fine for reading a request URL into a render pass, wrong for holding per-user state across concurrent requests. Resolve per request and pass the value down, rather than treating a module-level flag as request-scoped storage.

TypeScript#

Both type parameters usually take care of themselves. T is inferred from availableValues, and K from whatever the mapper returns:

ts
const language = createFeatureQueryParam("language", {
  availableValues: ["en", "pt", "fr"],
  defaultValue: "en",
});
language.signal$;      // ReadonlySignal<"en" | "pt" | "fr">
language.update("es"); // ✗ Argument of type '"es"' is not assignable…

Give two type arguments when the app-facing type differs from the raw one — and then valueMapper becomes required, because it is the only thing that can produce a K:

ts
createFeatureQueryParam<"true" | "false", boolean>("image-upload", {
  availableValues: ["true", "false"],
  defaultValue: "false",
}); // ✗ Property 'valueMapper' is missing

An open-ended flag — one with a validator and no fixed list — has nothing to infer T from, so name it: without the annotation T narrows to the literal type of defaultValue and every other value is a type error.

ts
const build = createFeatureQueryParam<string>("build", {
  defaultValue: "stable",
  validator: (value) => /^[a-z]+$/.test(value),
});
build.update("canary"); // ✓

isValid is a type guard, so a string of unknown origin narrows on the way in:

ts
const fromForm: string = input.value;

if (language.isValid(fromForm)) {
  language.update(fromForm); // fromForm: "en" | "pt" | "fr"
}

Exports#

Values

createFeatureQueryParam

Types

FeatureQueryParam · FeatureQueryParamOptions · FeatureQueryParamInitOptions