Melody offers many ways to deal with events. In this chapter, we'll look at how it works and how far you can take it.
Refs in Melody
A core concept for event handling in Melody is the ref. A ref is a function which you pass into a template and then assign it to the ref attribute of elements. Unlike other frameworks, Melody enables you to use the same ref for multiple elements.
The term has been coined by React - as far as we know. You can think of it as a way to reference an actual Element in the DOM for further use.
function useTodoItemModel(props) {
const todoHandlerRef = useCallback(todoElement => {
// this function is called whenever the todoHandlerRef is
// assigned to a new element
const handle = () => console.log('clicked!');
todoElement.addEventListener('click', handle, false);
return {
unsubscribe() {
// the unsubscribe method is invoked whenever the
// referenced element is removed or the ref handler function is
// changed
todoElement.removeEventListener('click', handle, false);
}
};
});
return {
todoHandlerRef,
...props,
};
}
<li>
{{ name }}
<button ref="{{ todoHandlerRef }}">Delete</button>
</li>
We call the function that is being assigned to the ref attribute the ref handler function. The ref handler accepts the referenced element and returns an object with an unsubscribe method that is invoked when the element is removed or the ref handler has been changed.
This architectural choice enables Melody to reuse the same ref handler across multiple elements and thus contributes to avoiding code duplication.
The example above already shows one very stable way to handle events.
With Rx.js
Melody works especially well with Rx.js for event handling. An Rx.js Subscription object fulfills the contract of the subscription object that Melody needs.
By leveraging Rx.js you also enable yourself to compose various parts of an event handler together. This is an incredibly powerful technique and helps to reuse code.
An Rx utility
Since both Melody and Rx are built to be extensible, it is possible to come up with various abstractions that enable an even easier way to solve a particular problem. Below, we'll implement one such abstraction that makes it easier to compose Rx streams for event handling.
// a utility for composing pure event handlers
const useEventStream = (eventName, ...steps) =>
useCallback(
el =>
fromEvent(el, eventName)
.pipe(...steps)
.subscribe(),
[]
);
function useSelectableCounter(props) {
const { count, increaseCounter, decreaseCounter } = useCounterState(props);
const { isSelected, toggle } = useSelectable(props);
const increaseCounterRef = useEventStream(
'click',
filter(isSelected), // isSelected is an accessor function from a useAtom
tap(increaseCounter)
);
const decreaseCounterRef = useEventStream(
'click',
filter(isSelected),
tap(decreaseCounter)
);
return {
count,
isSelected: isSelected(),
toggle,
increaseCounterRef,
decreaseCounterRef
};
}
Event handling and element access
It might not be obvious yet how we can add event handlers while also getting access to an element if, for example, we want to do some animation with it. In the example below, we'll assume that new elements are added with an opacity of 0 and that we want to animate it to 1, fading the element in. Please note that this is a small example and that you should, of course, use CSS instead to do this animation.
function useTodoItemModel(props) {
// useRef is a useful tool for storing an element value
// in this example, however, we are now limiting the feature to a single
// referenced element
const todoElementRef = useRef(null);
const todoHandlerRef = useCallback(todoElement => {
// this function is called whenever the todoHandlerRef is
// assigned to a new element
const handle = () => {
// When the element is clicked...
// ... fade it out
if (todoElementRef.current.animate) {
const animation = todoElementRef.current.animate([
{ opacity: 1 },
{ opacity: 0 }
], { duration: 1000 });
// ... and once the animation is completed, actually remove it
animation.onfinish = () => props.removeTodoItem();
} else {
props.removeTodoItem();
}
};
todoElement.addEventListener('click', handle, false);
// so far everything was the same but if this function is executed
// then the element was also just added
// we'll use the Web Animation API to do this small animation
// details here: https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API/Using_the_Web_Animations_API
if (todoElement.animate) {
todoElement.animate([
{ opacity: 0 },
{ opacity: 1 }
], { duration: 1000 })
}
// also keep a reference to it
todoElementRef.current = todoElement;
return {
unsubscribe() {
// the unsubscribe method is invoked whenever the
// referenced element is removed or the ref handler function is
// changed
todoElement.removeEventListener('click', handle, false);
}
};
});
return {
todoHandlerRef,
...props,
};
}
Common Mistakes
Passing event handlers to the template
In Melody it is also technically possible to define event handlers and assign them within the template.
<li>
{{ name }}
<button onclick="{{ todoHandlerRef }}">Delete</button>
</li>
This is similar to how you might have seen it in React. However, it is not the preferred way in Melody and you really should avoid it.
One of the main advantages of Melody is that you get a very clear Separation of Concerns. You have a JavaScript function which deals with the state of a component and you have a template that deals with how it is being rendered. This style of event handling violates that contract and causes stronger coupling between the logic and the visual representation.
To illustrate: If you choose another approach to event handling, such as Rx, and you need to add additional event handlers, or you need to switch from native event handling to hammer.js or a similar library, then you can do so without changing the template at all. Because the template doesn't know anything about what you're doing with the element.
However, if you've chosen to go for the option of specifying the event handler within the template, then you'll need to change both. Things like Hammer.js on the other hand really need to use refs so you'd need to completely change the approach. You'll also risk more merge conflicts, you're making it harder to reuse the same view with a different state hook and in general you're just slowing yourself down.
How to avoid: Always prefer to use refs for attaching event handlers. They are just as easy to use (see abstractions above), more powerful and help to avoid issues around code reuse and complexity.
Avoiding the platform
Another React pattern that is commonly used in Melody but does not necessarily offer any benefit is that of passing event handlers down from a parent component to a child component. You might have seen that in one of the previous examples.
When taken to an extreme you'll create a lot of strongly coupled components due to incredibly large API surfaces. And that is very unfortunate considering that browsers already have a beautiful mechanism to solve this problem: Event bubbling.
function useTodoItemModel(props) {
// useRef is a useful tool for storing an element value
// in this example, however, we are now limiting the feature to a single
// referenced element
const todoElementRef = useRef(null);
const todoHandlerRef = useCallback(todoElement => {
// this function is called whenever the todoHandlerRef is
// assigned to a new element
const handle = () => {
// When the element is clicked...
// ... fade it out
if (todoElementRef.current.animate) {
const animation = todoElementRef.current.animate([
{ opacity: 1 },
{ opacity: 0 }
], { duration: 1000 });
// ... and once the animation is completed, actually remove it
// instead of requiring a "removeTodoItem" property...
// animation.onfinish = () => props.removeTodoItem();
// ... we can also dispatch a custom DOM event
// see: https://developer.mozilla.org/en-US/docs/Web/Guide/Events/Creating_and_triggering_events
// for reference
animation.onfinish = () => todoElementRef.current.dispatchEvent(new CustomEvent(
'removeTodoItem',
{ detail: props.name }
));
} else {
todoElementRef.current.dispatchEvent(new CustomEvent(
'removeTodoItem',
{ detail: props.name }
));
}
};
todoElement.addEventListener('click', handle, false);
// so far everything was the same but if this function is executed
// then the element was also just added
// we'll use the Web Animation API to do this small animation
// details here: https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API/Using_the_Web_Animations_API
if (todoElement.animate) {
todoElement.animate([
{ opacity: 0 },
{ opacity: 1 }
], { duration: 1000 })
}
// also keep a reference to it
todoElementRef.current = todoElement;
return {
unsubscribe() {
// the unsubscribe method is invoked whenever the
// referenced element is removed or the ref handler function is
// changed
todoElement.removeEventListener('click', handle, false);
}
};
});
return {
todoHandlerRef,
...props,
};
}
That event can then be caught by a parent component for further use
How to avoid: Leveraging the platform and the tools it provides can be incredibly useful to create loose coupling and performance improvements by relying on techniques such as event delegation.