Untangling Spaghetti Code: How to Write Maintainable JavaScript

Share this article

This article was peer reviewed by Tom Greco, Dan Prince and Yaphi Berhanu. Thanks to all of SitePoint’s peer reviewers for making SitePoint content the best it can be!

Almost every developer has had the experience of maintaining or taking over a legacy project. Or, maybe it’s an old project which got picked up again. Common first thoughts are to throw away the code base, and start from scratch. The code can be messy, undocumented and it can take days to fully understand everything. But, with proper planning, analysis, and a good workflow, it is possible to turn a spaghetti codebase into a clean, organised, and scalable one.

I’ve had to take over and clean-up a lot of projects. There haven’t been many I started from scratch. In fact, I am currently doing exact that. I’ve learned a lot regarding JavaScript, keeping a codebase organised and — most importantly — not being mad at the previous developer. In this article I want to show you my steps and tell you my experience.

Key Takeaways

  • Understand the Project First: Before diving into coding, analyze the existing codebase thoroughly, understand the technologies and tools used, and study any available documentation or unit tests to gain a deeper insight into the project’s functionality.
  • Establish a Consistent Coding Standard: Implement an `.editorconfig` file to maintain consistent coding styles across various editors and IDEs used by different developers. This includes consistent indentation and adherence to a naming convention.
  • Implement Linting Tools: Use JavaScript linters like ESLint to enforce coding standards and best practices, ensuring code quality and helping to identify and fix errors early in the development process.
  • Manage Dependencies Wisely: Update project dependencies cautiously, understanding the implications of changes in major, minor, and patch versions to avoid introducing new errors into the project.
  • Refactor with Care: Focus on modularizing the codebase where appropriate, breaking down complex functions into smaller, manageable, and reusable modules that follow single responsibility principles.
  • Document and Commit Regularly: Use JSDoc for comprehensive documentation and maintain an organized commit workflow with regular commits to track changes and facilitate rollbacks if necessary. Always work on a separate branch from the master to manage refactoring and new features effectively.

Analyse the Project

The very first step is to get an overview of what is going on. If it’s a website, click your way through all the functionality: open modals, send forms and so on. While doing that, have the Developer Tools open, to see if any errors are popping up or anything is getting logged. If it’s a Node.js project, open the command line interface and go through the API. In the best case the project has an entry point (e.g. main.js, index.js, app.js, …) where either all modules are initialized or, in the worst case, the entire business logic is located.

Find out which tools are in use. jQuery? React? Express? Make a list of everything that is important to know. Let’s say the project is written in Angular 2 and you haven’t worked with that, go straight to the documentation and get a basic understanding. Search for best practices.

Understand the Project on a Higher Level

Knowing the technologies is a good start, but to get a real feel and understanding, it’s time to look into the unit tests. Unit testing is a way of testing functionality and the methods of your code to ensure your code behaves as intended. Reading — and running — unit tests gives you a much deeper understanding than reading only code. If they are no unit tests in your project, don’t worry, we will come to that.

Create a Baseline

This is all about establishing consistency. Now that you have all information about the projects toolchain, you know the structure and how the logic is connected, it’s time to create a baseline. I recommend adding an .editorconfig file to keep coding style guides consistent between different editors, IDE’s, and developers.

Coherent indentation

The famous question (it’s rather a war though), whether spaces or tabs should be used, doesn’t matter. Is the codebase written in spaces? Continue with spaces. With tabs? Use them. Only when the codebase has mixed indentation is it necessary to decide which to use. Opinions are fine, but a good project makes sure all developer can work without hassle.

Why is this even important? Everyone has it’s own way of using an editor or IDE. For instance, I am a huge fan of code folding. Without that feature, I am literally lost in a file. When the indentation isn’t coherent, this features fails. So every time I open a file, I would have to fix the indentation before I can even start working. This is a huge waste of time.

// While this is valid JavaScript, the block can't
// be properly folded due to its mixed indentation.
 function foo (data) {
  let property = String(data);

if (property === 'bar') {
   property = doSomething(property);
  }
  //... more logic.
 }

// Correct indentation makes the code block foldable,
// enabling a better experience and clean codebase.
function foo (data) {
 let property = String(data);

 if (property === 'bar') {
  property = doSomething(property);
 }
 //... more logic.
}

Naming

Make sure that the naming convention used in the project is respected. CamelCase is commonly used in JavaScript code, but I’ve seen mixed conventions a lot. For instance, jQuery projects often have mixed naming of jQuery object variables and other variables.

// Inconsistent naming makes it harder
// to scan and understand the code. It can also
// lead to false expectations.
const $element = $('.element');

function _privateMethod () {
  const self = $(this);
  const _internalElement = $('.internal-element');
  let $data = element.data('foo');
  //... more logic.
}

// This is much easier and faster to understand.
const $element = $('.element');

function _privateMethod () {
  const $this = $(this);
  const $internalElement = $('.internal-element');
  let elementData = $element.data('foo');
  //... more logic.
}

Linting Everything

While the previous steps were more cosmetic and mainly to help with scanning the code faster, here we introduce and ensure common best practices as well as code quality. ESLint, JSLint, and JSHint are the most popular JavaScript linters these days. Personally, I used to work with JSHint a lot, but ESLint has started to become my favorite, mainly because of its custom rules and early ES2015 support.

When you start linting, if a lot of errors pop up, fix them! Don’t continue with anything else before your linter is happy!

Updating Dependencies

Updating dependencies should be done carefully. It’s easy to introduce more errors when not paying attention to the changes your dependencies have gone through. Some projects might work with fixed versions (e.g. v1.12.5), while others use wildcard versions (e.g. v1.12.x). In case you need a quick update, a version number is constructed as follows: MAJOR.MINOR.PATCH. If you aren’t familiar with how semantic versioning works, I recommend reading this article by Tim Oxley.

There is no general rule for updating dependencies. Each project is different and should be handled as such. Updating the PATCH number of your dependencies shouldn’t be a problem at all, and MINOR is usually fine too. Only when you bump the MAJOR number of your dependencies, you should look up what exactly has changed. Maybe the API has changed entirely and you need to rewrite large parts of your application. If that’s not worth the effort, I would avoid updating to the next major version.

If your project uses npm as dependency manager (and there aren’t any competitors) you can check for any outdated dependencies with the handy npm outdated command from your CLI. Let me illustrate this with an example from one of my projects called FrontBook, where I frequently update all dependencies:

Image of npm outdated

As you can see I have a lot of major updates here. I wouldn’t update all of them at once, but one at a time. Granted, this will take up a lot of time, yet it is the only way to ensure nothing breaks (if the project doesn’t have any tests).

Let’s Get Our Hands Dirty

The main message I want you to take with you is that cleaning up doesn’t necessarily mean removing and rewriting large sections of code. Of course, this is sometimes the only solution but it shouldn’t be your first and only step. JavaScript can be an odd language, hence giving generic advice is usually not possible. You always have to evaluate your specific situation and figure out a working solution.

Establish Unit Tests

Having unit tests ensures you understand how the code is intended to work and you don’t accidentily break anything. JavaScript unit testing is worth its own articles, so I won’t be able to go much into detail here. Widely used frameworks are Karma, Jasmine, Mocha or Ava. If you also want to test your user interface, Nightwatch.js and DalekJS are recommended browser automation tools.

The difference between unit testing and browser automation is, that the former tests your JavaScript code itself. It ensures all your modules and general logic work as intended. Browser automation, on the other hand, tests the surface — the user interface — of your project, making sure elements are in the right place and work as expected.

Take care of unit tests before you start refactoring anything else. The stability of your project will improve, and you haven’t even thought about scalability! A great side effect is not being worried all the time that you might have broken something and didn’t notice.

Rebecca Murphey as written an excellent article on writing unit tests for existing JavaScript.

Architecture

JavaScript architecture is another huge topic. Refactoring and cleaning up the architecture boils down to how much experience you have with doing that. We have a lot of different design patterns in software development, but not all of them are a good fit where scalability is concerned. Unfortunately I won’t be able to cover all of the cases in this article, but can at least give you some general advice.

First of all, you should figure out which design patterns are already used in your project. Read up about the pattern, and ensure it’s consistent. One of the keys to scalability is sticking to the pattern, and not mixing methodologies. Of course, you can have different design patterns for different purposes in your project (e.g. using the Singleton Pattern for data structures or short namespaced helper functions, and the Observer Pattern for your modules) but should never write one module with one pattern, and another one with a different pattern.

If there isn’t really any architecture in your project (maybe everything is just in one huge app.js), it’s time to change that. Don’t do it all at once, but piece by piece. Again, there is no generic way to do things and every project setup is different. Folder structures varies between projects, depending on the size and complexity. Usually — on a very basic level — the structure is split up into third-party libraries, modules, data and an entry-point (e.g. index.js, main.js) where all your modules and logic gets initialized.

This leads me to modularization.

Modularize Everything?

Modularization is by far not the answer to the great JavaScript scalability question. It adds another layer of API that developers have to get familiar with. This can be worth the hassle though. The principle is splitting up all your functionality in to tiny modules. By doing that, it is easier to solve problems in your code and to work in a team on the same codebase. Every module should have exactly one purpose and task to do. A module doesn’t know about the outside logic of your application, and can be reused in different locations and situations.

How do you split up a large feature with lots of tightly connected logic? Let’s do this together.

// This example uses the Fetch API to request an API. Let's assume
// that it returns a JSON file with some basic content. We then create a
// new element, count all characters from some fictional content
// and insert it somewhere in your UI.
fetch('https://api.somewebsite.io/post/61454e0126ebb8a2e85d', { method: 'GET' })
  .then(response => {
    if (response.status === 200) {
      return response.json();
    }
  })
  .then(json => {
    if (json) {
      Object.keys(json).forEach(key => {
        const item = json[key];
        const count = item.content.trim().replace(/\s+/gi, '').length;
        const el = `
          <div class="foo-${item.className}">
            <p>Total characters: ${count}</p>
          </div>
        `;
        const wrapper = document.querySelector('.info-element');

        wrapper.innerHTML = el;
      });
    }
  })
  .catch(error => console.error(error));

This is not very modular. Everything is tightly connected and dependent on the other pieces. Imagine this with larger, more complex functions and you would have to debug this because something breaks. Maybe the API doesn’t respond, something changed inside of the JSON or whatever. A nightmare, isn’t it?

Let’s separate out the different responsibilities:

// In the previous example we had a function that counted
// the characters of a string. Let's turn that into a module.
function countCharacters (text) {
  const removeWhitespace = /\s+/gi;
  return text.trim().replace(removeWhitespace, '').length;
}

// The part where we had a string with some markup in it,
// is also a proper module now. We use the DOM API to create
// the HTML, instead of inserting it with a string.
function createWrapperElement (cssClass, content) {
  const className = cssClass || 'default';
  const wrapperElement = document.createElement('div');
  const textElement = document.createElement('p');
  const textNode = document.createTextNode(`Total characters: ${content}`);

  wrapperElement.classList.add(className);
  textElement.appendChild(textNode);
  wrapperElement.appendChild(textElement);

  return wrapperElement;
}

// The anonymous function from the .forEach() method,
// should also be its own module.
function appendCharacterCount (config) {
  const wordCount = countCharacters(config.content);
  const wrapperElement = createWrapperElement(config.className, wordCount);
  const infoElement = document.querySelector('.info-element');

  infoElement.appendChild(wrapperElement);
}

Alright, we have three new modules now. Let’s see the refactored fetch call.

fetch('https://api.somewebsite.io/post/61454e0126ebb8a2e85d', { method: 'GET' })
  .then(response => {
    if (response.status === 200) {
      return response.json();
    }
  })
  .then(json => {
    if (json) {
      Object.keys(json).forEach(key => appendCharacterCount(json[key]))
    }
  })
  .catch(error => console.error(error));

We could also take the logic from inside the .then() methods and separate that, but I think I have demonstrated what modularization means.

If !modularization What Else?

As I already mentioned, turning your codebase in tiny modules adds another layer of API. If you don’t want that, but want to keep it easier for other developers to work with your code, it’s absolutely fine to keep functions larger. You can still break down your code into simpler portions and focus more on testable code.

Document Your Code

Documentation is a heavily discussed topic. One part of the programming community advocates for documenting everything, while another group thinks self-documenting code is the way to go. As with most things in life, I think a good balance of both makes code readable and scalable. Use JSDoc for your documentation.

JSDoc is an API documentation generator for JavaScript. It is usually available as a plugin for all well-known editors and IDE’s. Let’s go through an example:

function properties (name, obj = {}) {
  if (!name) return;
  const arr = [];

  Object.keys(obj).forEach(key => {
    if (arr.indexOf(obj[key][name]) <= -1) {
      arr.push(obj[key][name]);
    }
  });

  return arr;
}

This function takes two parameters and iterates over an object, which then returns an array. This might not be an overly complicated method, but for someone who hasn’t written the code it might take a while to figure out what is going on. Additionally, it’s not obvious what the method does. Let’s start documenting:

/**
 * Iterates over an object, pushes all properties matching 'name' into
 * a new array, but only once per occurance.
 * @param  {String}  propertyName - Name of the property you want
 * @param  {Object}  obj          - The object you want to iterate over
 * @return {Array}
 */
function getArrayOfProperties (propertyName, obj = {}) {
  if (!propertyName) return;
  const properties = [];
  Object.keys(obj).forEach(child => {
    if (properties.indexOf(obj[child][propertyName]) <= -1) {
      properties.push(obj[child][propertyName]);
    }
  });
  return properties;
}

I haven’t touched much of the code itself. Just by renaming the function and adding a short, yet detailed comment block, we’ve improved the readability.

Have An Organized Commit Workflow

Refactoring is a huge mission on its own. To be able to always rollback your changes (in case you break something and only notice later), I recommend committing every update you make. Rewrote a method? git commit (or svn commit, if you work with SVN). Renamed a namespace, folder or a few images? git commit. You get the idea. It might be tedious for some people to do, but it really helps you clean up properly and get organized.

Create a new branch for the entire refactoring effort. Don’t ever work on master! You may have to do quick changes or upload bug fixes to the production environment and you don’t want to deploy your (maybe untested) code until it is tested and finished. Hence it is advised to always work on a different branch.

In case you need short update how all this works, there is an interesting guide from GitHub on their version control workflow.

How To Not Lose Your Mind

Besides all the technical steps required for a clean-up, there is one important step I rarely see mentioned anywhere: not being mad at the previous developer. Of course, this doesn’t apply to everyone, but I know that some people experience this. It took me years to really understand this and get over it. I used to get pretty mad at the previous developers code, their solutions and just why everything was such a mess.

In the end, all that negativity never got me anywhere. It only results in you refactoring more than necessary, wasting your time, and maybe breaking things. This just makes you more and more annoyed. You might spend extra hours and nobody will ever thank you for rewriting an already working module. It’s not worth it. Do what is required, analyse the situation. You can always refactor tiny bits every time you go back to a module.

There are always reasons why code is written the way it is. Maybe the previous developer just didn’t have enough time to do it properly, didn’t know better, or whatever. We have all been there.

Wrapping It Up

Let’s go over all steps again, to create a checklist for your next project.

  1. Analyse the project
  • Put your developer hat away for a moment, and be a user to see what it’s all about.
  • Go through the codebase and make a list of the tools in use.
  • Read up documentation and best practices of the tools.
  • Go through the unit tests to get a feeling for the project on a higher level.
  1. Create a baseline
  • Introduce .editorconfig to keep the coding style guides consistent between different IDEs.
  • Make indentation consistent; tabs or spaces, doesn’t matter.
  • Enforce a naming convention.
  • If not already present, add a linter like ESLint, JSLint, or JSHint.
  • Update dependencies, but do it wisely and watch out for what exactly has been updated.
  1. Cleaning up
  • Establish unit tests and browser automation with tools like Karma, Jasmine, or Nightwatch.js.
  • Make sure the architecture and design pattern are consistent.
  • Don’t mix design patterns, stick to the ones already there.
  • Decide if you want to split up your codebase into modules. Each should only have one purpose and be unaware of the rest of your codebase logic.
  • If you don’t want to do that, focus more on testable code and break it down into simpler blocks.
  • Document your functions and code in a balanced way with properly named functions.
  • Use JSDoc to generate documentation for your JavaScript.
  • Commit regularly and after important changes. If something breaks, it’s easier to go back.
  1. Don’t lose your mind
  • Don’t get mad at the previous developer; negativity will only result in unnecessary refactoring and wasting time.
  • There have been reasons why code is written like it is. Keep in mind that we’ve all been there.

I really hope this article has helped you. Let me know if you struggle with any of the steps, or maybe have some good advice that I didn’t mention!

Frequently Asked Questions (FAQs) about Writing Maintainable JavaScript

What is spaghetti code and why should it be avoided?

Spaghetti code is a term used to describe a complex and tangled web of code that is difficult to read, understand, and maintain. It often results from poor programming practices, such as lack of planning, not following coding standards, or not using object-oriented programming principles. This type of code can lead to numerous problems, including bugs that are hard to track down, difficulty in adding new features, and increased time and cost of maintenance. Therefore, it’s crucial to avoid writing spaghetti code and instead strive for clean, maintainable code.

How can I write maintainable JavaScript code?

Writing maintainable JavaScript code involves several best practices. Firstly, always plan your code before you start writing. This includes understanding the problem you’re trying to solve and designing a solution. Secondly, follow coding standards and conventions. This makes your code easier to read and understand. Thirdly, use object-oriented programming principles. This helps to organize your code and makes it more reusable. Lastly, always document your code. This includes writing comments to explain what your code does and why you made certain decisions.

What are some common pitfalls to avoid when writing JavaScript code?

Some common pitfalls to avoid when writing JavaScript code include not using strict mode, not declaring variables properly, using the ‘==’ operator instead of ‘===’, and not handling errors properly. Avoiding these pitfalls can help you write cleaner, more maintainable code.

How can I refactor my existing spaghetti code into maintainable code?

Refactoring spaghetti code into maintainable code involves several steps. Firstly, you need to understand the existing code. This may involve reading through the code and documenting what each part does. Secondly, identify areas of the code that can be improved. This could be sections of code that are repeated, complex conditional logic, or code that is difficult to understand. Thirdly, make the necessary changes to the code. This could involve breaking down complex functions into smaller, more manageable ones, removing duplicate code, or rewriting sections of code to make them easier to understand. Lastly, test your changes to ensure that the functionality of the code remains the same.

What tools can I use to help write maintainable JavaScript code?

There are several tools available that can help you write maintainable JavaScript code. These include linters like ESLint, which can enforce coding standards and highlight potential problems in your code. Code editors like Visual Studio Code can also help by providing features like syntax highlighting and auto-completion. Additionally, version control systems like Git can help you keep track of changes to your code and make it easier to collaborate with others.

How does object-oriented programming help in writing maintainable code?

Object-oriented programming (OOP) is a programming paradigm that helps in writing maintainable code. It allows you to organize your code into objects, which can contain both data and functions. This makes your code more modular and reusable, as you can create objects that perform specific tasks and then use them throughout your code. OOP also makes your code easier to understand, as it allows you to model real-world concepts in your code.

How important is commenting in maintaining JavaScript code?

Commenting is crucial in maintaining JavaScript code. It helps other developers (and your future self) understand what the code does and why certain decisions were made. This is especially important in complex codebases, where understanding the code’s functionality can be challenging. However, comments should be used judiciously. Over-commenting can make the code cluttered and harder to read. Ideally, your code should be self-explanatory, with comments reserved for explaining why certain decisions were made or clarifying complex sections of code.

How can I ensure my JavaScript code is scalable?

Ensuring your JavaScript code is scalable involves designing your code in a way that allows it to handle increased loads efficiently. This can be achieved by following good programming practices such as using efficient algorithms and data structures, avoiding global variables, and minimizing DOM manipulation. Additionally, using a modular approach to coding, where your code is broken down into smaller, reusable pieces, can also enhance scalability.

What is the role of testing in writing maintainable JavaScript code?

Testing plays a crucial role in writing maintainable JavaScript code. It helps ensure that your code works as expected and can help catch bugs early in the development process. There are several types of testing you can use, including unit testing, integration testing, and end-to-end testing. Using a combination of these testing methods can help ensure that your code is robust and reliable.

How can I keep up with the latest best practices for writing maintainable JavaScript code?

Keeping up with the latest best practices for writing maintainable JavaScript code can be achieved by regularly reading industry blogs, attending webinars and conferences, and participating in online communities. Additionally, practicing continuous learning and regularly reviewing and refactoring your code can also help you stay up-to-date with the latest best practices.

Moritz KrögerMoritz Kröger
View Author

Moritz is a German front-end developer living in Berlin. Although Germans typically have a thing for extra long compound words, he's a friend of short functions.

best practicesdesign patternsnilsonjRefactoringvanilla javascript
Share this article
Read Next
Get the freshest news and resources for developers, designers and digital creators in your inbox each week