This is an excerpt from my upcoming course Clean Components. You can learn more about it here.

It is Lesson 2 of Module 1 — Cleaning up the template.

The applications that we write are filled with repetition. For example:

  • A navbar with 12 nav items, the only difference being the text and where it takes you
  • A newsfeed with a never-ending list of images to double-tap
  • A countdown timer that's used in multiple places

But just because the interface repeats, it doesn't mean your code needs to be repetitive as well.

This is exactly why we invented computers — to do the tedious, boring things for us!

We know we can reduce duplicated code by reusing components, but it certainly doesn't stop there.

In this lesson we'll be covering:

  • Why repeated code in your components is a problem
  • Cleaning up tangled messes using v-for, with a real case study
  • Using a declarative, dynamic, and data-driven approach to reducing repetition

At the very end we'll also discuss some of the tradeoffs that we had to make along the way.

So let's get into it!

Why repeated code is a problem

As software developers we're always looking for ways to get more done with less work.

So why write something multiple times when you can you write it just once?

By reducing repeated code, you can greatly simplify your components. This makes them easier to read and understand.

And less code means less bugs! 🐛

Plus, if you've copy and pasted code in a bunch of places, what happens when you need to change it in the future? You have to go around and make that same change everywhere you've copy and pasted it.

Gross.

Trust me, it's not a fun task.

Refactoring a menu component

Here we have a Menu component that has a lot of repeated sections. Right now the template is 181 lines long, but there is definitely a lot we can do to simplify this component and make it easier to work with:

<template>
  <Flexbox class="menubar">
    <Flexbox
      align-items="center"
      class="menubar__list"
    >
      <!-- File -->
      <div @click="fileActive = true">
        <div class="menubar__list__item">
          File
        </div>
        <DropdownList
          v-if="fileActive"
          class="menubar__list__item__dropdown"
          @mouseleave="fileActive = false"
        >
          <DropdownItem @click="newRepository">
            New repository
          </DropdownItem>
          <DropdownItem @click="addLocalRepository">
            Add local repository
          </DropdownItem>
          <DropdownItem @click="cloneRepository">
            Clone repository
          </DropdownItem>
          <DropdownItem
            v-if="currentRepository !== undefined"
            @click="switchRepository"
          >
            Switch repository
          </DropdownItem>

          <DropdownDivider />

          <DropdownItem @click="appOptions">
            Options
          </DropdownItem>
          <DropdownItem @click="exitApp">
            Exit
          </DropdownItem>
        </DropdownList>
      </div>

      <!-- View -->
      <div @click="viewActive = true">
        <div class="menubar__list__item">
          View
        </div>
        <DropdownList
          v-if="viewActive"
          class="menubar__list__item__dropdown"
          @mouseleave="viewActive = false"
        >
          <DropdownItem @click="gitCommands">
            Git commands
          </DropdownItem>

          <DropdownDivider />

          <DropdownItem @click="fullScreenView">
            Toggle full screen
          </DropdownItem>
          <DropdownItem @click="openDevTools">
            Toggle developer tools
          </DropdownItem>
        </DropdownList>
      </div>

      <!-- Repository -->
      <div
        v-if="currentRepository !== undefined"
        @click="repositoryActive = true"
      >
        <div class="menubar__list__item">
          Repository
        </div>
        <DropdownList
          v-if="repositoryActive"
          class="menubar__list__item__dropdown"
          @mouseleave="repositoryActive = false"
        >
          <DropdownItem @click="viewOnGithub">
            View on GitHub
          </DropdownItem>
          <DropdownItem @click="openPowerShell">
            Open in PowerShell
          </DropdownItem>
          <DropdownItem @click="openFileExplorer">
            Show in Explorer
          </DropdownItem>
          <DropdownItem @click="openEditor">
            Open in Code editor
          </DropdownItem>

          <DropdownDivider />

          <DropdownItem @click="openRepositorySettings">
            Repository settings
          </DropdownItem>
        </DropdownList>
      </div>

      <!-- Branch -->
      <div
        v-if="currentRepository !== undefined"
        @click="branchActive = true"
      >
        <div class="menubar__list__item">
          Branch
        </div>
        <DropdownList
          v-if="branchActive"
          class="menubar__list__item__dropdown"
          @mouseleave="branchActive = false"
        >
          <DropdownItem @click="newBranch">
            New branch
          </DropdownItem>
          <DropdownItem @click="renameBranch">
            Rename branch
          </DropdownItem>
          <DropdownItem @click="deleteBranch">
            Delete branch
          </DropdownItem>

          <DropdownDivider />

          <DropdownItem @click="updateToMaster">
            Update to master
          </DropdownItem>
          <DropdownItem @click="compareToMaster">
            Compare to master
          </DropdownItem>
          <DropdownItem @click="mergeIntoCurrent">
            Merge into current branch
          </DropdownItem>
          <DropdownItem @click="compareOnGithub">
            Compare on GitHub
          </DropdownItem>
          <DropdownItem @click="createPullRequest">
            Create pull request
          </DropdownItem>
        </DropdownList>
      </div>

      <!-- Help -->
      <div @click="helpActive = true">
        <div class="menubar__list__item">
          Help
        </div>
        <DropdownList
          v-if="helpActive"
          class="menubar__list__item__dropdown"
          @mouseleave="helpActive = false"
        >
          <DropdownItem @click="switchRepository">
            Welcome
          </DropdownItem>
          <DropdownItem @click="reportIssue">
            Report issue
          </DropdownItem>
          <DropdownItem @click="contactSupport">
            Contact support
          </DropdownItem>

          <DropdownDivider />

          <DropdownItem @click="showUserGuides">
            Show User Guides
          </DropdownItem>
          <DropdownItem @click="showLogs">
            Show logs in Explorer
          </DropdownItem>
          <DropdownItem @click="about">
            About
          </DropdownItem>
        </DropdownList>
      </div>
    </Flexbox>
  </Flexbox>
</template>

First, let's explain what's going on in this component.

This is a menu component taken from a tool for browsing Git repositories. In it we have 5 menus:

  • File
  • View
  • Repository
  • Branch
  • Help

Each of these menus uses a DropdownList component with DropdownItem components inside for each menu item. There is also a DropdownDivider component which adds in a divider between the menu items.

When the user clicks on the div that wraps a menu, it sets the corresponding flag to true, and the DropdownList is displayed. As soon as the user's mouse leaves the menu though, it's set back to false and the menu hides itself again.

This is repeated for each of the 5 menus we have.

Although this component isn't too bad, there's still a lot we can do to simplify it and make it easier to work with. So let's get going!

Refactoring the branch menu

First we need to figure out what our strategy here will be.

Since each menu is performing a distinct function, we could apply what we learned from the previous lesson and break each menu into it's own component. That would clean things up a bit, but we can do much better than that.

Let's focus in on one of the menus, and once we figure out how to simplify that we'll be able to apply it to all of the others. We'll look at the Branch menu:

<!-- Branch -->
<div
  v-if="currentRepository !== undefined"
  @click="branchActive = true"
>
  <div class="menubar__list__item">
    Branch
  </div>
  <DropdownList
    v-if="branchActive"
    class="menubar__list__item__dropdown"
    @mouseleave="branchActive = false"
  >
    <DropdownItem @click="newBranch">
      New branch
    </DropdownItem>
    <DropdownItem @click="renameBranch">
      Rename branch
    </DropdownItem>
    <DropdownItem @click="deleteBranch">
      Delete branch
    </DropdownItem>

    <DropdownDivider />

    <DropdownItem @click="updateToMaster">
      Update to master
    </DropdownItem>
    <DropdownItem @click="compareToMaster">
      Compare to master
    </DropdownItem>
    <DropdownItem @click="mergeIntoCurrent">
      Merge into current branch
    </DropdownItem>
    <DropdownItem @click="compareOnGithub">
      Compare on GitHub
    </DropdownItem>
    <DropdownItem @click="createPullRequest">
      Create pull request
    </DropdownItem>
  </DropdownList>
</div>

The majority of this component is the DropdownItem component repeated 8 times. That means that we can replace the repetition with a v-for.

Repeating each menu elegantly

Here is the rewritten section:

<template>
  <!-- Branch -->
  <div
  v-if="currentRepository !== undefined"
  @click="branchActive = true"
  >
  <div class="menubar__list__item">
    Branch
  </div>
  <DropdownList
    v-if="branchActive"
    class="menubar__list__item__dropdown"
    @mouseleave="branchActive = false"
  >
    <DropdownItem
      v-for="menuItem in menuItems"
      :key="menuItem.text"
      @click="menuItem.action"
    >
      {{ menuItem.text }}
    </DropdownItem>
  </DropdownList>
  </div>
</template>

We'll ignore the DropdownDivider component for now, just to keep things simple.

The result of this refactor is that this section went from 41 lines down to 22 lines, nearly cut in half!

The only part that we changed was how we used the DropdownItem component:

<DropdownItem
  v-for="menuItem in menuItems"
  :key="menuItem.text"
  @click="menuItem.action"
>
  {{ menuItem.text }}
</DropdownItem>

We add in the v-for, looping over menuItems, an array that we'll create in just a moment. Since we already have unique text for the menu, we'll use that as our key as well.

The click handler gets replaced by menuItem.action, and the menu text is replaced by menuItem.text. All that's left now is to create our menuItems array.

To create the array we have to look at what all of the DropdownItem components were doing before, and copy it into an array that looks like this:

[
  {
  text: 'New Branch',
  action: this.newBranch,
  },
  // ...
]

Once you've done that, you'll end up with an array in your data() function like this:

export default {
  data() {
    return {
      menuItems: [
        {
          text: 'New Branch',
          action: this.newBranch,
        },
        {
          text: 'Rename Branch',
          action: this.renameBranch,
        },
        {
          text: 'Delete Branch',
          action: this.deleteBranch,
        },
        {
          text: 'Update to Master',
          action: this.updateToMaster,
        },
        {
          text: 'Compare to Master',
          action: this.compareToMaster,
        },
        {
          text: 'Merge Into Current',
          action: this.mergeIntoCurrent,
        },
        {
          text: 'Compare on GitHub',
          action: this.compareOnGithub,
        },
        {
          text: 'Create Pull Request',
          action: this.createPullRequest,
        },
      ]
    };
  },
};

Now we've greatly simplified the template of the Branch menu!

But you may have noticed that we didn't actually get rid of the complexity. We've just moved it out of the template and into the data() function of our component. All we've done in this example is move where the complexity lives.

But hold on, we're not quite done here yet.

If we keep going we'll start to see some benefits of this approach. We've just tidied things up a bit. The next steps are where we really start to see some progress.

Applying to every menu item

Now we take the improvements we made to the Branch menu, and apply them to the other menus.

We'll have to create a new menuItems array for each menu to keep them separate.

Our in-progress template now looks like this:

<template>
  <Flexbox class="menubar">
    <Flexbox
      align-items="center"
      class="menubar__list"
    >
      <!-- File -->
      <div @click="fileActive = true">
        <div class="menubar__list__item">
          File
        </div>
        <DropdownList
          v-if="fileActive"
          class="menubar__list__item__dropdown"
          @mouseleave="fileActive = false"
        >
          <DropdownItem
            v-for="menuItem in fileItems"
            :key="menuItem.text"
            @click="menuItem.action"
          >
            {{ menuItem.text }}
          </DropdownItem>
        </DropdownList>
      </div>

      <!-- View -->
      <div @click="viewActive = true">
        <div class="menubar__list__item">
          View
        </div>
        <DropdownList
          v-if="viewActive"
          class="menubar__list__item__dropdown"
          @mouseleave="viewActive = false"
        >
          <DropdownItem
            v-for="menuItem in viewItems"
            :key="menuItem.text"
            @click="menuItem.action"
          >
            {{ menuItem.text }}
          </DropdownItem>
        </DropdownList>
      </div>

      <!-- Repository -->
      <div
        v-if="currentRepository !== undefined"
        @click="repositoryActive = true"
      >
        <div class="menubar__list__item">
          Repository
        </div>
        <DropdownList
          v-if="repositoryActive"
          class="menubar__list__item__dropdown"
          @mouseleave="repositoryActive = false"
        >
          <DropdownItem
            v-for="menuItem in repositoryItems"
            :key="menuItem.text"
            @click="menuItem.action"
          >
            {{ menuItem.text }}
          </DropdownItem>
        </DropdownList>
      </div>

      <!-- Branch -->
      <div
        v-if="currentRepository !== undefined"
        @click="branchActive = true"
      >
        <div class="menubar__list__item">
          Branch
        </div>
        <DropdownList
          v-if="branchActive"
          class="menubar__list__item__dropdown"
          @mouseleave="branchActive = false"
        >
          <DropdownItem
            v-for="menuItem in branchItems"
            :key="menuItem.text"
            @click="menuItem.action"
          >
            {{ menuItem.text }}
          </DropdownItem>
        </DropdownList>
      </div>

      <!-- Help -->
      <div @click="helpActive = true">
        <div class="menubar__list__item">
          Help
        </div>
        <DropdownList
          v-if="helpActive"
          class="menubar__list__item__dropdown"
          @mouseleave="helpActive = false"
        >
          <DropdownItem
            v-for="menuItem in helpItems"
            :key="menuItem.text"
            @click="menuItem.action"
          >
            {{ menuItem.text }}
          </DropdownItem>
        </DropdownList>
      </div>
    </Flexbox>
  </Flexbox>
</template>

And we'll need to add the following to our data() function:

export default {
  // ...

  data() {
    return {
      fileItems: [
        {
          text: "New Repository",
          action: this.newRepository,
        },
        {
          text: "Add Local Repository",
          action: this.addLocalRepository,
        },
        {
          text: "Clone Repository",
          action: this.cloneRepository,
        },
        {
          text: "Switch Repository",
          action: this.switchRepository,
        },
        {
          text: "Options",
          action: this.appOptions,
        },
        {
          text: "Exit",
          action: this.exitApp,
        },
      ],
      viewItems: [
        {
          text: "Git Commands",
          action: this.gitCommands,
        },
        {
          text: "Toggle Full Screen",
          action: this.fullScreenView,
        },
        {
          text: "Toggle Developer Tools",
          action: this.openDevTools,
        },
      ],
      repositoryItems: [
        {
          text: "View on GitHub",
          action: this.viewOnGithub,
        },
        {
          text: "Open in PowerShell",
          action: this.openPowerShell,
        },
        {
          text: "Show in Explorer",
          action: this.openFileExplorer,
        },
        {
          text: "Open in Code Editor",
          action: this.openEditor,
        },
      ],
      branchItems: [
        {
          text: 'New Branch',
          action: this.newBranch,
        },
        {
          text: 'Rename Branch',
          action: this.renameBranch,
        },
        {
          text: 'Delete Branch',
          action: this.deleteBranch,
        },
        {
          text: 'Update to Master',
          action: this.updateToMaster,
        },
        {
          text: 'Compare to Master',
          action: this.compareToMaster,
        },
        {
          text: 'Merge Into Current',
          action: this.mergeIntoCurrent,
        },
        {
          text: 'Compare on GitHub',
          action: this.compareOnGithub,
        },
        {
          text: 'Create Pull Request',
          action: this.createPullRequest,
        },
      ],
      helpItems: [
        {
          text: 'Welcome',
          action: this.switchRepository,
        },
        {
          text: 'Report Issue',
          action: this.reportIssue,
        },
        {
          text: 'Contact Support',
          action: this.contactSupport,
        },
        {
          text: 'Show User Guides',
          action: this.showUserGuides,
        },
        {
          text: 'Show Logs',
          action: this.showLogs,
        },
        {
          text: 'About',
          action: this.about,
        },
      ],
    };
  },

  // ...
};

Yes, we've added a lot to our data() function. But we'll cover why that's not so much of an issue right after we finish cleaning up this template.

Let's quickly recap what we've done.

We noticed that each menu had the same structure of DropdownItem components repeating, so we were able to clean that up using a v-for.

If you look closely at what we have now with our template, you'll see that because we've cleaned it up, each menu now has an identical structure again.

Time to add in another v-for and clean it up!

Finishing up our refactor

Let's grab the File menu:

<!-- File -->
<div @click="fileActive = true">
  <div class="menubar__list__item">
    File
  </div>
  <DropdownList
    v-if="fileActive"
    class="menubar__list__item__dropdown"
    @mouseleave="fileActive = false"
  >
    <DropdownItem
      v-for="menuItem in fileItems"
      :key="menuItem.text"
      @click="menuItem.action"
    >
      {{ menuItem.text }}
    </DropdownItem>
  </DropdownList>
</div>

In order to add in the v-for we need to convert every value here to use a variable that will change with every iteration. We'll put all of these on a menu object:

<!-- File -->
<div @click="menu.active = true">
  <div class="menubar__list__item">
    {{ menu.text }}
  </div>
  <DropdownList
    v-if="menu.active"
    class="menubar__list__item__dropdown"
    @mouseleave="menu.active = false"
  >
    <DropdownItem
      v-for="menuItem in menu.items"
      :key="menuItem.text"
      @click="menuItem.action"
    >
      {{ menuItem.text }}
    </DropdownItem>
  </DropdownList>
</div>

We keep track of whether the menu is open or not through menu.active, we use menu.text to hold the name of the menu, and we're now keeping all of the menu items on menu.items as well.

So each menu object will look like this:

const menu = {
  text: 'File',
  active: false,
  items: [
    {
      text: 'New Repository',
      action: this.newRepository,
    },
    // ...
  ],
};

When we fully refactor the rest of this component this way, we're left with a template that looks like this:

<template>
  <Flexbox class="menubar">
    <Flexbox
      align-items="center"
      class="menubar__list"
    >
      <div
        v-for="menu in menus"
        :key="menu.text"
        @click="menu.active = true"
      >
        <div class="menubar__list__item">
          {{ menu.text }}
        </div>
        <DropdownList
          v-if="menu.active"
          class="menubar__list__item__dropdown"
          @mouseleave="menu.active = false"
        >
          <DropdownItem
            v-for="menuItem in menu.items"
            :key="menuItem.text"
            @click="menuItem.action"
          >
            {{ menuItem.text }}
          </DropdownItem>
        </DropdownList>
      </div>
    </Flexbox>
  </Flexbox>
</template>

To me, this template is very clear and concise. It's quite easy to read and understand what it does.

We had to nest two v-fors to get there, but in the end it's worth it. The template was originally 181 lines long, and now it's only 31 lines!

But I did mention that we're just moving the complexity to a different location, so let's take a moment to understand why we did that.

Dealing with tradeoffs

If we take a look at what the data() function now looks like after this refactoring, it's pretty long:

export default {
  data() {
    return {
      menus: [
        {
          text: 'File',
          active: false,
          items: [
            {
              text: "New Repository",
              action: this.newRepository,
            },
            {
              text: "Add Local Repository",
              action: this.addLocalRepository,
            },
            {
              text: "Clone Repository",
              action: this.cloneRepository,
            },
            {
              text: "Switch Repository",
              action: this.switchRepository,
            },
            {
              text: "Options",
              action: this.appOptions,
            },
            {
              text: "Exit",
              action: this.exitApp,
            },
          ],
        },
        {
          text: 'View',
          active: false,
          items: [
            {
              text: "Git Commands",
              action: this.gitCommands,
            },
            {
              text: "Toggle Full Screen",
              action: this.fullScreenView,
            },
            {
              text: "Toggle Developer Tools",
              action: this.openDevTools,
            },
          ],
        },
        {
          text: 'Repository',
          active: false,
          items: [
            {
              text: "View on GitHub",
              action: this.viewOnGithub,
            },
            {
              text: "Open in PowerShell",
              action: this.openPowerShell,
            },
            {
              text: "Show in Explorer",
              action: this.openFileExplorer,
            },
            {
              text: "Open in Code Editor",
              action: this.openEditor,
            },
          ],
        },
        {
          name: 'Branch',
          active: false,
          items: [
            {
              text: 'New Branch',
              action: this.newBranch,
            },
            {
              text: 'Rename Branch',
              action: this.renameBranch,
            },
            {
              text: 'Delete Branch',
              action: this.deleteBranch,
            },
            {
              text: 'Update to Master',
              action: this.updateToMaster,
            },
            {
              text: 'Compare to Master',
              action: this.compareToMaster,
            },
            {
              text: 'Merge Into Current',
              action: this.mergeIntoCurrent,
            },
            {
              text: 'Compare on GitHub',
              action: this.compareOnGithub,
            },
            {
              text: 'Create Pull Request',
              action: this.createPullRequest,
            },
          ],
        },
        {
          name: 'Help',
          active: false,
          items: [
            {
              text: 'Welcome',
              action: this.switchRepository,
            },
            {
              text: 'Report Issue',
              action: this.reportIssue,
            },
            {
              text: 'Contact Support',
              action: this.contactSupport,
            },
            {
              text: 'Show User Guides',
              action: this.showUserGuides,
            },
            {
              text: 'Show Logs',
              action: this.showLogs,
            },
            {
              text: 'About',
              action: this.about,
            },
          ],
        }
      ]
    };
  },
};

Yes, we added about 130 lines, so we didn't reduce the lines of code in our app.

But it's not about how many lines of code we have.

It's about the type of code as well. We've shifted 150 lines of logic into 130 lines of static, declarative configuration, and there are many benefits to that:

  • It's easier to read configuration — Reading code is difficult because you have to figure out what the logic is doing, but configuration is very straightforward to understand. Here we've reduced the amount of logic necessary to understand what the component does.
  • Less logic means less bugs — The config above is just a boring array with some objects, it's pretty simple, so it's unlikely that bugs would come from there. However, template code is far more likely to have all sorts of bugs and logic errors in it.
  • Dynamic components are more flexible — Because we've made this menu component dynamically render out the different menus and menu items, we gain a massive amount of flexibility. We could load menu configurations from the server, or even have plugins add menu options if we wanted to (and so much more).

At the end of the day though, it's all about your judgment.

You could go to one extreme and define your entire app in a giant json file that gets parsed when the app loads. Or you could never use a v-for and explicitly write out each time you want to use a component.

As always, you'll have to rely on your experience and intuition to know the best approach in each situation.

Stay tuned for more excerpts and teasers from the course as we get closer to the launch date!

You can also find out more about the course here.