lit-ui-router-effect
lit-ui-router-effect provides Effect bindings for lit-ui-router: a SubscriptionRef of the router's state 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.
Release candidate
The package publishes on a 0.1.0-rc line while the atlas adopts it as its second consumer. The API below is live and covered by tests; the surface freezes at 0.1.0 once that adoption has exercised it.
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 effect
# or
pnpm add lit-ui-router-effect effectlit-ui-router, lit, effect, and @uirouter/core are peer dependencies.
Quick start
import { html, LitElement } from 'lit';
import { Data, 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. Data.struct gives the selection value
// equality, so Equal.equals compares it structurally.
private active = new RouterRefController(
this,
(route) =>
Data.struct({
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.
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.
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 connectsoptions.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.equalsforDatavalues, 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
RefController
RefController is the generic primitive behind RouterRefController — the same selector/options contract over any SubscriptionRefs, not just the router's:
import { Data, 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) => Data.struct({ 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 pass the router yourself with options.router for a host that lives outside the router's DOM.
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 recomputedDatavalue 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) and the MobX sample app, so the three 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.