Refactoring an if/else hell in JavaScript

This article is extracted from the book JavaScript for Rails Developers and adapted for the web. Get your copy today. ✌️


Writing code is not a linear process—you can’t always see what needs to be written when staring at a blank canvas. In this article, I want to document the steps I took to write a method from from a larger codebase. You’ll see how it evolved from a highly procedural structure with difficult to follow if/else branches into a more object-oriented design that is actually followable and more straightforward to maintain.

For context: the feature of this code allows lines of a code editor (that is built in the book) to be moved up and down using the arrow keys. References like view, lineAt, dispatch and so on are specific to the underlying library, CodeMirror, that is being used.

Quick reminder: when refactoring, the behavior of the code should remain unchanged. In this case, that means I can’t alter the result of it and how the moveLine method is used in its parent context.

First look at the initial version I started with. You can see how the code closely follows my thought process, focusing on what I needed the feature to accomplish:

  • If the direction (from the arrow key) is up, dispatch the changes—unless duplicate is true.
  • If the direction is down, do the same, but in reverse.
export const moveLine = (view, direction, { duplicate = false } = {}) => {
  const document = view.state.doc
  const selection = view.state.selection.main
  const line = document.lineAt(selection.from)
  const lineCount = document.lines

  if (direction === "up") {
    if (duplicate) {
      const changes = {
        from: line.from,
        to: line.from,
        insert: line.text + "\n"
      }

      view.dispatch({
        changes,
        selection: {
          anchor: selection.anchor,
          head: selection.head
        }
      })

    } else if (line.number > 1) {
      const prevLine = document.lineAt(line.from - 1)
      const changes = {
        from: prevLine.from,
        to: line.to,
        insert: line.text + "\n" + prevLine.text
      }

      view.dispatch({
        changes,
        selection: {
          anchor: selection.anchor - prevLine.length - 1,
          head: selection.head - prevLine.length - 1
        }
      })
    }

    return true

  } else if (direction === "down") {
    if (duplicate) {
      const changes = {
        from: line.to,
        to: line.to,
        insert: "\n" + line.text
      }

      view.dispatch({
        changes,
        selection: {
          anchor: selection.anchor + line.length + 1,
          head: selection.head + line.length + 1
        }
      })
    } else if (line.number < lineCount) {
      const nextLine = document.lineAt(line.to + 1)
      const changes = {
        from: line.from,
        to: nextLine.to,
        insert: nextLine.text + "\n" + line.text
      }

      view.dispatch({
        changes,
        selection: {
          anchor: selection.anchor + nextLine.length + 1,
          head: selection.head + nextLine.length + 1
        }
      })
    }

    return true
  }

  return false
}

My first step was to extract the logic from each branch into its own method. This makes it much easier to identify patterns within the code:

  • the dispatch pattern (view.dispatch());
  • the changes object (const changes = {});
  • the selection calculation (selection.anchor/head - length - 1).
export const moveLine = (view, direction, { duplicate = false } = {}) => {
  const document = view.state.doc
  const selection = view.state.selection.main
  const currentLine = document.lineAt(selection.from)
  const totalLines = document.lines

  if (direction === "up") {
    return moveLineUp(view, document, selection, currentLine, duplicate)
  } else if (direction === "down") {
    return moveLineDown(view, document, selection, currentLine, totalLines, duplicate)
  }

  return false
}

const moveLineUp = (view, document, selection, currentLine, duplicate) => {
  if (duplicate) {
    return duplicateLineUp(view, selection, currentLine)
  } else if (currentLine.number > 1) {
    return shiftLineUp(view, document, selection, currentLine)
  }

  return false
}

const moveLineDown = (view, document, selection, currentLine, totalLines, duplicate) => {
  if (duplicate) {
    return duplicateLineDown(view, selection, currentLine)
  } else if (currentLine.number < totalLines) {
    return shiftLineDown(view, document, selection, currentLine)
  }

  return false
}

const duplicateLineUp = (view, selection, currentLine) => {
  const changes = {
    from: currentLine.from,
    to: currentLine.from,
    insert: `${currentLine.text}\n`
  }

  view.dispatch({
    changes,
    selection: { anchor: selection.anchor, head: selection.head }
  })

  return true
}

const duplicateLineDown = (view, selection, currentLine) => {
  const changes = {
    from: currentLine.to,
    to: currentLine.to,
    insert: `\n${currentLine.text}`
  }

  view.dispatch({
    changes,
    selection: {
      anchor: selection.anchor + currentLine.length + 1,
      head: selection.head + currentLine.length + 1
    }
  })

  return true
}

const shiftLineUp = (view, document, selection, currentLine) => {
  const previousLine = document.lineAt(currentLine.from - 1)

  const changes = {
    from: previousLine.from,
    to: currentLine.to,
    insert: `${currentLine.text}\n${previousLine.text}`
  }

  view.dispatch({
    changes,
    selection: {
      anchor: selection.anchor - previousLine.length - 1,
      head: selection.head - previousLine.length - 1
    }
  })

  return true
}

const shiftLineDown = (view, document, selection, currentLine) => {
  const nextLine = document.lineAt(currentLine.to + 1)

  const changes = {
    from: currentLine.from,
    to: nextLine.to,
    insert: `${nextLine.text}\n${currentLine.text}`
  }

  view.dispatch({
    changes,
    selection: {
      anchor: selection.anchor + nextLine.length + 1,
      head: selection.head + nextLine.length + 1
    }
  })

  return true
}

Next, I wanted to remove the duplicated dispatch pattern and include it only once in the moveLine method. Doing this helped highlight even clearer patterns in the returned objects.

export const moveLine = (view, direction, { duplicate = false } = {}) => {
  const document = view.state.doc
  const selection = view.state.selection.main
  const currentLine = document.lineAt(selection.from)
  const totalLines = document.lines

  let operation

  if (direction === "up") {
    operation = moveLineUp(document, selection, currentLine, duplicate)
  } else if (direction === "down") {
    operation = moveLineDown(document, selection, currentLine, totalLines, duplicate)
  }

  if (!operation) return false

  view.dispatch({
    changes: operation.changes,
    selection: operation.selection
  })

  return true
}

const moveLineUp = (document, selection, currentLine, duplicate) => {
  if (duplicate) {
    return duplicateLineUp(selection, currentLine)
  } else if (currentLine.number > 1) {
    return shiftLineUp(document, selection, currentLine)
  }

  return null
}

const moveLineDown = (document, selection, currentLine, totalLines, duplicate) => {
  if (duplicate) {
    return duplicateLineDown(selection, currentLine)
  } else if (currentLine.number < totalLines) {
    return shiftLineDown(document, selection, currentLine)
  }

  return null
}

const duplicateLineUp = (selection, currentLine) => ({
  changes: {
    from: currentLine.from,
    to: currentLine.from,
    insert: `${currentLine.text}\n`
  },

  selection: {
    anchor: selection.anchor,
    head: selection.head
  }
})

const duplicateLineDown = (selection, currentLine) => ({
  changes: {
    from: currentLine.to,
    to: currentLine.to,
    insert: `\n${currentLine.text}`
  },

  selection: {
    anchor: selection.anchor + currentLine.length + 1,
    head: selection.head + currentLine.length + 1
  }
})

const shiftLineUp = (document, selection, currentLine) => {
  const previousLine = document.lineAt(currentLine.from - 1)

  return {
    changes: {
      from: previousLine.from,
      to: currentLine.to,
      insert: `${currentLine.text}\n${previousLine.text}`
    },

    selection: {
      anchor: selection.anchor - previousLine.length - 1,
      head: selection.head - previousLine.length - 1
    }
  }
}

const shiftLineDown = (document, selection, currentLine) => {
  const nextLine = document.lineAt(currentLine.to + 1)

  return {
    changes: {
      from: currentLine.from,
      to: nextLine.to,
      insert: `${nextLine.text}\n${currentLine.text}`
    },

    selection: {
      anchor: selection.anchor + nextLine.length + 1,
      head: selection.head + nextLine.length + 1
    }
  }
}

I wanted to try moving the duplicated method into the direction logic instead, just to see how that would work.

export const moveLine = (view, direction, { duplicate = false } = {}) => {
  const document = view.state.doc
  const selection = view.state.selection.main
  const currentLine = document.lineAt(selection.from)
  const totalLines = document.lines

  let operation

  if (direction === "up") {
    operation = moveLineUp(document, selection, currentLine, duplicate)
  } else if (direction === "down") {
    operation = moveLineDown(document, selection, currentLine, totalLines, duplicate)
  }

  if (!operation) return false

  view.dispatch({
    changes: operation.changes,
    selection: operation.selection
  })

  return true
}

const moveLineUp = (document, selection, currentLine, duplicate) => {
  if (currentLine.number === 1 && !duplicate) return null

  if (duplicate) {
    return {
      changes: {
        from: currentLine.from,
        to: currentLine.from,
        insert: `${currentLine.text}\n`
      },

      selection: {
        anchor: selection.anchor,
        head: selection.head
      }
    }
  }

  const previousLine = document.lineAt(currentLine.from - 1)

  return {
    changes: {
      from: previousLine.from,
      to: currentLine.to,
      insert: `${currentLine.text}\n${previousLine.text}`
    },

    selection: {
      anchor: selection.anchor - previousLine.length - 1,
      head: selection.head - previousLine.length - 1
    }
  }
}

const moveLineDown = (document, selection, currentLine, totalLines, duplicate) => {
  if (currentLine.number === totalLines && !duplicate) return null

  if (duplicate) {
    return {
      changes: {
        from: currentLine.to,
        to: currentLine.to,
        insert: `\n${currentLine.text}`
      },

      selection: {
        anchor: selection.anchor + currentLine.length + 1,
        head: selection.head + currentLine.length + 1
      }
    }
  }

  const nextLine = document.lineAt(currentLine.to + 1)

  return {
    changes: {
      from: currentLine.from,
      to: nextLine.to,
      insert: `${nextLine.text}\n${currentLine.text}`
    },

    selection: {
      anchor: selection.anchor + nextLine.length + 1,
      head: selection.head + nextLine.length + 1
    }
  }
}

I got stuck at this point. There’s still so much happening on every line, and it’s still not easy to follow. I want to try moving it into a class (this would mean changing one of the refactoring rules, but let’s work around it for now).

class Line {
  constructor(view, direction, duplicate = false) {
    this.view = view
    this.direction = direction
    this.duplicate = duplicate
  }

  move() {
    if (!this.duplicate && this.#atBoundary) return false

    const operation = this.duplicate
      ? this.#duplicate()
      : this.#swap()

    this.view.dispatch(operation)

    return true
  }

  // private

  #duplicate() {
    return {
      changes: this.#duplicateChanges,
      selection: this.#newSelection
    }
  }

  #swap() {
    return {
      changes: this.#swapChanges,
      selection: this.#newSelection
    }
  }

  get #document() {
    return this.view.state.doc
  }

  get #selection() {
    return this.view.state.selection.main
  }

  get #currentLine() {
    return this.#document.lineAt(this.#selection.from)
  }

  get #moveUp() {
    return this.direction === "up"
  }

  get #targetLine() {
    return this.#document.lineAt(
      this.#moveUp ? this.#currentLine.from - 1 : this.#currentLine.to + 1
    )
  }

  get #atBoundary() {
    return this.#moveUp
      ? this.#currentLine.number === 1
      : this.#currentLine.number === this.#document.lines
  }

  get #selectionOffset() {
    if (this.duplicate && this.#moveUp) return 0

    const lineLength = this.duplicate ? this.#currentLine.length : this.#targetLine.length

    return (lineLength + 1) _ (this.#moveUp ? -1 : 1)
  }

  get #duplicateChanges() {
    return {
      from: this.#moveUp ? this.#currentLine.from : this.#currentLine.to,
      to: this.#moveUp ? this.#currentLine.from : this.#currentLine.to,
      insert: this.#moveUp ? `${this.#currentLine.text}\n` : `\n${this.#currentLine.text}`
    }
  }

  get #swapChanges() {
    return {
      from: this.#moveUp ? this.#targetLine.from : this.#currentLine.from,
      to: this.#moveUp ? this.#currentLine.to : this.#targetLine.to,
      insert: this.#moveUp
        ? `${this.#currentLine.text}\n${this.#targetLine.text}`
        : `${this.#targetLine.text}\n${this.#currentLine.text}`
    }
  }

  get #newSelection() {
    return {
      anchor: this.#selection.anchor + this.#selectionOffset,
      head: this.#selection.head + this.#selectionOffset
    }
  }
}

export const moveLine = (view, direction, { duplicate = false } = {}) => {
  const line = new Line(view, direction, duplicate)

  return line.move()
}

And wow, this class is actually readable! Previously, you had to scan most of the method to understand what was happening. With this class, you only need to focus on the move method. If you’re interested in any of the internals, you can dive deeper into those private (getter) methods. Sure, the majority of the logic is still there, but now it’s encapsulated in smaller methods, making it objectively easier to understand.

This class works because of the moveLine export, which returns the result of the move method from Line. As a result, it can still be imported in its parent as moveLine.

This is a perfect example of a situation where I should have taken a step back quicker and followed my own advice: if it’s more than a few lines of code, you likely need to create a class.


Interested in making JavaScript your second-favorite language? Get your copy of JavaScript for Rails Developers today.

Published at . Have suggestions or improvements on this content? Do reach out.

More articles like this on modern Rails & frontend? Get them first in your inbox.
JavaScript for Rails Developers
Out now

UI components Library for Ruby on Rails apps

$ 99 one-time
payment

View components