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

@dialogs-valve/react

v0.3.0

Published

A small library to manage dialogs (like modals, drawers, etc) using URL query params.

Readme

@dialogs-valve/react

npm CI Deploy Demo codecov License: MIT

[!TIP] Check out the Live Demo! 🚀

See how Dialogs Valve handles deep linking, history, and overlapping modals in real-time.

A small, router-agnostic React library to manage dialogs (modals, drawers, etc.) using URL query params.

By storing your dialog state in the URL, you get out-of-the-box support for deep linking, browser history (back/forward buttons), and shareable URLs.

Features

  • 🔗 URL-Driven: Dialog state is completely synced with URL query parameters.
  • 🚏 Router-Agnostic: Works seamlessly with Next.js, React Router, TanStack Router, Remix, or any custom router.
  • 🌐 Route-Independent: Open any dialog from any page without registering it as a route — the current page stays underneath, and closing returns you to it untouched.
  • 🎭 Overlap Support: Open multiple dialogs stacked on top of each other.
  • 🧩 Type-Safe: Define a strict registry of dialog keys and get full compile-time validation via module augmentation — no generics at call sites.
  • 💂 Route Guards: Built-in canShow guard mechanism for permission-based rendering.
  • Animated Exits: Configurable delay to allow close animations to finish before unmounting.

Installation

npm install @dialogs-valve/react
yarn add @dialogs-valve/react
pnpm install @dialogs-valve/react

Quick Start

1. Define your dialog registry

Create a map of dialog keys to their corresponding React components. Add a declare module block in the same file to register your types — this is what enables compile-time key validation and autocomplete across the entire app with no boilerplate at call sites.

// dialogs-valve-registry.tsx
import type { DialogMap } from "@dialogs-valve/react";
import { UserProfileModal } from "./components/UserProfileModal";
import { SettingsDrawer } from "./components/SettingsDrawer";

export const dialogs = {
  "user-profile": { Component: UserProfileModal },
  "settings":     { Component: SettingsDrawer },
} as const satisfies DialogMap;

// Register once — all hooks and helpers are auto-typed from this point on
declare module "@dialogs-valve/react" {
  // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
  interface DialogsValveRegistry {
    dialogs: typeof dialogs;
  }
}

Note on dialog components: The library automatically passes open (boolean) and onClose (() => void) to every dialog component it renders, along with any custom props extracted from query params. Any props beyond open and onClose should be declared as optional, since a dialog can be opened from a deep link without those query params present.

2. Setup the Provider

Wrap your app (or the sub-tree where dialogs live) with DialogsValveProvider. Pass onNavigate and locationSearch from your router — this is the recommended setup for reliable, reactive integration.

// App.tsx
import { useLocation, useNavigate } from "react-router-dom";
import { DialogsValveProvider } from "@dialogs-valve/react";
import { dialogs } from "./dialogs-valve-registry";

function App() {
  const navigate = useNavigate();
  const { search } = useLocation();

  return (
    <DialogsValveProvider
      dialogs={dialogs}
      onNavigate={navigate}
      locationSearch={search}
    >
      <MainLayout />
    </DialogsValveProvider>
  );
}

Both props are optional — if omitted, the library falls back to window.history.pushState for navigation and a built-in popstate + MutationObserver listener for tracking URL changes. The fallbacks work in most cases but are less reliable than passing values directly from your router.

3. Trigger dialogs anywhere

Import useDialogsValve directly from the library anywhere inside the provider. Keys are fully typed — TypeScript will error on typos and provide autocomplete.

// UserList.tsx
import { useDialogsValve } from "@dialogs-valve/react";

export function UserList() {
  const { openDialog, closeDialog, closeAllDialogs, isOpen } = useDialogsValve()!;

  return (
    <div>
      <button onClick={() => openDialog("settings")}>
        Open Settings
      </button>

      {/* Pass extra context through URL params */}
      <button onClick={() => openDialog("user-profile", { props: { userId: "42" } })}>
        View User 42
      </button>
    </div>
  );
}

Advanced Features

Passing Props via Query Params

When you call openDialog("key", { props: { ... } }), primitive values (string, number, boolean) are serialized into the URL alongside the active dialog key.

For example:

openDialog("user-profile", { props: { userId: "123", mode: "edit" } })

Generates a URL like: ?dialog=user-profile&user-profile.userId=123&user-profile.mode=edit

These props are automatically extracted and spread as React props onto the rendered component:

// <UserProfileModal> receives:
{
  open: true,
  onClose: () => void,
  userId: "123",
  mode: "edit",
}

Overlapping Dialogs

By default, opening a dialog stacks it on top of any currently open dialogs (overlap: true). Each dialog adds its own key to the URL — close one and the rest stay open.

// Open a second drawer on top of the first — both stay in the URL
openDialog("settings");
// URL: ?dialog=user-profile&dialog=settings

Each dialog only removes itself when closed — the others remain open.

Cross-Route Dialog Links

To open a dialog on a different route — "navigate to another page and open a dialog there" from a single click — pass pathName. The helper builds a fresh query string rooted at that path, keeping prop serialization intact.

import { buildDialogUrl } from "@dialogs-valve/react";

// A link on /list that lands on /admin/users with a dialog already open:
const href = buildDialogUrl("user-create", {
  props: { tab: "details" },
  pathName: "/admin/users",
});
// → "/admin/users?dialog=user-create&user-create.tab=details"

<Link to={href}>Add user</Link>;

The same option works on openDialog("user-create", { pathName: "/admin/users" }). When pathName is omitted, the URL is relative to the current location (the default). Unlike same-route links, the current route's existing dialog params are not merged, since overlapping against another route is meaningless.

Same-route builders (buildDialogUrl without pathName, buildCloseDialogUrl, buildCloseAllDialogsUrl) return a relative, search-only URL (e.g. ?dialog=user-view, or ? when no dialogs remain). The current pathname is intentionally left off so your router resolves it against the current location — preserving both the pathname and any router basename, with no work on your side. So onNavigate={navigate} works as-is even under a basename: closing a dialog keeps you on the page where you opened it instead of bouncing back to the origin (/).

Closing dialogs only strips dialog state — each dialog's key plus its serialized props (<key>.<prop>). Any unrelated query params you keep in the URL (e.g. utm_source, list filters, pagination) are left untouched, so opening or closing a dialog never wipes the rest of your query string.

Dialog Replacement

Pass overlap: false to remove all currently open dialogs and open the new one in their place. Useful for wizard-style flows or exclusive panels.

openDialog("step-two", { overlap: false }); // removes step-one, adds step-two
// URL: ?dialog=step-one → ?dialog=step-two

Dialog Guards and Permissions

Add a canShow guard to any registry entry to conditionally prevent rendering. Pass a permissions context object to the provider — it is forwarded to every canShow call.

// dialogs-valve-registry.tsx
export type AppPermissions = { isAdmin: boolean };

export const dialogs = {
  "admin-dashboard": {
    Component: AdminDashboardModal,
    canShow: (permissions: AppPermissions) => permissions.isAdmin === true,
  },
} as const satisfies DialogMap<string, AppPermissions>;

declare module "@dialogs-valve/react" {
  // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
  interface DialogsValveRegistry {
    dialogs: typeof dialogs;
  }
}
// App.tsx
<DialogsValveProvider
  dialogs={dialogs}
  onNavigate={navigate}
  permissions={{ isAdmin: currentUser.role === "admin" }}
>
  <App />
</DialogsValveProvider>

If canShow returns false, the dialog is skipped and an error is logged to the console.

Reacting to a blocked dialog

Because dialog state lives in the URL, a user can land directly on a guarded dialog via a shared/deep link they aren't permitted to open — which by default results in nothing visible. To surface feedback (a toast, a redirect, an analytics event), pass onUnauthorized:

<DialogsValveProvider
  dialogs={dialogs}
  permissions={{ isAdmin: currentUser.role === "admin" }}
  onUnauthorized={(key, permissions) => toast.error(`Not authorized: ${key}`)}
>
  <App />
</DialogsValveProvider>

onUnauthorized is invoked from an effect (not during render) and fires once per block event, so it's safe to run side effects inside it — keep your canShow guards pure.

Async permissions

When permissions load asynchronously (e.g. fetched after mount), the first render would otherwise evaluate guards against incomplete data — a guarded dialog can flash in then disappear, or a guard that reads permissions.isAdmin can throw on undefined. Pass permissionsLoading to tell the library while permissions are not yet safe to guard against:

function App() {
  const { permissions, isLoading } = usePermissions();

  return (
    <DialogsValveProvider
      dialogs={dialogs}
      permissions={permissions}
      permissionsLoading={isLoading}
    >
      <MainLayout />
    </DialogsValveProvider>
  );
}

While permissionsLoading is true, dialogs with a canShow guard are deferred (not rendered) until it flips back to false. Dialogs without a guard are unaffected and always render. It defaults to false, so omitting it leaves behavior unchanged.

Router Integration

The recommended way to set up the provider is to pass both onNavigate and locationSearch from your router. This gives the library a first-class, reactive integration — navigation goes through your router's history API and URL state is read directly from a value your router already tracks.

React Router v6:

import { useLocation, useNavigate } from "react-router-dom";

function App() {
  const navigate = useNavigate();
  const { search } = useLocation();

  return (
    <DialogsValveProvider
      dialogs={dialogs}
      onNavigate={navigate}
      locationSearch={search}
    >
      <MainLayout />
    </DialogsValveProvider>
  );
}

Next.js App Router:

"use client";
import { useRouter, useSearchParams } from "next/navigation";

function AppShell() {
  const router = useRouter();
  const searchParams = useSearchParams();

  return (
    <DialogsValveProvider
      dialogs={dialogs}
      onNavigate={router.push}
      locationSearch={searchParams.toString()}
    >
      <MainLayout />
    </DialogsValveProvider>
  );
}

TanStack Router:

import { useNavigate, useLocation } from "@tanstack/react-router";

function App() {
  const navigate = useNavigate();
  const { searchStr } = useLocation();

  return (
    <DialogsValveProvider
      dialogs={dialogs}
      onNavigate={(url) => navigate({ to: url })}
      locationSearch={searchStr}
    >
      <MainLayout />
    </DialogsValveProvider>
  );
}

Without a router (fallback mode):

If you don't have access to router hooks — for example in a plain Vite app without a router, or in a context where the router isn't available — both props can be omitted. The library will fall back to window.history.pushState for navigation and a built-in popstate + MutationObserver listener for tracking URL changes.

<DialogsValveProvider dialogs={dialogs}>
  <MainLayout />
</DialogsValveProvider>

This fallback works in most cases, but the router-integrated setup is preferred whenever possible.

Global Configuration

Customize the URL param key and animation timing via the config prop on DialogsValveProvider.

<DialogsValveProvider
  dialogs={dialogs}
  onNavigate={navigate}
  config={{
    dialogParamKey: "modal", // Default: "dialog"
    closeDelay: 500,         // Default: 300ms — wait before unmounting for animations
  }}
>

API Reference

DialogsValveProvider Props

Import directly from @dialogs-valve/react.

| Prop | Type | Default | Description | |------|------|---------|-------------| | dialogs | DialogMap | — | Required. Your dialog registry map. | | onNavigate | (url: string) => void | history.pushState | Navigation callback from your router. | | permissions | TPermissions | — | Permissions context forwarded to canShow guards. | | permissionsLoading | boolean | false | While true, dialogs with a canShow guard are deferred until permissions resolve. Unguarded dialogs are unaffected. | | onUnauthorized | (key, permissions?) => void | — | Called when a canShow guard denies a dialog. Fires from an effect, once per block event. | | config | DialogsValveConfig | — | Override dialogParamKey or closeDelay. | | locationSearch | string | — | Reactive search string from your router (e.g. useLocation().search). When provided, overrides the built-in location listener. | | children | ReactNode | — | Your app content. |


useDialogsValve() Return Value

Import directly from @dialogs-valve/react. Must be called within a DialogsValveProvider. When called outside one, the hook logs an error and returns null (it does not throw) — that's why the examples above use the non-null assertion (useDialogsValve()!). Keys are automatically typed from your DialogsValveRegistry augmentation.

| Method / Property | Signature | Description | |-------------------|-----------|-------------| | openDialog | (key, options?) => void | Opens a dialog. Optionally pass props, overlap, or pathName. | | closeDialog | (key) => void | Closes a specific dialog by key. | | closeAllDialogs | () => void | Closes all currently open dialogs. | | isOpen | (key) => boolean | Returns true if the dialog is currently open. | | getDialogProps | (key) => Record<string, DialogPropValue> | Returns the deserialized props for a dialog from the URL. | | dialogParamKey | string | The active URL param key (e.g. "dialog"). |


URL Builder Helpers

All imported directly from @dialogs-valve/react. Keys are typed from your registry augmentation.

| Function | Signature | Description | |----------|-----------|-------------| | buildDialogUrl | (key, options?, paramKey?) => string | Builds a URL that opens a dialog. | | buildCloseDialogUrl | (key, paramKey?) => string | Builds a URL that closes a specific dialog. | | buildCloseAllDialogsUrl | (paramKey?) => string | Builds a URL that closes all dialogs. | | extractDialogProps | (search, key) => Record<string, DialogPropValue> | Extracts props for a dialog from a URL search string. | | getActiveDialogKeys | (search, paramKey) => string[] | Returns the currently active dialog keys from a URL. | | cleanUpQueryParams | (search, paramKey, key) => string | Removes a dialog key and its props from a URL search string. | | validateDialogKeys | (keys, validKeys) => string[] | Filters a list of keys to only registered ones. | | parsePropValue | (value) => DialogPropValue | Deserializes a URL-encoded prop value to its typed primitive. |


Constants

| Export | Value | Description | |--------|-------|-------------| | DIALOG_MAIN_KEY | "dialog" | Default URL query param key for tracking active dialogs. | | DIALOG_DELAY_TO_CLOSE | 300 | Default milliseconds before unmounting a closed dialog. |


BuildDialogUrlOptions

Passed as the second argument to openDialog or buildDialogUrl.

| Option | Type | Default | Description | |--------|------|---------|-------------| | props | Record<string, string \| number \| boolean> | — | Custom props to serialize into the URL. | | overlap | boolean | true | true to stack on existing dialogs; false to replace them. | | pathName | string | current path | Root the URL at this path instead of the current location, for cross-route dialog links. When set, the query is built from scratch (current params are not merged). |


DialogsValveConfig

Passed as config to DialogsValveProvider.

| Option | Type | Default | Description | |--------|------|---------|-------------| | dialogParamKey | string | "dialog" | The URL query param key for tracking active dialogs. | | closeDelay | number | 300 | Milliseconds to wait before unmounting a closed dialog (for exit animations). |


Types

All types are re-exported directly from @dialogs-valve/react:

import type {
  DialogMap,              // Registry map type
  DialogEntry,            // Single registry entry { Component, canShow? }
  DialogPropValue,        // string | number | boolean
  BuildDialogUrlOptions,
  DialogsValveConfig,
  DialogsValveContextValue,
  DialogsValveProviderProps,
  InferDialogKeys,        // Extracts key union from a registry type
  RegisteredDialogKeys,   // Key union resolved from DialogsValveRegistry augmentation
  onNavigateType,         // (url: string) => void
} from "@dialogs-valve/react";

InferDialogKeys is useful when you need the key union in other type declarations:

type MyDialogKeys = InferDialogKeys<typeof dialogs>;
// "user-profile" | "settings"

DialogsValveRegistry is the augmentation interface. Extend it once in your registry file:

declare module "@dialogs-valve/react" {
  // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
  interface DialogsValveRegistry {
    dialogs: typeof dialogs;
  }
}

Versioning

This project uses Semantic Versioning, with one caveat while it is in 0.x.

While in 0.x (current)

The library follows the 0ver convention used by most pre-1.0 OSS projects (React, Vue, Vite all did this):

  • Breaking changes bump the minor number — 0.1.00.2.0
  • Features and fixes bump the patch number — 0.1.00.1.1

Breaking changes are allowed in any 0.x release. If you depend on this library while it is in 0.x, pin with ~0.x.y (patch-only updates) rather than ^0.x.y (which would let breaking changes in).

After 1.0.0

Once 1.0.0 ships, standard SemVer applies:

  • Major (1.x2.0) — breaking changes
  • Minor (1.01.1) — new features, backwards-compatible
  • Patch (1.0.01.0.1) — bug fixes, backwards-compatible

At that point ^1.0.0 is safe to use.

Release process

Releases are managed via Changesets and published automatically to npm by GitHub Actions. See CONTRIBUTING.md for how to add a changeset to your PR.

The changelog for each version lives in CHANGELOG.md once the first automated release lands.