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.
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#
npm i @dmytromykhailiuk/preact-signal-feature-query-param
@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.
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.
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:
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 |
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.
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:
// 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#
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#
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.
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.
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.
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.
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.
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.
The mapped value. Bind it into JSX, or read
.value inside a computed /
effect — never during render. See
Rendering.
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.
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.
Resolve the value: URL, then storage, then the default. Safe to call again when the URL changes. See Init & timing.
Resolves on the exact write that finishes
init() — immediately if it already ran. Nothing
polls, nothing races.
Whether init() has run. A plain read, with no
subscription attached.
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.
Back to defaultValue, with the persisted value
dropped — so the next init() falls back to the
default again.
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().
The query param this flag is read from.
The key it persists under — null when
persist is false.
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.
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.
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:
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.
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.
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:
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.
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:
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 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():
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:
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:
// ✗ 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:
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.
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:
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:
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.
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.
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.
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:
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:
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.
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:
const fromForm: string = input.value;
if (language.isValid(fromForm)) {
language.update(fromForm); // fromForm: "en" | "pt" | "fr"
}
Exports#
Values
createFeatureQueryParam
Types
FeatureQueryParam ·
FeatureQueryParamOptions ·
FeatureQueryParamInitOptions