Semantic Differences and Structural Similarities

In modern front-end development, we strive to create reusable components to save time and reduce duplication. But if we prioritize reusability too much, we risk sacrificing clarity and flexibility. A common pitfall is to build overly generic components that blur the semantic distinctions between different UI elements, leading to a more complex and harder-to-maintain codebase.

In this article, I’ll walk you through my approach to structuring components by prioritizing ontological meaning—what a component represents in your application—its purpose or role—rather than how it looks, and how this can help avoid common pitfalls when building complex UIs.

Ontological meaning refers to the purpose or concept a component represents within your application. While components may look similar, their meaning—or ontology—can be different. For example, a TaskCard and ProjectCard might both display a title and a progress bar, but they represent two very different concepts.

The Naive Approach: One Generic Component for Everything

When you're building a front-end application, you’ll often come across components that look similar at first glance. Imagine a Project Card and a Task Card. Both cards might show a title, a description, and a progress bar, but they represent fundamentally different concepts: a project and a task.

A naive approach might suggest: “These two cards look the same, so let’s just create one generic component that can handle both!”

You could end up with something like this:

// Generic InfoCard component
const InfoCard = ({ title, description, progress }) => (
  <div className="info-card">
    <h2>{title}</h2>
    <p>{description}</p>
    <div className="progress-bar" style={{ width: `${progress}%` }} />
  </div>
);

Instead of creating separate components for ProjectCard and TaskCard, you would simply pass different data into this single generic InfoCard component:

// Using InfoCard for a Project
<InfoCard title="Website Redesign" description="Redesigning the homepage with a new layout" progress={40} />

// Using InfoCard for a Task
<InfoCard title="Create Wireframe" description="Design the initial wireframe for the homepage layout" progress={60} />

At first glance, this might seem like a time-saving strategy. After all, both components share the same structure, so why not create a single generic component? However, this desire for efficiency can introduce long-term challenges.

The Problems with a Single Generic Component

  1. Loss of Semantic Meaning: By using the same InfoCard for both projects and tasks, you start to lose the ontological meaning of these components. Is this component about a project or a task? Over time, it’s easy to forget the distinction. While the InfoCard looks the same, it represents two different things.

  2. Lack of Flexibility: What happens when one of these cards needs to change? Let’s say you need to add task assignees or due dates to the TaskCard. Now, you have to modify the InfoCard to accommodate these new features. The problem is, this generic component might also be used for projects, which don’t need the same updates. You end up adding conditional logic and props to handle edge cases, cluttering your shared component with unnecessary complexity.

  3. Harder to Maintain: As the generic InfoCard becomes more complex, it becomes harder to maintain. You might start introducing props like isProject or isTask, which increase the mental load for anyone maintaining the component making it harder to understand and debug. Over time, this kind of complexity leads to "prop bloat," where the component accepts more and more parameters to handle different scenarios.

A Better Approach: Semantic Components with Shared Structure

Instead of trying to force unrelated concepts into a single generic component, it's better to think of components as semantically different but structurally similar. This means you should create separate components for ProjectCard and TaskCard even if they share the same layout at the start. You can still share the structure (like the card layout) between them, but by keeping their identity distinct, you give yourself much more flexibility for the future.

Here’s how it could look:

const InfoCard = ({ title, description, progress }) => (
  <div className="info-card">
    <h2>{title}</h2>
    <p>{description}</p>
    <div className="progress-bar" style={{ width: `${progress}%` }} />
  </div>
);
// ProjectCard.js - Semantically distinct component
const ProjectCard = ({ projectName, projectDescription, projectProgress }) => (
  <InfoCard title={projectName} description={projectDescription} progress={projectProgress} />
);

// TaskCard.js - Semantically distinct component
const TaskCard = ({ taskName, taskDescription, taskProgress }) => (
  <InfoCard title={taskName} description={taskDescription} progress={taskProgress} />
);

At first glance, this might look like unnecessary boilerplate since both ProjectCard and TaskCard are essentially just wrapping the same InfoCard. But this boilerplate is intentional and provides several key benefits.

Why Boilerplate Isn’t Always Bad

  1. Clarity: By having distinct ProjectCard and TaskCard components, you maintain a clear separation of meaning. If someone new joins your team, they will immediately understand that ProjectCard deals with a project and TaskCard deals with a task, even if they share the same structure. This makes your code easier to understand and maintain over time.

  2. Flexibility: As your application evolves, ProjectCard and TaskCard can evolve independently to meet new requirements. For example, let's say you want to add additional information to the ProjectCard, such as project deadlines or milestones. Instead of modifying a shared component like InfoCard, you can extend or replace the entire structure of the ProjectCard while keeping the TaskCard untouched.

For instance, if you want to add more detailed project information to the ProjectCard, you can simply replace InfoCard with a more complex component:

const ProjectCard = ({ projectName, projectDescription, projectProgress, projectDeadline }) => (
  <div>
    <ExtendedProjectInfoCard 
      title={projectName} 
      description={projectDescription} 
      progress={projectProgress} 
      deadline={projectDeadline} 
    />
  </div>
);
  1. Maintainability: It makes your code easier to maintain in the long run by keeping components focused and modular. When you need to make changes, you know exactly where to look, and you can be confident that updates to one component won’t inadvertently affect another. You avoid unecessary conditional logic and props that clutter your shared components.

Balancing Reusability and Flexibility

The key here is to reuse what makes sense but not force reuse at the expense of clarity or flexibility. Reusing the shared structure (like InfoCard) helps maintain consistency and reduces duplication, but by keeping ProjectCard and TaskCard semantically distinct, you allow each component to evolve independently. This leads to a cleaner codebase, more scalable components, and less brittle logic.

Conclusion: Prioritize Semantic Meaning

When designing components, focusing on semantic differences while reusing common structure is an excellent strategy to strike the right balance between reusability and flexibility. While it might seem like extra work upfront to create separate components like ProjectCard and TaskCard, this approach pays off in the long term by making your codebase easier to maintain, more understandable, and more adaptable as the application grows.

Next time you’re tempted to create a single generic component for similar-looking elements, remember: a little intentional boilerplate can save you from a lot of complexity and headaches down the road.

Key Takeaways:

  • Reuse Structure, Not Meaning: While components may share a layout, wrap them in distinct components (ProjectCard, TaskCard) to preserve their unique semantic purpose.

  • Leverage Generic Components Thoughtfully: It’s fine to use generic components like InfoCard, but ensure they’re wrapped in more meaningful components that reflect the domain concepts.

  • Maintain Clarity with Wrappers: Adding wrappers around shared components keeps your codebase clearer and more flexible. A ProjectCard can evolve independently from a TaskCard, even if they share structure initially.

  • Boilerplate for Semantic Clarity: Writing a little extra wrapper code up front might seem redundant, but it ensures your components remain clear and adaptable as the application scales.

  • Avoid Overcomplicating Generic Components: Instead of adding complex props to handle different scenarios within a generic component, wrap the generic structure in components that handle those differences separately.

0
Subscribe to my newsletter

Read articles from George Daskalakis directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

George Daskalakis
George Daskalakis

I'm George, a full-stack developer with 5 years of experience in web development. I specialize in creating seamless user experiences with frameworks like Angular, Vue.js, React, and recently, Svelte/SvelteKit. On the backend, I work primarily with Node.js and have expertise in AWS and serverless architectures. Outside of coding, I enjoy gaming, playing guitar, and exploring low-level languages like Rust and C++ as well as game development.