Skip to content

Routing

PreffX provides a lightweight, signal‑based client‑side routing mechanism through the url utility. There is no router component to import — you get a reactive ReadonlySignal<URL> that stays in sync with the browser's location via the Navigation API.

The url signal

Every PreffX component receives url in its second argument (utils).

PropertyTypeDescription
urlReadonlySignal<URL>Reactive signal whose .value is the current URL object. Changes whenever the user navigates to a same‑origin page.

Type signature

typescript
type PreffXUtils = {
    url: ReadonlySignal<URL>;
    // ...
};

Basic routing

Use url.value to read the current pathname, search params, hash, or any other URL property. Combine it with computed to reactively switch between views.

tsx
import type { PC } from 'preffx';

const HomePage: PC = () => <div>Home page</div>;
const ContactsPage: PC = () => <div>Contacts page</div>;
const NotFound: PC = () => <div>Page not found</div>;

export const App: PC = (_, { computed, url }) => {
    const page = computed(() => {
        switch (url.value.pathname) {
            case '/':
                return <HomePage />;
            case '/contacts':
                return <ContactsPage />;
            default:
                return <NotFound />;
        }
    });

    return (
        <div>
            <nav>
                <a href="/">Home</a>
                <a href="/contacts">Contacts</a>
            </nav>
            <main>{page}</main>
        </div>
    );
};

How it works

  1. You render <a href="/contacts"> links as usual.
  2. When the user clicks the link, the browser emits a navigate event.
  3. PreffX intercepts the event (calls event.preventDefault()) and updates the internal url signal.
  4. Any computed that depends on url.value re‑evaluates and the DOM updates reactively.

Use standard <a> elements with href. PreffX handles the interception automatically — no special <Link> component required.

tsx
<nav>
    <a href="/">Home</a>
    <a href="/about">About</a>
    <a href="/contacts">Contacts</a>
    <a href="/products?category=books&sort=asc">Books</a>
</nav>

PreffX's interceptor only activates when at least one component is using the url signal (tracked by an internal URL_WATCHERS counter). This means:

  • If no component reads url, clicks on <a> links behave normally (full page navigation).
  • As soon as a component accesses url.value, navigation is intercepted and handled client‑side.

Reading URL parts

Because url is a ReadonlySignal<URL>, you can read any property of the native URL object:

tsx
const CurrentRoute: PC = (_, { url }) => {
    return (
        <div>
            <p>Pathname: {url.value.pathname}</p>
            <p>Search:   {url.value.search}</p>
            <p>Hash:     {url.value.hash}</p>
            <p>Host:     {url.value.host}</p>
            <p>Origin:   {url.value.origin}</p>
        </div>
    );
};

These are all reactive — changes to any of them trigger recomputation of dependent signals.

Route parameters (query strings)

Parse query parameters from url.value.searchParams inside a computed:

tsx
import type { PC } from 'preffx';

const ProductList: PC = (_, { computed, url }) => {
    const category = computed(() =>
        url.value.searchParams.get('category') ?? 'all'
    );
    const sort = computed(() =>
        url.value.searchParams.get('sort') ?? 'name'
    );

    return (
        <div>
            <p>Category: {category}</p>
            <p>Sort by:  {sort}</p>
            <a href="/products?category=books&sort=asc">Books (asc)</a>
            <a href="/products?category=books&sort=desc">Books (desc)</a>
        </div>
    );
};

Dynamic segments (path params)

Since url gives you the full URL object, you can parse path segments manually:

tsx
import type { PC } from 'preffx';

const UserProfile: PC = (_, { computed, url }) => {
    const segments = computed(() => url.value.pathname.split('/').filter(Boolean));
    // pathname = "/users/42"  →  segments = ["users", "42"]

    const userId = computed(() => segments.value[1]);

    return <div>User ID: {userId}</div>;
};

For a more structured approach, build a simple route matcher:

tsx
import type { PC } from 'preffx';

// Simple route pattern → params extractor
function match(pattern: string, pathname: string): Record<string, string> | null {
    const patternParts = pattern.split('/').filter(Boolean);
    const pathParts = pathname.split('/').filter(Boolean);
    if (patternParts.length !== pathParts.length) return null;

    const params: Record<string, string> = {};
    for (let i = 0; i < patternParts.length; i++) {
        if (patternParts[i].startsWith(':')) {
            params[patternParts[i].slice(1)] = pathParts[i];
        } else if (patternParts[i] !== pathParts[i]) {
            return null;
        }
    }
    return params;
}

const RouteExample: PC = (_, { computed, url }) => {
    const route = computed(() => {
        const { pathname } = url.value;
        const params = match('/users/:id', pathname);
        if (params) return <div>User {params.id}</div>;

        const postParams = match('/posts/:postId', pathname);
        if (postParams) return <div>Post {postParams.postId}</div>;

        return <div>Not found</div>;
    });

    return (
        <div>
            <nav>
                <a href="/users/42">User 42</a>
                <a href="/users/7">User 7</a>
                <a href="/posts/abc">Post abc</a>
            </nav>
            <main>{route}</main>
        </div>
    );
};

Cross‑origin navigation

The navigation interceptor only applies to same‑origin navigations. Cross‑origin links (different protocol, host, or port) are left untouched and trigger a full page load as normal.

tsx
<a href="https://example.com">External link</a>  <!-- full navigation -->
<a href="/internal">Internal link</a>             <!-- client‑side navigation -->

Browser support

The routing relies on the Navigation API (globalThis.navigation).

Since January 2026, this feature works across the latest devices and browser versions. This feature might not work in older devices or browsers.

Released under the Apache-2.0 License.