The Observable Overture
Unearth an underrated gem that enhances component integration for flexible, scalable, and notably maintainable solutions.
Let's cut to the chase
Even the most seasoned developers might miss out on hidden gems in the ever-evolving web development landscape. One powerful yet frequently overlooked concept is the Observable pattern that empowers developers to establish seamless connections between different elements.
Picture this scenario: you need to synchronize input fields, where any text entered into one input must instantly mirror into the other and vice versa. These input fields are required, so we should disable all submit buttons on a page when fields are empty. Yes, back to 2010, to kilometer-long landing pages and pure – like Walter White's masterpiece – Javascript.
The first thing that comes to mind is relatively straightforward. We begin by disabling all buttons, assuming our fields are empty when loading the page. Subsequently, we pinpoint all input fields within the page and attach event listeners to them, enabling the detection of input events. We dynamically toggle the buttons with each input event and update the values within the corresponding fields.
const buttons = document.querySelectorAll('button');
const inputs = document.querySelectorAll('input');
buttons.forEach((button) => button.toggleAttribute('disabled', true));
inputs.forEach((input) => input.addEventListener('input', onInput));
function onInput(event) {
buttons.forEach((button) => button.toggleAttribute('disabled', !event.target.value.length));
inputs.forEach((input) => event.target !== input && (input.value = event.target.value));
}
Building Boundaries
The example above might fulfill your current needs. However, the existing event handler bears excessive responsibilities. It responds to input events by modifying the state of all associated elements (input fields and buttons). Also, introducing new requirements, such as validating and displaying an error state or enabling (disabling) other fields, could lead to an unmaintainable codebase. This troublesome situation isn't as far from you as you might think. It can catch you right behind the corner.
To address this challenge, we need to establish clear boundaries between elements, and the Observable pattern can significantly assist in achieving this objective. By implementing this pattern, we construct a centralized state that acts as a hub, notifying each subscriber about changes in its data.
Now, let's delve into the implementation of a rudimentary version of this state:
function buildObservable (initialValue = null) {
const observers = new Set();
let value = initialValue;
/** The `next` method preserves and emits a new value to subscribers (observers). */
function next(newValue) {
value = newValue;
observers.forEach((observer) => observer(value));
}
/**
* The `subscribe` method adds a callback function to a list of subscribers
* and initiates an initial call with the current value.
*/
function subscribe(fn) {
observers.add(fn);
fn(value);
}
/**
* The unsubscribe method removes a specified callback function from the list
* of subscribers when the function is passed as an argument. If no specific
* function is provided, it deletes all subscribers from the list.
*/
function unsubscribe(fn) {
if (fn) observers.delete(fn);
else observers.clear();
}
return {
next,
subscribe,
unsubscribe,
}
}
Please note that the solution mentioned above is not production-ready. It lacks essential features such as validation of subscriptions, validation and comparison of new values against the old ones, debouncing (throttling), and pipes. I recommend exploring suitable solutions within the NPM ecosystem, such as RxJS, to address these requirements.
Once you've found a suitable solution utilizing the Observable pattern, tackling the task becomes remarkably straightforward: create an instance of your observable, implement observers for updating fields and buttons, and subscribe to changes.
const buttons = document.querySelectorAll('button');
const inputs = document.querySelectorAll('input');
/** Initialize an instance of our observable. */
const observable = buildObservable('');
/** Monitor changes in the value and update the observable accordingly. */
const onInput = event => observable.next(event.target.value);
inputs.forEach((input) => input.addEventListener('input', onInput));
/** Alter the corresponding fields' value. */
const updateValue = (input) => input.value !== value && (input.value = value);
observable.subscribe((value) => inputs.forEach(updateValue));
/** Toggle the buttons. */
const toggleButton = (button) => button.toggleAttribute('disabled', !value.length);
observable.subscribe((value) => buttons.forEach(toggleButton));
What do we achieve? We acquire more lines of code at the cost of maintainability and flexibility. Each observer now has a singular focus. It remains blissfully unaware of other parts of our application. This separation allows us to seamlessly introduce new features or modify existing ones without worrying about causing unintended issues. However, the last statement is a bit of a stretch because you need to write tests to achieve this.
The Hidden Hero
At this point, you might be wondering if you've seen this before, and you'd be absolutely right. In fact, you work with a slightly modified version of this concept every day—the EventTarget API, the foundational interface that underpins every DOM node. Since 2018, the capability to create its instance manually has been available, paving the way for its application in comunications between elements of your application.
Let's delve into how our previous implementation will appear when utilizing the EventTarget API, replacing the need for custom implementations of the Observable pattern.
const buttons = document.querySelectorAll('button');
const inputs = document.querySelectorAll('input');
/**
* Instantiate the EventTarget. Its constructor doesn't accept any parameters.
* This limitation leads us to a significant point: the interface does not support
* data storage out of the box. Consequently, new observers will not receive the
* latest value during subscription, but what is perfect in our world?
*/
const observable = new EventTarget();
/**
* In our scenario, where data needs to be provided within an event, we utilize
* CustomEvent, also known as a synthetic event. However, a standard Event can be
* employed if your use case doesn't require passing additional data.
*/
const createEvent = (event) => new CustomEvent('input', { detail: event.target.value });
const onInput = event => observable.dispatchEvent(createEvent(event));
inputs.forEach((input) => input.addEventListener('input', onInput));
/** The rest of the implementation remains essentially unchanged from the previous version. */
const updateValue = (input) => input.value !== value && (input.value = value);
observable.addEventListener('input', ({ detail }) => inputs.forEach(updateValue));
const toggleButton = (button) => button.toggleAttribute('disabled', !value.length);
observable.addEventListener('input', ({ detail }) => buttons.forEach(toggleButton));
Conslusion
The Observable pattern, whether custom or build-in implementation, is a powerful tool that enables the creation of seamless connections between diverse elements, promoting flexible and maintainable approaches in web development. While its effectiveness might not translate well in backend applications due to its impact on the control of application flow, its significance in the UI cannot be overstated.