When the React team invented hooks, they wanted to achieve co-location of related code, allowing for more granular and cleaner code reuse. And they did a fantastic job at coming up with a solution that enables it - which is why we immediately adopted it for Melody, too. Co-locating related code, allowing us to compose a solution out of different, separated concerns, has always been a goal of Melody. It's also why Melody features a template engine. Related code should be together and multiple pieces can be composed together to form something new.
In this chapter, we'll explore what "Separation of Concerns" means, how we can put it into practice, and how we can gain an advantage out of doing so.
Identifying different concerns
Assume we want to implement an item component which fetches data from an endpoint using an API and which has a concept of "slideouts", information containers that slide out of the item, of which only a single one can be opened at any given time.
An initial implementation might look like the following, with the state hook taking care of everything:
function useItem(props) {
// fetch item data
const [getItemData, setItemData, itemData] = useAtom();
useEffect(() => {
getItemData(props.itemId).then(setItemData);
return () => setItemData(null);
}, [props.itemId]);
// slideout handling for rating & info
const [isInfoSlideoutOpen, setInfoSlideoutOpen, infoSlideoutOpen] = useAtom();
const [isRatingSlideoutOpen, setRatingSlideoutOpen, ratingSlideoutOpen] = useAtom();
const infoSlideoutToggle = useCallback(el =>
fromEvent(el, 'click').subscribe(
() => {
// make sure only one of them is open at a time
setInfoSlideoutOpen(open => !open);
setRatingSlideoutOpen(open => false);
}
)
);
const ratingSlideoutToggle = useCallback(el =>
fromEvent(el, 'click').subscribe(
() => {
setInfoSlideoutOpen(open => false);
setRatingSlideoutOpen(open => !open);
}
)
);
// putting it all together
return {
...props,
itemData,
infoSlideoutOpen,
ratingSlideoutOpen,
infoSlideoutToggle,
ratingSlideoutToggle
};
}
Extracting concerns
Let's look at the example above and try to extract different concerns out of it. Please note that there are multiple ways to do so and that the best one to choose depends on your priorities and expectations towards the code.
We'll focus on making it possible to change the logic that relates to how slideouts are being opened. In our example above, we defined it so that only a single slideout can be open at any given time. We're now preparing our code to also enable items that can have more than just one slideout open.
function useItemData(itemId) {
const [getItemData, setItemData, itemData] = useAtom();
useEffect(() => {
getItemData(props.itemId).then(setItemData);
return () => setItemData(null);
}, [itemId]);
return itemData;
}
function useSlideout(name) {
const [isOpen, setOpen, open] = useAtom();
const toggleRef = useCallback(el =>
fromEvent(el, 'click').subscribe(
() => {
// we switch to native event dispatching to
// achieve higher independence between slideout data (this hook)
// and the logic of what happens when you try to open one
el.dispatchEvent(new CustomEvent('slideoutRequested', {
detail: name
}));
}
)
);
const slideout = {
name,
isOpen,
setOpen,
open,
toggleRef
};
return slideout;
}
// this logic is used when only a single slideout might be open at
// any given point in time
function useToggleLogic(...slideouts) {
return useCallback(el =>
fromEvent(el, 'slideoutRequested')
.subscribe(event => {
event.stopPropagation();
const { name } = event.detail;
[infoSlideout, ratingSlideout].forEach(candidate => {
candidate.setOpen(open => name === candidate.name
? !open
: false);
});
}),
// the re-eval condition is dependent on the slideout names
// that makes it a bit more reliable
// note that we could've also used "setOpen" as key
// but not the slideout object itself since that is
// re-created on every render (since it includes "open")
[...slideouts.map(slideout => slideout.name)]
);
}
// this is what an item might look like in which only a single
// slideout can be opened at any time (think: accordion-style)
function useItemWithSingleSlideout(props) {
// fetch item data
const itemData = useItemData(props.itemId);
// slideout handling for rating & info
const infoSlideout = useSlideout('info');
const ratingSlideout = useSlideout('rating');
// logic for what to do when a slideout is requested
const rootRef = useToggleLogic([infoSlideout, ratingSlideout]);
// putting it all together
return {
...props,
rootRef,
itemData,
infoSlideout,
ratingSlideout,
};
}
// let's show how to reuse most of the code to create something different:
// an item which can toggle multiple slideouts independently from each other
// and where multiple slideouts can be open at any given time
function useToggleMultiLogic(...slideouts) {
return useCallback(el =>
fromEvent(el, 'slideoutRequested')
.subscribe(event => {
event.stopPropagation();
const { name } = event.detail;
[infoSlideout, ratingSlideout].forEach(candidate => {
if (candidate.name === name) {
candidate.setOpen(open => !open);
}
});
}),
// the re-eval condition is dependent on the slideout names
// that makes it a bit more reliable
// note that we could've also used "setOpen" as key
// but not the slideout object itself since that is
// re-created on every render (since it includes "open")
[...slideouts.map(slideout => slideout.name)]
);
}
// reuse the individual parts
function useItemWithMultiSlideout(props) {
// fetch item data
const itemData = useItemData(props.itemId);
// slideout handling for rating & info
const infoSlideout = useSlideout('info');
const ratingSlideout = useSlideout('rating');
// logic for what to do when a slideout is requested
// this is the part which we're replacing
const rootRef = useToggleLogic([infoSlideout, ratingSlideout]);
// putting it all together
return {
...props,
rootRef,
itemData,
infoSlideout,
ratingSlideout,
};
}
Using different templates for the same data
When you're coming from a React background, the following might sound like blasphemy: You can use different views for the same data. And you can use the same view for different data.
Melody puts a larger focus on making it easy to switch out the view since we believe it is more common to experiment with visually modified components than with a different logic. Both are very easy to achieve either way.
The traditional way to compose a Melody component is by using the createComponent function and giving it the state hook and the template.
Now let's assume we want to experiment with using a checkbox instead of a toggle button. We could rewrite everything from scratch, or we can reuse most of it and switch out the template:
The degree of code reuse that you can achieve with Melody depends on how and where you cut your components into pieces. Melody is designed to allow you to cut at the most obvious places (separating view and data logic) but in the end its your decision where to split your components.
As a rule of thumb for when you're aiming at reusability: Divide and Conquer. Divide components into individual pieces, always challenging yourself to really separate concerns not just between view and data but also in how they are separated into pieces as well. And then recompose the entity out of the individual parts.
Appendix
An alternative Item state hook
As mentioned before, depending on our needs we might have wanted to go for a very different implementation - and that is totally fine. There's rarely a "right" or "wrong" choice to make when you define the boundaries of your logic and how they fit together. Sometimes you'll make the right choice and a few months later there's a request for a change that requires you to refactor your code to use a different separation.
// we still want the separate data handling hook
function useItemData(itemId) {
const [getItemData, setItemData, itemData] = useAtom();
useEffect(() => {
getItemData(props.itemId).then(setItemData);
return () => setItemData(null);
}, [itemId]);
return itemData;
}
// but our handling of slideouts is very different
function useSlideouts(...names) {
// we use a single variable to remember which one is currently
// open (since we don't have to deal with cases where many could be open)
const [openSlideout, setOpenSlideout] = useState(null);
// and we generate slideout objects from names
return names.map(name => {
// they still have a nice ref for events
const toggleRef = useCallback(el =>
fromEvent(el, 'click')
.subscribe(() => setOpenSlideout(name))
);
// minor optimization: only recreate the slideout objects
// if the currently open one has changed
return useMemo(() => ({
name,
open: name === openSlideout,
toggleRef
}), [openSlideout]);
});
}
// this is what an item might look like in which only a single
// slideout can be opened at any time (think: accordion-style)
function useItemWithSingleSlideout(props) {
// fetch item data
const itemData = useItemData(props.itemId);
const [openSlideout, setOpenSlideout] = useState(null);
// slideout handling for rating & info
const [infoSlideout, ratingSlideout] = useSlideouts('info', 'rating');
// putting it all together
return {
...props,
itemData,
infoSlideout,
ratingSlideout,
};
}
Depending on your actual needs, this might be a much better implementation. It is less complex, less code and doesn't come with unnecessary extension points. At the same time, it gives us a reusable and completely decoupled way to define that something within a component can be opened (but only one thing at any given time). We could use that same useSlideouts hook to define an accordion component, for example.
That's the power and beauty of separation of concerns.
If we already know that we won't need that capability, we could have chosen a simpler implementation where we only keep track of the currently opened slideout and then ask the view layer to deal with showing the right one. You can find that example further below in .