npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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-param

Peers: @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 the validator turns 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 defaultValue in 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