This lesson walks through designing and implementing a complete custom controller end to end, using a media query tracker as a realistic, self-contained example.
Designing a MediaQueryController
A media query controller should let a component reactively know whether a given CSS media query (like (max-width: 600px)) currently matches, updating automatically as the viewport changes — using the native window.matchMedia() API and its own change event, wrapped in the ReactiveController interface.
Designing a good controller starts the same way as designing a component's properties: decide what public state/methods it exposes, what configuration its constructor needs, and exactly which lifecycle hooks are required to set up and tear down correctly.
The initial matches value is captured synchronously in the constructor, so the very first render already reflects the correct state before any change event has fired.
The controller is instantiated once, typically as a private field, right where the component needs it.
render() reads this.#isMobile.matches directly — no extra property/state wiring needed on the host component itself.
The controller is fully reusable across any component that needs to react to any media query, not just this one component.
Consider accepting the media query string as a constructor parameter (as shown) rather than hardcoding it, for maximum reusability.
Custom Controller Design Checklist
Questions to answer while designing any new controller.
Question
For MediaQueryController
What public state does it expose?
matches: boolean
What configuration does its constructor need?
The host, plus the media query string
What does hostConnected() set up?
A change listener on the MediaQueryList
What does hostDisconnected() tear down?
That same change listener
When does it call requestUpdate()?
Inside the change event handler
Testing a Controller Independently of Any Component
Because a controller is a plain class, not tied to any specific component, you can unit test its logic by passing a minimal fake host object implementing just addController and requestUpdate, without needing to render a real Lit component at all.
This kind of isolated, framework-free testability is one of the practical advantages of the controller pattern.
Publishing a Controller as Its Own Package
Because controllers have no dependency on any specific component's internals, a well-designed one can be extracted into its own small, independently versioned package and reused across multiple projects or teams — exactly the model official packages like @lit-labs/task and community media-query controllers already follow.
Common Mistakes
Hardcoding configuration (like the media query string) directly inside the controller instead of accepting it as a constructor parameter.
Forgetting to capture the initial state synchronously in the constructor, causing the first render to show a default/incorrect value briefly.
Not testing a controller in isolation with a fake host, missing an easy opportunity for fast, framework-free unit tests.
Building a controller so tightly coupled to one specific component's internals that it can't actually be reused anywhere else.
Key Takeaways
Designing a controller follows the same discipline as designing a component's public property API: decide what's exposed and configurable upfront.
hostConnected()/hostDisconnected() should always be a matched setup/teardown pair, exactly like a component's own lifecycle hooks.
Controllers can be unit tested in isolation using a minimal fake host object.
A well-designed controller can be extracted into its own reusable, independently published package.
Pro Tip
Write your first custom controller for a piece of logic you already have duplicated (even slightly differently) across two or more existing components — extracting a controller from real, existing duplication teaches the pattern far more concretely than inventing a new controller for a hypothetical need.
You've completed the controllers series. Next, learn how to build accessible forms with Lit components.