Observed since 20 Oct, 21:57 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
Small SPA router based on preact signals
Tiny SPA routing helpers built on top of @preact/signals that treat the URL as your application state.
Using the URL as the single source of truth gives you, for free:
This package embraces that: it exposes just a few primitives that read/write the URL and let the browser do the rest.
A — a typed anchor that performs client-side navigation for same-origin
pathsnavigate — programmatic navigation using history.pushState/replaceStatereplaceParams — rebuild query strings in a predictable wayurl — reactive, read-only helpers: path, hash, params, equalsTrailing slashes are normalized away (/users/ → /users), and query parameter
order doesn’t matter when comparing URLs.
Use the A component instead of anchors. It preserves all native behaviors
(open in new tab, copy link address, etc.) and does SPA navigation for
same-origin, non-/api/* URLs.
import { A } from '@01edu/router-signal'
export function Nav() {
return (
<nav>
<A href='/'>Home</A>
<A href='/users' params={{ page: 2 }}>Users (page 2)</A>
<A href='https://example.com'>External (normal link)</A>
</nav>
)
}
Programmatic navigation:
Always prefer using an
Aover anavigate, use of navigate inonClickare code smells.
import { navigate } from '@01edu/router-signal'
// Navigate to /users?tab=active
navigate({ href: '/users', params: { tab: 'active' } })
// Replace current entry and set a hash
navigate({ hash: 'details', replace: true })
Read the current URL reactively (no extra hooks or signals):
import { effect } from '@preact/signals'
import { url } from '@01edu/router-signal'
// All properties are signals, so it's reactive.
effect(() => {
if (url.path === '/users') {
// ...
}
console.log(url.hash) // e.g. "#details"
console.log(url.params.page) // "2" | null
})
// Order-insensitive equality by origin, path, and query values
url.equals(new URL('/users?page=2', location.origin)) // true or false
<A>Props:
href?: string — target path or URLhash?: string — fragment without leading #params?: Record<string, string | number | boolean | null | undefined> —
query updatesreplace?: boolean — use history.replaceState instead of pushStateclass, target, rel, …)Semantics for params values:
true → include key with empty value (?key)false or null/undefined → remove keystring | number | boolean → set as value (?key=...)Examples:
import { A, replaceParams } from '@01edu/router-signal';
// Merge params into the URL:
<A href="/users" params={{ page: 3 }}>Users page 3</A>
// Replace current params entirely:
<A href="/users" params={replaceParams({ page: 1, filter: true })}>
Users page 1 (filter on)
</A>
// Clear all params:
<A href="/users" params={replaceParams()}>
Users (no query)
</A>
// External and /api are normal links:
<A href="https://example.com">External</A>
<A href="/api/report.csv" download>
Download CSV
</A>
Behavioral details:
params is omitted and you only change the hash (or keep the same path),
current query parameters are preserved.href automatically.navigate(props)Programmatic client-side navigation.
type ParamValue = string | number | boolean | null | undefined;
navigate({
href?: string,
hash?: string,
params?: Record<string, ParamValue>,
replace?: boolean,
}): void
Examples:
import { navigate, replaceParams } from '@01edu/router-signal'
// Go to /users?tab=active
navigate({ href: '/users', params: { tab: 'active' } })
// Toggle a boolean flag (?filter):
navigate({ params: { filter: true } })
navigate({ params: { filter: false } })
// Replace current entry instead of pushing:
navigate({ hash: 'details', replace: true })
// Replace all params with just ?page=2&filter:
navigate({ params: replaceParams({ page: 2, filter: true }) })
Notes:
params is omitted and only hash changes, existing query params are
preserved./.replaceParams(newParams?)Replace the current query parameters wholesale. This returns an object suitable
for the params field in navigate or <A>.
replaceParams(newParams?: Record<string, string | number | boolean | null | undefined>): Record<string, ParamValue>
newParams using the same semantics as params:
true → ?keyfalse | null | undefined → remove key?key=valueInternally this use a
computedsignals to only update if new params are added, not if there value changes
Examples:
import { navigate, replaceParams } from '@01edu/router-signal'
// Replace all params with page=2 and a boolean filter:
navigate({ params: replaceParams({ page: 2, filter: true }) })
// Clear all params:
navigate({ params: replaceParams() })
This eliminates accidental accumulation of stale keys when navigating across views, important for "pages changes".
urlA reactive view of the current URL. Reads are backed by signals internally, but you don’t create or pass around any new signals or hooks yourself.
type Url = {
path: string // pathname without trailing slash, e.g. "/users"
hash: string // includes the leading "#", or "" if none
params: Record<string, string | null> // enumerable, values are string|null
equals: (u: URL) => boolean // same-origin, same path, same query values (order-insensitive)
}
Examples:
import { url } from '@01edu/router-signal'
function isUsers() {
return url.path === '/users'
}
const page = url.params.page // "2" | null
const hasFilter = url.params.filter !== null
url.equals(new URL('/users?page=2', location.origin)) // true/false
Notes:
params is a proxy exposing string-or-null values. It’s enumerable, so
Object.keys(url.params) and { ...url.params } work as expected.equals.Because the URL is the state, you rarely need custom signals or hooks. A few common patterns:
Active link:
import { A, url } from '@01edu/router-signal'
<A
href='/users'
class={url.path === '/users' ? 'active' : undefined}
aria-current={url.path === '/users' ? 'page' : undefined}
>
Users
</A>
Filter toggle:
import { A, url } from '@01edu/router-signal'
const isOn = url.params.filter !== null
<A params={{ filter: isOn ? false : true }}>
{isOn ? 'Disable' : 'Enable'} filter
</A>
Pagination:
import { A, url } from '@01edu/router-signal';
const current = Number(url.params.page ?? '1');
const next = current + 1;
const prev = Math.max(1, current - 1);
<A params={{ page: prev }}>Prev</A>
<A params={{ page: next }}>Next</A>
Replace vs merge:
import { A, replaceParams } from '@01edu/router-signal';
// Merge: keep existing keys unless you override them
<A params={{ sort: 'name' }}>Sort by name</A>
// Replace: start fresh (prevents stale filters carrying over)
<A params={replaceParams({ sort: 'name' })}>Sort by name</A>
/api/*
paths. External URLs and /api/* fall back to normal anchors.# (e.g. #details), or "" if none.window.history and location).This library is intentionally small. The browser already gives you a robust
state container (the URL) and a navigation API (history).
We aim to provide a thin layer between preact signals and the browser URL, this ensure your app are simpler to reason about, integrate better with the platform and don’t invent new state for things the URL already models perfectly.
We volontarly have "bring your own" approach to things like validation, typesafety, params assertion, and in most case found it wasn't even nescessary.
MIT
Download counts and popularity will appear here as the collector receives them.
This describes our collected data; it is not a security assessment.
Observed since 20 Oct, 21:57 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
package:ce3378a7f4d270867342298d75513a66befb163dc323369598c4d63bdd80c10a