The Case for Services
Published 2025-02-02, About 8 minute read.
I've mentioned before that one of the first difficulties you encounter as you
adopt web components is
communicating between components.. If only we could reach out and share instances between web components,
especially when web components might be loading dynamically and at different
times.
Hear me out:
Services are a fantastic way to communicate
and share functionality and state between web components.
In this post, I'll talk about:
-
What a service system is (according to yours truly) and what as service
system should provide
-
Draw some distinctions in how a service system differs from just using
events or context or the window to share things
- Sketch out an example for a service system.
- Describe the benefits I see in choosing this strategy
And I'll also share a project I've been working on!
Okay, so what is a "service"? 📎
A service is some object that contains functionality and sometimes contains
some state. This service is a singleton, and should be available for anyone to
use and subscribe to changes in state
Another important detail is that consumers of services should not have to
worry about the instantiation and lifecycle of a service. If a consumer calls
on a service, the service system should provide a reference by either just
providing it from some store of services or instantiating one for the
consumer. This means that services are often lazy and won't
exist until they're needed.
If you've ever used Ember.js, they have a
construct called services
that are a big inspiration for what I'm proposing here. The services I'm
proposing is an implementation of
dependency injection
that works especially well with web components or micro-framework situations.
So, my goal is to have a system that:
- Lazily instantiates services, only when they're called on
- The consumer does not have to worry about instantiation details
- The system should be able to provide to any framework
-
It should support reactivity, or be able to notify consumers when a change
of state happens
- Ideally should allow services to use services internally
So why not just use context? 📎
Okay, about context as a architecture: context is an
excellent way to avoid prop drilling.
If you had read the docs on react context
in its first versions it was included in, you would also have noticed a lot of
warnings about context. Context was meant to avoid prop drilling, but it makes
it hard for component re-use. Reading it now, it's kind of funny how much the
docs try to talk you out of using context.
In Lit, we run into some of the same restrictions outlined in those warnings
from React. Context can only be consumed by descendents of the provider of the
context in the DOM tree. Also, components need to set up specific context
events and code to receive specific contexts from its ancestors. Above all
this, the provider instantiates the context whether there are consumers or
not.
I guess I'm trying to say context is great for certain small scopes in an
application, but it was not meant to be a general way that "services" would be
instantiated and shared generally.
Why not just share references by events? 📎
Components really should use
provider patterns if
tight coupling is expected. For example, a list and list items and
functionality with a list makes a perfect situation where communication is
heiarchical and coupled by design. So there are lots of places you wouldn't
reach for a service.
But that's just the thing- what if you need general APIs available globally?
What if you don't want components coupled tightly to where they are in the
DOM? What if you have components entering and leaving the DOM that need to
work with these APIs? Events and sending references with events is awkward and
not ideal.
Event ping pong 📎
One thing we tried early at my place of employment was using an event bus to
send/receive messages. This seems to work well for things, but there were a
few problems. First, if a component wasn't in the DOM yet, we had to set up an
event that notified all involved that the component was indeed now in the DOM.
Then, when the component finally announced it's arrival, components could send
it information. But second, events are annoying to set up, tear down, and
manage.
It went something like this
-
Component 1: Hey, component two, I'm waiting for you to
mount in the DOM
- Component 2: Alright, I'm mounted. What is it?
-
Component 1: Sweet, I need
data from you,
here's a callback, can you send it through this callback?
- Component 2: Cool, here's
data
This. Was. Aweful. And not only was it painful to set up and tear down event
listerners like this (even when we abstracted that process out to nice
methods), we had trouble debugging, testing, and keeping track of all who were
subscribing and who needed to be subscribing for state changes.
Okay, so why not just share class instances on the Window? 📎
I'm growing increasingly less convinced we need to worry about window
pollution or keeping things private.
In my post on stores, I just slapped my store references in the globalThis and
retrieved them in other modules. This works... fine, but there's no system or
convention or expectation to organize code this way, and it's completely run
time.
I'll just say, this is no way to run a ship! Let's make things formal...
Enough chit-chat, let's look at an implementation! 📎
For my implementation, I actually stick pretty close to the
web components context protocol. The idea is there's one thing running that listens to
get-service events called the service provider. And this provides
services to all who dispatch that event.
The difference between context providers and this service-provider is that
this service-provider instantiates services if they don't exist, and keeps a
record of which services exist already. It also listens to and dispatches
events off of the window. This allows components to register services event if
they are not yet connected to the DOM.Lastly, this service provider doesn't
have to be a web component. It can be a class you run in a script, as long as
it's run first thing.
This is a very simple service- all it does it provide a string "foo." But
There's a couple nice things about this minimal example.
If you have multiple components reaching out for this service, only one
service is created and returned. The provider handles all this, and you' don't
have to worry about order or when the new consumers are brought into the app.
(The only exception being that the service provider needs to be instantiated
first.) Here's an example where you can add more
my-component components, and they retrieve the service just fine.
If you're using a web component, you can easily inspect the service, because
the service reference is attached as a property on that web component. If you
need to debug a service, it's easy to see and inspect. In a framework, it's
not too difficult either, you would just need to debug that component through
whatever component dev tools are availabe.
And speaking of frameworks, this would be super easy to do in
any framework. You can easily just use events to reach out to service
references, and the framework doesn't have to know how you got them.
Of course, you would need to deal with reactivity. This is something we
touched on a bit in the last
post about stores. I have a lot to say about this, but reactivity in this service system is a
bit complicated. (Maybe my next post?)
Here's an example using
petite-vue, a really cool
project which lets you sprinkle in vue-like components throughout a page. One
thing you'll notice in this example is that vue reactivity catches changes in
the service, and when the service changes its properties the view renders the
new value. Pretty nice perk to using petite-vue!
{{ myService.data }}
And, just to beat the proverbial dead horse, is an example with a service
being used in React:
I didn't try to hook up this service with reactivity to React, but if you're
interested, you could find a way to make a hook that uses
useSyncExternalStore
to make React react to changes
from external sources. (Also probably a topic for a whole blog post 😅)
wc-services 📎
So I made a thing:
wc-services. This
package provides a service-provider component, a
service fetching function, and some niceties to make using
services easier to work with and reactive!
The only additional part of this package that isn't covered here is that
services need to extend a base class. This is required only so services can
track consumers and notify consumers when state changes. So a service might
look like this:
And a Lit component might look like this:
To see this in action on stackblitz, check out this example:
Let me know what you think! 📎
Again, I would love to hear what you think and any suggestions you might have. Find me on Bluesky or Mastodon. I also have an RSS feed here