@dmytromykhailiuk/preact-signal-feature-query-param
v1.0.0
Published
Feature flags driven by a URL query param, persisted in localStorage and exposed as a typed Preact signal — validated, mapped, zero re-render.
Maintainers
Readme
@dmytromykhailiuk/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.
Full documentation: open Docs in a browser — every option, with examples, a table of contents and cross-links. This README is the short form.
?language=fr, ?image-upload=true, ?resolution=4k — the oldest feature-flag mechanism there
is, and the one QA, support and demos actually use. 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; what comes back is a
ReadonlySignal you bind straight into JSX.
Install
npm i @dmytromykhailiuk/preact-signal-feature-query-paramPeers: @preact/signals ≥ 2 and preact ≥ 10.11.
Quick start
// features.ts — the one place flags are declared
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",
});// main.ts — resolve every flag once, before the app renders
import { imageUpload, language } from "./features";
language.init();
imageUpload.init();// anywhere in the app — bind the signal, never unwrap it in render
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.
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 — it is an explicit, shareable choice |
| localStorage | the URL carried nothing usable | already there |
| defaultValue | nothing else was found | no |
Two consequences worth knowing:
- An invalid value never lands anywhere. A param outside
availableValues, or one thevalidatorturns down, is ignored — resolution simply falls through to the next source. - The default is never written. A flag nobody chose stays unset in storage, so changing
defaultValuein a later release still reaches returning users.
init({ withReset: true }) skips the stored value and drops it — "start from the default unless
this URL says otherwise". init({ search }) takes the query string from anywhere else: a
request URL on the server, a hash for hash-based routers, a router's own location.
API
const feature = createFeatureQueryParam<T, K>(queryParamName, {
defaultValue: T; // used until something valid shows up; must itself be valid
availableValues?: readonly T[]; // the accepted set
validator?: (value: T) => boolean;// extra check; both must pass
valueMapper?: (value: T) => K; // "true" → true, "4k" → { width, height }
persist?: boolean; // default true
storageKey?: string; // defaults to the query param name
});
feature.signal$; // ReadonlySignal<K> — the mapped value
feature.raw$; // ReadonlySignal<T> — the raw string behind it
feature.peek(); // K, read without subscribing
feature.init(options?); // resolve: URL → storage → default
feature.afterInit(); // Promise<void> — resolves once init() has run
feature.isInitialized(); // boolean
feature.update(value); // set at runtime; false when the value was rejected
feature.reset(); // back to defaultValue, persisted value dropped
feature.isValid(value); // type guard over availableValues + validator
feature.queryParamName;
feature.storageKey; // null when persist is false
feature.defaultValue;The returned object is frozen, and every flag owns its storage key — declaring the same key twice
throws, the same way two owners of one localStorage key always were a bug.
Typing
T is the raw string type; K is what the app consumes. Give one type argument and the flag is
its own value; give two and valueMapper becomes required, because it is the only thing that can
produce a K:
createFeatureQueryParam("language", { availableValues: ["en", "fr"], defaultValue: "en" });
// FeatureQueryParam<"en" | "fr"> — inferred, no type arguments needed
createFeatureQueryParam<"fullhd" | "4k", Resolution>("resolution", {
availableValues: ["fullhd", "4k"],
defaultValue: "4k",
valueMapper: toResolution, // required — the compiler asks for it
});
createFeatureQueryParam<string>("build", {
defaultValue: "stable",
validator: (value) => /^[a-z]+$/.test(value), // open-ended: no fixed set to infer from
});Zero re-render
signal$ is a ReadonlySignal, so a flag change updates the DOM node it is bound to and nothing
else — no component re-renders, whether the change came from init() or from update(). Bind it
directly, derive with computed / useComputed, and branch with <Show>:
const badge = useComputed(() => `${resolution.signal$.value.width}p`);
<span>{badge}</span>
<Show when={imageUpload.signal$} fallback={<Placeholder />}>
<UploadButton />
</Show>Timing
init() is explicit — call it when the URL is known, which for most apps is the first line of
main.ts. Anything that has to wait can:
await Promise.all([language.afterInit(), imageUpload.afterInit()]);afterInit() resolves on the exact write that finishes init() (and immediately if it already
ran), so nothing polls and nothing races. Before init(), a flag reads as its defaultValue.
SSR
No window, no location, no localStorage — none of it is required. Features can be declared
at module scope in code that also runs on the server: location is read through a guard,
persistence falls back to an in-process store, and the request URL can be handed in explicitly:
language.init({ search: request.url });License
MIT
