Skip to content

lit-ui-router-effect ​

NPM VersionGitHub Release

lit-ui-router-effect provides Effect bindings for lit-ui-router: a SubscriptionRef of the router's state, from ui-router-effect, and ref-following ReactiveControllers that keep components in sync with it — and with any other SubscriptionRef the application holds.

It is a thin wrapper on top of lit-ui-router — it registers no custom elements and adds no routing behavior. If your application already keeps its state in Effect refs, these bindings let route state participate in the same system, with automatic requestUpdate() and no manual refresh plumbing.

Not using Effect?

You don't need this package to react to route changes. The core package's zero-dependency TransitionController covers the same ground with transition hooks instead of refs.

Installation ​

bash
npm install lit-ui-router-effect ui-router-effect effect

lit-ui-router, ui-router-effect, lit, effect, and @uirouter/core are peer dependencies.

Effect 3 and 4 ​

Both majors are supported (effect@^3.22.0 || ^4.0.2) and both are exercised in CI. The bindings' own API is identical on either. The examples below are written for Effect 4, whose Equal.equals compares plain objects structurally. On Effect 3 it compares them by reference, so wrap a selected object in Data.struct(...) for equals: Equal.equals to see two equal selections as unchanged.

Quick start ​

ts
import { html, LitElement } from 'lit';
import { Equal } from 'effect';
import { RouterRefController } from 'lit-ui-router-effect';

class AppNav extends LitElement {
  // Re-renders only when a section's visibility actually flips —
  // not on every transition. Equal.equals compares the selection
  // structurally.
  private active = new RouterRefController(
    this,
    (route) => ({
      inbox: route.includes('inbox.**'),
      contacts: route.includes('contacts.**'),
    }),
    { equals: Equal.equals },
  );

  render() {
    return html`...${this.active.value.inbox ? 'Inbox is open' : ''}...`;
  }
}

No router configuration is required: the controller discovers the router from the enclosing <ui-router> element when the host connects, and the route ref lazily attaches its single transition hook on first use. On the server, where there is no element to seek, it reads the router withRouterSync scoped at construction.

The pieces ​

routeRef ​

routeRef(router) is the SubscriptionRef<RouteSnapshot> for a router — memoized, one per router instance. The first call registers one transitionService.onSuccess hook that replaces the value per successful transition. It is ui-router-effect's routeRef, re-exported, so code using either package shares one ref per router.

A RouteSnapshot is a value taken once per successful transition. Every member answers for that moment, so a selector asking during a later transition gets the settled answer, not the in-flight one.

MemberDescription
currentThe current StateDeclaration (globals.current)
paramsThe current RawParams (globals.params), a fresh object per transition
transitionThe transition that produced the snapshot
includes(stateOrName, p?)StateService.includes evaluated against the snapshot (globs like 'a.**')

RouterRefController ​

RouterRefController follows the route ref of the host's <ui-router> context:

ts
new RouterRefController(host, selector, options?)
  • selector: (route: RouteSnapshot) => T — the selected expression; the result is exposed as .value
  • options.router — explicit router instance, skipping context discovery; the route is then read at construction, so .value is live before the host connects. A thunk (() => UIRouter | undefined) is resolved at construction and again on every hostConnected; when it returns undefined, the controller discovers the router as if the option were omitted. Discovery subscribes, so on a page served by lit-ui-router-ssr the host follows <ui-router> from its placeholder router to the app's when hydrateRoot() sets it. A lit-ui-router that does not hand that router to subscribers leaves the host on the placeholder; hand such a host its router here or through setRouter()
  • options.onChange — effect invoked when the selected value changes (and once on every (re)connect); useful for resetting component state from route params
  • options.equals — comparer for precise, value-based change detection (Equal.equals, or any (a, b) => boolean); defaults to Object.is
  • options.initialValue — the value .value carries before the router is discovered: before hostConnected, and while a host has no router context
  • options.runtime — the runtime the subscription fiber is forked on; defaults to Effect's default runtime, and a ManagedRuntime satisfies it directly

controller.setRouter(router) hands the controller a router after construction, such as one that arrives as a host property. A connected host interrupts its fiber, re-seeds .value from the new router, re-forks, and updates; a disconnected one takes the router on its next hostConnected. Passing the router already followed does nothing.

RefController ​

RefController is the generic primitive behind RouterRefController — the same selector/options contract over any SubscriptionRefs, not just the router's:

ts
import { Equal } from 'effect';
import { RefController } from 'lit-ui-router-effect';

class NavHeader extends LitElement {
  private auth = new RefController(
    this,
    [Session.user$, Session.loggedIn$],
    (user, loggedIn) => ({ user, loggedIn }),
    { equals: Equal.equals },
  );

  render() {
    const { user, loggedIn } = this.auth.value;
    // ...
  }
}

The refs are a tuple and the selector receives their values positionally. Pass a thunk instead (() => [ref]) for refs that depend on the host's place in the DOM; it is resolved on every hostConnected, and .value carries options.initialValue until then.

Lifecycle safety ​

Each controller runs one fiber — Stream.runForEach over the refs' changes — forked in hostConnected and interrupted in hostDisconnected, so it lives exactly as long as the host is in the document. The value is also re-read synchronously on every (re)connect, so components that re-enter the DOM — for example under sticky states — never render stale values.

Refs given directly are read once more at construction, so .value is live before the host ever connects: a host rendered on the server sees the same value a browser would.

Development and production builds ​

Like lit-ui-router, this package ships two builds and bundlers pick between them through the development export condition — see Development & Production Builds for the mechanism and the full warning inventory across packages.

One warning exists here. A RouterRefController whose host has no <ui-router> ancestor logs a one-time console warning naming that host, and then follows nothing: .value stays at options.initialValue and the host is never asked to update, so the component renders once with its initial value and never again. Wrap the subtree in <ui-router>, or hand a host that lives outside the router's DOM its router with options.router or setRouter().

Why selectors instead of render auto-tracking? ​

The controllers here are the composition-friendly alternative to a base class that reads refs during render():

  • No base class required — controllers attach to any LitElement (or any ReactiveControllerHost)
  • Dependencies are explicit: the refs tuple names exactly which state drives the host
  • equals: Equal.equals avoids re-renders when a recomputed value is structurally unchanged
  • The fiber's lifetime is bound to the host's connection lifecycle automatically

Resolves stay on view props ​

The route ref mirrors current, params and transition — not resolves. Resolved data reaches a routed component exactly as it does without these bindings: as UIViewInjectedProps on _uiViewProps, scoped to that component's own view.

There is no resolve accessor on the snapshot, and route.transition.injector().get(token) is not a substitute: that is the transition's root injector, not the view's resolve context, so it resolves a different token set than the component's own view sees.

The split the Effect sample app follows:

  • resolved data → _uiViewProps.resolves
  • active state, and anything that outlives a single activation → a SubscriptionRef, selected by ref controllers

See it in a real app ​

The Effect sample app is a complete application built on these controllers. It is behaviorally identical to the vanilla sample app (which uses TransitionController), the MobX sample app and the Signals sample app, so the four codebases can be compared file-by-file to see exactly what the Effect idiom changes.

For a smaller picture, the Hello Galaxy (Effect) example is the galaxy tutorial rebuilt on these controllers, installed from npm.

Further reading ​