Don’t get me wrong—I'm a huge advocate for clean code and well-placed abstractions to reduce duplication, enhance readability, and keep code tidy. But sometimes, in our quest to eliminate repetition, we inadvertently create abstractions that lead us down a path of unnecessary complexity. This complexity can obscure intent, make the code harder to modify, and ultimately hinder our ability to adapt to new requirements. This article explores when it’s better to allow some repetition in your code, especially in UI development, rather than forcing an abstraction that doesn’t really belong.
When you need to create a list with JSX
When JSX first emerged, mixing JavaScript objects with HTML-like tags felt counterintuitive. Fast forward a decade, and it has become the new normal, with countless web applications built using this once-strange syntax. One common pattern is mapping a list of objects to UI elements, such as displaying a list of users:
const users = [
{ id: "u1", name: "Juntao Qiu" },
{ id: "u2", name: "Abruzzi" },
];
const UserList = ({ users }) => {
return (
<ol>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ol>
);
};
This pattern works well for simple data structures, but what happens when the data and UI requirements become more complex?
Evolving Complexity: A Navigation Example
Let’s take a navigation menu as an example. Initially, your menu might be simple:
const menus = [
{ name: "Products", link: "/products" },
{ name: "Customers", link: "/customers" },
{ name: "About", link: "/about" },
{ name: "Sign in", link: "/sign-in" },
];
const Navigation = () => {
return (
<nav>
<ol>
{menus.map((menu) => (
<li key={menu.name}>
<a href={menu.link}>{menu.name}</a>
</li>
))}
</ol>
</nav>
);
};
This approach works fine until the requirements evolve. Suppose the "Products" menu needs a dropdown with submenus:
const menus = [
{
name: "Products",
link: "/products",
submenus: [
{ name: "Product A", link: "/products/a" },
{ name: "Product B", link: "/products/b" },
],
},
// ...
];
You might extend the Navigation
component to handle this:
const DropdownMenuList = ({ menu }) => (
<Dropdown>
<Trigger>{menu.name}</Trigger>
<DropdownContent>
<ol>
{menu.submenus.map((sm) => (
<li key={sm.name}>
<a href={sm.link}>{sm.name}</a>
</li>
))}
</ol>
</DropdownContent>
</Dropdown>
);
const Navigation = () => {
return (
<nav>
<ol>
{menus.map((menu) =>
menu.submenus ? (
<DropdownMenuList key={menu.name} menu={menu} />
) : (
<li key={menu.name}>
<a href={menu.link}>{menu.name}</a>
</li>
),
)}
</ol>
</nav>
);
};
This works, but what if you need to dynamically load part of the menu, like user-specific items after login?
Dynamic Menu Items
Now you need two versions of menu items, depending on whether a user is logged in or not. One approach is to extend the current structure:
const menus = [
{
name: "Products",
link: "/products",
submenus: [
{ name: "Product A", link: "/products/a" },
{ name: "Product B", link: "/products/b" },
],
},
{
type: "protected",
loggedIn: {
name: "User",
submenus: [
{ name: "Profile", link: "/profile" },
{ name: "Settings", link: "/settings" },
{ name: "Logout", link: "/logout" },
],
},
default: {
name: "Sign in",
link: "/sign-in",
},
},
// ...
];
Then, you can switch between them programmatically:
const Navigation = () => {
const { isLoggedIn } = useAuth();
const dynamicMenus = menus.map((menu) => {
if (menu.type === "protected") {
return isLoggedIn ? menu.loggedIn : menu.default;
}
return menu;
});
return (
<nav>
<ol>
{dynamicMenus.map((menu) => (
<MenuItem key={menu.name} menu={menu} />
))}
</ol>
</nav>
);
};
Lazy Loading Content
When dealing with dynamic content, such as a project list fetched after login, it’s easy to see how the abstraction can break down:
const ProjectListMenu = () => {
const [projectMenus, setProjectMenus] = useState([]);
useEffect(() => {
fetch("/api/projects")
.then((r) => r.json())
.then((data) =>
setProjectMenus(
data.map((d) => ({ name: d.name, link: `/projects/${d.id}` })),
),
);
}, []);
return (
<ol>
{projectMenus.map((projectMenu) => (
<MenuItem key={projectMenu.name} menu={projectMenu} />
))}
</ol>
);
};
But how do you integrate this dynamic menu into the existing Navigation
component without convoluting the logic? Suppose you want to place the ProjectListMenu
in a specific slot within the navigation:
const menus = [
{
name: "Products",
type: "static",
},
{
type: "protected",
//...
},
{
type: "lazy",
menuItemId: "projects",
},
// ...
];
At runtime, you replace the item for type lazy
, but is that really what you’re after? The abstraction, meant to simplify, is now making things more complex.
As we try to reuse the concise map
list into UI elements, customization logic increasingly shifts into the data itself. This means moving conditions (flags, string literals, types, etc.) into the data, implicitly embedding these rules to simplify UI generation.
But the cost is high—the data structure becomes harder to read and understand:
const menus = [
{
name: "Products",
link: "/products",
type: "static",
submenus: [
{ name: "Product A", link: "/products/a" },
{ name: "Product B", link: "/products/b" },
],
},
{
type: "protected",
loggedIn: {
name: "User",
submenus: [
{ name: "Profile", link: "/profile" },
{ name: "Settings", link: "/settings" },
{ name: "Logout", link: "/logout" },
],
},
default: {
name: "Sign in",
link: "/sign-in",
},
},
{
menuItemId: "projects",
type: "lazy",
},
];
Let’s Rewind a Bit
React’s JSX was designed to offer a declarative approach to building UIs, and sometimes, the best way to utilize it is to embrace its strengths. Instead of forcing an abstraction that complicates the code, let’s look at a more straightforward approach:
const Navigation = () => {
const { isLoggedIn } = useAuth();
const { projects } = useProjects();
return (
<nav>
<ol>
<li>
<span>Products</span>
<ol>
<li>
<a href="/products/a">Product A</a>
</li>
<li>
<a href="/products/b">Product B</a>
</li>
</ol>
</li>
{projects && (
<>
<span>Projects</span>
<ol>
<li>
<a href="/projects/1">Project 1</a>
</li>
<li>
<a href="/projects/2">Project 2</a>
</li>
</ol>
</>
)}
<li>
<a href="/about">About</a>
</li>
{isLoggedIn && (
<>
<span>{name}</span>
<ol>
<li>
<a href="/profile">Profile</a>
</li>
<li>
<a href="/settings">Settings</a>
</li>
</ol>
</>
)}
</ol>
</nav>
);
};
While this code may seem more verbose, it’s actually cleaner and easier to read. By using JSX as intended—directly embedding the UI logic—you keep your code straightforward and declarative.
Refactoring for Clarity
You can still refactor for clarity by extracting components, but avoid the temptation to abstract too early:
const ProjectsMenu = () => {
const { projects } = useProjects();
if (!projects) {
return null;
}
return (
<>
<span>Projects</span>
<ol>
<li>
<a href="/projects/1">Project 1</a>
</li>
<li>
<a href="/projects/2">Project 2</a>
</li>
</ol>
</>
);
};
const UserMenu = () => {
const { name, isLoggedIn } = useAuth();
if (!isLoggedIn) {
return (
<li>
<a href="/sign-in">Sign in</a>
</li>
);
}
return (
<li>
<span>{name}</span>
<ol>
<li>
<a href="/profile">Profile</a>
</li>
<li>
<a href="/settings">Settings</a>
</li>
</ol>
</li>
);
};
const Navigation = () => {
return (
<nav>
<ol>
<ProductsMenu />
<ProjectsMenu />
<AboutMenu />
<UserMenu />
</ol>
</nav>
);
};
The difference here is that we’re not over-abstracting the data into a list of objects with special fields and nested structures. Instead, we let JSX do what it does best: represent UI in a straightforward, declarative way.
This approach is my go-to strategy when dealing with incorrect abstractions—especially when an abstraction starts to exhibit more variations than the default case. This often signals that the abstraction has drifted too far from its original intent and needs to be reconsidered.
When I encounter this, I prefer to inline all the abstractions into a single, all-encompassing place. By doing this, I can more easily spot any emerging patterns. If patterns do emerge, I can then introduce a new, more appropriate abstraction that fits the current situation—similar to the ProductsMenu
and ProjectsMenu
reusable component approach. This method often simplifies the code from a different perspective, making it more readable and clean.
Conclusion
In UI development, repetition isn’t just acceptable—it can be a strategic advantage. While abstraction is valuable, it's crucial to recognize when it starts introducing unnecessary complexity. Over-abstraction often leads to convoluted code that's difficult to maintain and adapt. Instead of prematurely forcing an abstraction, allow your code to repeat where necessary. This repetition can act as a natural buffer, revealing clearer patterns over time.
When an abstraction begins to diverge from its original intent, it's a sign that it needs to be revisited. By inlining your code and examining the big picture, you can identify opportunities to create more appropriate abstractions based on the current context. This approach doesn't just clean up your code—it also ensures that your abstractions are truly meaningful, aligned with real-world usage, and contribute to a more maintainable and adaptable codebase.