Cohesive Design for a React Component

Afiz Momin
9 min readDec 12, 2020

--

Introduction

Backend developers, often who work with Object-oriented programming languages, are well-versed with the use of design patterns and SOLID principles for better quality, maintenance, and conciseness of the program. This is often left on the sidewalk by the front-end developers when designing the React components. One of the important concepts called “cohesion” is far ignored which causes spaghetti code and the architecture sooner or later starts to smell. The change in one place causes an apocalypse — maybe that’s too far said, affects multiple unknown places, and if this is much prevalent in your codebase it could end up rewriting your application.

Many developers design the React components that make it harder to understand because it is complex, intertwined relationships are difficult to interpret. We are so used to quickly write a new component on a storybook and tack it to the application without much thought on the design of the component. Like me, most of you have started writing a new React component and that is perfectly fine to get started.

Working on the same application over time and iteratively adding more functionalities to existing components soon ran into the problems which made me a fanatic of emphasizing cohesion in the components. Unlike other posts, the scope of this post is limited to the topic of cohesion, separation of concerns, and loose-coupling in the context of React components. So let’s get started.

What is Cohesion?

In computer science, cohesion refers to the measure of the strength of different elements such as functions, classes, data, presentation, business logic, and services that are tied together. In other terms, it is how focused is a portion of code for a unified purpose.

High cohesion means less coupling and it is often tied to the topics of loose coupling and separation of concerns which we will see in the following sections. It would be easy to understand cohesion by exploring these two related topics.

It may be confusing whether cohesion defines loose coupling and separation of concerns or the loose coupling itself does. Instead of understanding from different blogs, I would rather keep it to how I understand them. In my opinion, these topics are different but related to each other.

Separation of Concerns (SoC)

This is a fairly known topic for many backend developers which is to do with separating things to group them at a place where they are used and are easy to track and separated from others. For example, a basket of assorted fruits can be segregated into their categories and individual baskets and place such that to match apples to apples, about the same as in a superstore.

How cohesion is related to SoC?
Given a basket of assorted fruits, if you were given a job of replenishing the low quantities counting in a basket every hour how difficult it would be? Wouldn’t it be easy to have each fruit in a separate basket and labeled with the name, count, and maximum items? Indeed. We are essentially assigning duties or separating the duties of each basket to maintain these attributes. Based on some statistics of the data, you may choose to arrange baskets. Also, it enables us to make a decision to place most picked fruits nearby and it can be rearranged each week based on customer behaviour. Having a bin with a single responsibility is very much what SoC means and cohesion implies.

A component given a job should do it really well and that should not be responsible for doing other jobs than it is designed for. Though separated, it can be combined to create an abstraction by composing them. This is a building block for the cohesive design.

Loose coupling

Coupling is a connection between modules, classes, or two or more entities. Without this connection, a dependent become useless except all by itself. Low coupling is preferred over strong coupling. The high coupling means more interdependency between classes which makes reuse difficult. The change in one place affects one or more unknown places.

Again take the example of the basket of fruits. When an item is picked from the bucket, it affects the entire count of the bucket and arduous need to keep track of each item. The same objective can be achieved by having each fruit placed in its own basket next to each other counting and replenishing with a breeze. Hmm, this is what loose coupling is all about.

Limiting the coupling reduces the interdependency of the classes and make code more readable. It is not always possible to eliminate the dependency, but always try to keep it very thin. Loose coupling can be achieved with proper separation of concerns and cohesion.

Why Cohesion is important?

You may have come across a code that is technically correct and works fine, but something is not right when you try to add more functionality and extend it. The code we write is supposed to be extensible, maintainable, and easy to read. It is the design of the code that allows to peel layers like an onion to easily re-apply and move around like a lego.

To give a simple example of cohesion — The Lego blocks to build different models that can be interchanged to build new models. In contrast, a change in a single part of the design of the rocket can significantly affect the overall design and can put the whole project in jeopardy.

Well, well, well, all good so far but how to apply them to the React component? Let’s start with an example, always better than a bunch of words.

Problem

We are going to work with a simple Expandable component that toggles the content when clicked on the “show more” link. In the context of React component design, lack of cohesion can have consequences which are illustrated in this simple example.

const Expandable = ({
isOpen,
children,
buttonText,
openIcon = <Icon color="blue" name="angle up" />,
closeIcon = <Icon color="blue" name="angle down" />
}) => {
const [open, setOpen] = useState(isOpen);
const expandableContainerRef = useRef();
const onClickHandler = () => {
setOpen(!open);
}
useEffect(() => {
if (expandableContainerRef && expandableContainerRef.current) {
if (open) {
expandableContainerRef.current.style.maxHeight = expandableContainerRef.current.scrollHeight + "px";
} else {
expandableContainerRef.current.style.maxHeight = 0;
}
}
}, [open, expandableContainerRef])
return (
<div className="container">
<button className="button" onClick={onClickHandler}>
{buttonText}
{!!open ? openIcon : closeIcon}
</button>
<div ref={expandableContainerRef} className="body">
{children}
</div>
</div>
);
};

The example is nice and clean for now. The problem starts when more variations are added to the implementation. To add to the complexity, the body of the Expandable component handles a text with limited words that allows toggling between a set of words and all the content. Similarly, a table with limited rows. Let’s assume you end up implementing as the following code snippet.

const Expandable = ({
isOpen,
children,
buttonText,
variant,
wordsSize,
rowsSize,
text,
openIcon = <Icon color="blue" name="angle up" />,
closeIcon = <Icon color="blue" name="angle down" />
}) => {
let body = children;
const
[open, setOpen] = useState(isOpen);
const expandableContainerRef = useRef();
const onClickHandler = () => {
setOpen(!open);
}
useEffect(() => { if (expandableContainerRef && expandableContainerRef.current) {
const currentScrollHeight = expandableContainerRef.current.scrollHeight + "px";
if (open) {
expandableContainerRef.current.style.maxHeight = currentScrollHeight;
} else {

if (!variant) {
expandableContainerRef.current.style.maxHeight = 0;
} else {
expandableContainerRef.current.style.maxHeight = defaultBodyHeight || currentScrollHeight;
}
}
}
}, [open, expandableContainerRef, variant, defaultBodyHeight])
if (variant === "limitByWords") {
const truncatedText = text.split(" ").slice(0, wordsSize).join(" ");
body = !!open ? text : truncatedText;
}
if (variant === "limitByRows") {
const truncatedRows = React.Children.toArray(children).splice(0, rowsSize);
body = !!open ? children : truncatedRows;
}
return (
<div className="container">
<button className="button" onClick={onClickHandler}>
{buttonText}
{!!open ? openIcon : closeIcon}
</button>
<div ref={expandableContainerRef} className="body">
{body}
</div>
</div>
);
};

Without much thought on a design, one may choose to implement all these variants in the same component. Sure, you can do it and may get off without any bugs. But is it a good design? Maybe not. Let’s understand why.

As we add more variations to the component, each time we change the component’s props thus the interface, and the implementation. This design exposes at least two problems:
a) lack of separation of concern — Expandable component’s responsibility is to toggle the content of its body and not the what and how to render the content.
b) tight coupling — an addition of a variant causes the component’s default implementation to change and also the number of props. Nevertheless, props become conditional and developers need to know their use. You could do a better job by adding brief documentation on how to use props, but self-documentation is better than additional notes.

If we are able to achieve loose coupling and separation of concern, the Expandable component will be cohesive in nature. Different bits and pieces of a code can be made interoperable, composable, and extendable.

How to solve the problem?

Let’s start with the SoC. The job of the expandable is to toggle the content and that’s it. See the following code modification.

export const Expandable = ({
isOpen,
children,
buttonText,
initialBodyHeight,
openIcon = <Icon color="blue" name="angle up" />,
closeIcon = <Icon color="blue" name="angle down" />
}) => {
...
...
return (
<div className="container">
<button className="button" onClick={onClickHandler}>
{buttonText}
{!!open ? openIcon : closeIcon}
</button>

{
React.Children.map(children, child => React.cloneElement(child, { open, ref: expandableContainerRef, className: "body"}))
}
</div>
);
};

As you can see, what and how to render the body is no longer the responsibility of the Expandable component. It is up to the child component. Not just that, we also made the Expandable component to have a single responsibility. Naturally, the dependencies on the props by the variants disappeared. Wait, how would I implement the child with different props? Exactly, they are child components. Let children define what they need.

Okay! But how the Expandable component knows to work with a child? Well, this is where the loose-coupling concept kicks in. Remember as mentioned earlier, no coupling is of no use and loose coupling is preferred over tight coupling.

The child component must accept “ref” and optional “open” as a prop to work with the Expandable. Notice that other props for each child component can be passed. Hmm, so now I can pass variant-specific props to a child component and child component as children of the Expandable component. And this is how we apply loose coupling.

The following code snippet is the implementation for each variant that is rendered as the body of the expandable.

DefaultDisplay

export const DefaultDisplay = React.forwardRef(
({ className, children }, ref) => (
<div ref={ref} className={className}>
{children}
</div>
);
);

Display Limit Words

export const DisplayWords = React.forwardRef(
({ className, text, wordsSize, open }, ref) => {
const truncatedText = text
.split(" ")
.splice(0, wordsSize)
.join(" ");
return (
<div className={className} ref={ref}>
{open ? text : `${truncatedText}...`}
</div>
);
}
);

Display Limit Rows

export const DisplayRows = React.forwardRef(
({ children, rowsSize, className, open }, ref) => {
return (
<div className={className} ref={ref}>
{open ? children : React.Children.toArray(children).splice(0, rowsSize)}
</div>
);
}
);

By now it is clear and as it can be seen that each variant is free for reuse at other places in a different context or on its own as long as needed props are passed.

Adding a new variant with this design is as easy as creating a new component and passing in the props by Expandable. Interesting! This means a change to the Expandable component is not needed at all. Neither do I have to touch any other components. Nonetheless, you can nest Expandable within an expandable to create a different visual structure. Well, this is what cohesion is and allows us to compose things like layers that can be peeled and applied to another.

Using a cohesive Expandable component

export const DefaultExpandable = () => (
<div style={{ margin: "10px" }}>
<Expandable buttonText="Show more" initialBodyHeight="0px"
<DefaultDisplay>
<div>
<div>Body goes here</div>
<div>Body goes here</div>
<div>Body goes here</div>
<div>Body goes here</div>
</div>
</DefaultDisplay>
</Expandable>
</div>
);
export const LimitWordsExpandable = () => (
<div style={{ margin: "10px" }}>
<Expandable buttonText="Show more">
<DisplayWords text="The slice() method returns a shallow copy of a portion of an array into a new array object selected from begin to end (end not included) where begin and end represent the index of items in that array. The original array will not be modified." wordsSize={10} />
</Expandable>
</div>
);
export const LimitRowsExpandable = () => (
<div style={{ margin: "10px" }}>
<Expandable buttonText="Show more">
<DisplayRows rowsSize={2}>
<div>Body goes here</div>
<div>Body goes here</div>
<div>Body goes here</div>
<div>Body goes here</div>
</DisplayRows>
</Expandable>
</div>
);

Nice! How it looks in real? Exactly the same as the original implementation.

Bonus

Can you use it with any child component? Not for all cases, you can have a child element of the Expandable that work exactly like the “DefaultDisplay” child component. Wow!

References

--

--

No responses yet