lit-ui-router-effect
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
npm install lit-ui-router-effect ui-router-effect effectlit-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
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.
| Member | Description |
|---|---|
current | The current StateDeclaration (globals.current) |
params | The current RawParams (globals.params), a fresh object per transition |
transition | The 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:
new RouterRefController(host, selector, options?)selector: (route: RouteSnapshot) => T— the selected expression; the result is exposed as.valueoptions.router— explicit router instance, skipping context discovery; the route is then read at construction, so.valueis live before the host connects. A thunk (() => UIRouter | undefined) is resolved at construction and again on everyhostConnected; when it returnsundefined, the controller discovers the router as if the option were omitted. Discovery subscribes, so on a page served bylit-ui-router-ssrthe host follows<ui-router>from its placeholder router to the app's whenhydrateRoot()sets it. Alit-ui-routerthat does not hand that router to subscribers leaves the host on the placeholder; hand such a host its router here or throughsetRouter()options.onChange— effect invoked when the selected value changes (and once on every (re)connect); useful for resetting component state from route paramsoptions.equals— comparer for precise, value-based change detection (Equal.equals, or any(a, b) => boolean); defaults toObject.isoptions.initialValue— the value.valuecarries before the router is discovered: beforehostConnected, and while a host has no router contextoptions.runtime— the runtime the subscription fiber is forked on; defaults to Effect's default runtime, and aManagedRuntimesatisfies 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:
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 anyReactiveControllerHost) - Dependencies are explicit: the refs tuple names exactly which state drives the host
equals: Equal.equalsavoids 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
- API reference
- Effect — SubscriptionRef
- Lit — Reactive Controllers
- On the Altitude Atlas — this package as a brick in sheet 2's model in the round, sized by its authored files and seated on what it peers