Skip to content

Reactive Components

<ui-view> re-renders routed components automatically. But most apps also have components outside the viewport that depend on router state — a nav header highlighting the active section, a breadcrumb trail, a user menu that appears after login. Lit doesn't know these components care about the router, so by default they render once and go stale.

TransitionController is the core package's answer: a zero-dependency Lit ReactiveController that calls host.requestUpdate() whenever a matching transition event fires.

Basic usage

ts
import { html, LitElement } from 'lit';
import { TransitionController } from 'lit-ui-router';

class NavHeader extends LitElement {
  private transitions = new TransitionController(this);

  render() {
    // Re-evaluated after every successful transition
    return html`
      <span>Current state: ${this.transitions.current?.name}</span>
      ${this.transitions.includes('admin.**') ? html`<admin-toolbar></admin-toolbar>` : null}
    `;
  }
}

No wiring is needed: on hostConnected the controller discovers the router from the nearest <ui-router> (or <ui-view>) ancestor via the ui-router-context event, registers its transition hooks, and deregisters them all on hostDisconnected — nothing leaks when elements come and go from the DOM.

Router discovery

Controllers find the router. Anything else — a store, an element that is not a Lit host, a third-party component — asks the same provider directly, and <ui-router> answers both ways in from one listener:

ts
import { consume } from '@lit/context';
import { routerContext } from 'lit-ui-router/context';
import type { UIRouterLit } from 'lit-ui-router';

class UserMenu extends LitElement {
  @consume({ context: routerContext })
  router!: UIRouterLit;
}

It reads both ways: a ContextProvider of routerContext on any ancestor satisfies seekRouter too. subscribe gets one call and a no-op unsubscribe — <ui-router> takes its router on connect and does not swap it.

When there is no tree at all to bubble through — a server render, a test — the same entry publishes a router directly: withRouterSync(router, run) scopes it to one synchronous call, and anything that call reaches asks getScopedRouter() for it. provideRouter(root, router) is the event-target side of the same hand-off, answering context-request on a plain EventTarget; see Server-Side Routing.

Reading router state

The controller exposes the essentials directly:

MemberWhat it returns
currentThe current StateDeclaration (globals.current)
paramsThe current parameter values (globals.params)
transitionThe most recent observed Transition
includes(state, p?)StateService.includes — supports glob patterns like 'admin.**'
router / globalsThe discovered UIRouter instance and its globals

Scoping with criteria and callbacks

By default the controller observes every successful transition. Options narrow that down and let you run logic before the re-render:

ts
class UserDetail extends LitElement {
  private transitions = new TransitionController(this, {
    criteria: { to: 'users.detail' },
    callback: () => this.loadUser(this.transitions.params.userId),
  });
}
  • criteria — a HookMatchCriteria limiting which transitions notify the host (to, from, glob patterns, predicate functions)
  • callback — invoked before requestUpdate() with the transition and the reason ('onSuccess', 'hostConnected', …)
  • events — which lifecycle events to observe; defaults to ['onSuccess'], and accepts any of 'onBefore' | 'onStart' | 'onSuccess' | 'onError'
  • router — an explicit router instance, skipping context discovery

For 'onBefore' and 'onStart' events, the callback's return value is passed back to UI-Router as a HookResult — so a controller can even cancel or redirect pending transitions.

Reconnect safety

On every (re)connect the controller synchronizes once with the router's current state before any new transition fires. Components that enter the DOM after navigation completed — or that are detached and re-attached, as with sticky states — render fresh values immediately instead of waiting for the next transition.

A nav link needs more than "the router moved": it needs to know whether its state is the current one. SrefStatusController exposes exactly that — active, exact, entering, exiting — and leaves the rendering to you:

ts
import { html, LitElement } from 'lit';
import { classMap } from 'lit/directives/class-map.js';
import { srefHref, SrefStatusController } from 'lit-ui-router';

class NavLink extends LitElement {
  private users = new SrefStatusController(this, { state: 'users' });

  render() {
    return html`<a
      href=${srefHref('users')}
      class=${classMap({ 'nav-link': true, active: this.users.active, disabled: this.locked })}
      aria-current=${this.users.ariaCurrent()}
      >Users</a
    >`;
  }
}

This is the composition path srefActiveClass cannot offer: a class attribute holds one toggling directive, so the directive and classMap cannot share it. Reach for the directive when the link's classes are all the component needs; reach for the controller when the status is one input among several.

The controller takes the same target as the directives — state, params, options, or no state at all to watch the srefHref links the host renders — plus:

  • retarget({ state, params, options }) — point it at another state, for a host that takes the state as a property
  • ariaCurrent(value?) — the aria-current token for the current status, or nothing; value accepts a token or { exact, active }
  • router — an explicit router instance, skipping context discovery. Passing it also computes the status in the constructor, so it is there for the very first render and needs no DOM at all.

Only a change in one of the four flags requests a host update, so transitions that leave the link alone cost nothing.

The directives themselves are compared with uiSref/uiSrefActive in Design System Links, and their server-side story is in Server-Side Routing.

See it live

The same problem, solved with the MobX bindings: <app-root> sits outside every <ui-view>, so it never receives fresh view props, and reaction controllers keep its breadcrumb and visit counter in sync. Swap RouterReactionController for TransitionController and the shape is identical — a controller on the host, a value read in render().

In both idioms the controllers only read. The example's one write to app state — recording an arrival — sits in an onSuccess hook, because an effect caused by navigating belongs to the navigation, not to a component watching it.

This is the minimal version — the solar-system tutorial with a store layered in where the router stops helping, small enough to read in one sitting. For the full comparison, the two sample apps below build the same application twice, once with each idiom.

Open in StackBlitz

See it in a real app

The vanilla sample app is built on this pattern — its App, nav header, and message compose view each use a TransitionController. The behaviorally identical MobX sample app solves the same problems with the observable store and reaction controllers from lit-ui-router-mobx, one of the companion packages — if your app already uses MobX, prefer those bindings; the two codebases compare the idioms file-by-file.