Let's Stop Using the Merge-Branch Pattern

There's an anti-pattern in programming that I've taken to calling the "Merge-Branch Pattern." Like a river system where many streams converge before splitting again, it often looks like clean code at first glance, which is why it sometimes gets introduced during refactoring. This pattern exists regardless of programming language, and I've seen it flow into codebases more times than I'd like to admit—including my own.

The Merge-Branch Pattern

This anti-pattern can be described as follows:

  1. Multiple different triggers from different places call the same function (merge) - like tributaries flowing into a main river
  2. An argument is passed to the function to identify which trigger initiated the call
  3. The function then branches based on that argument to perform different actions (branch) - like a river delta splitting into multiple streams

Sample Code

Here's what this anti-pattern looks like. Two different buttons, when clicked, call onTapButton, which then performs different actions based on the button type.

Here, onTapButton is the "merge point" where different flows come together, and switch (type) is the "branch point" where the flow splits again.

function onTapButton(type) {
  switch (type) {
    case "delete":
      deleteItem()
      break
    case "add":
      addItem()
      break
  }
}

return (
  <>
    <button onClick={() => onTapButton("delete")}>Delete</button>
    <button onClick={() => onTapButton("add")}>Add</button>
  </>
)

Fixed Code

Let's eliminate the merging and branching of our code streams. Since this example is simple, we can just directly call the appropriate function for each button, keeping each flow separate from start to finish.

return (
  <>
    <button onClick={() => deleteItem()}>Delete</button>
    <button onClick={() => addItem()}>Add</button>
  </>
)

What's Wrong With the Merge-Branch Pattern?

  • Changes have wider impact across the codebase, like a dam affecting all downstream waters
  • If the merged code becomes complex, developers who don't fully understand it will write workarounds to avoid using it, creating even more turbulent flows

What Happens If Development Continues This Way?

Example: Adding Features

Let's peek into the future of this code. First, you decide you want to show a loading spinner when the Delete or Add buttons are pressed. Easy enough! Since you've unified everything in onTapButton, you only need to add the loading logic in one place. In fact, wasn't this kind of reuse exactly why you created onTapButton in the first place?

async function onTapButton(type) {
  showLoading(true)

  switch (type) {
    case "delete":
      await deleteItem()
      break
    case "add":
      await addItem()
      break
  }

  showLoading(false)
}

Now you're feeling great about adding more buttons. This pattern seems harmless, right?

Six Months Later

For various reasons (we've all been there), you've been away from this project for about six months. Let's see what happened to your once-pristine code:

async function onTapButton(type) {
  // update and back-navigation trigger screen transitions, so don't show loading
  showLoading(type !== "update" && type !== "back-navigation")

  switch (type) {
    case "delete":
      await deleteItem()
      break
    case "add":
      await addItem()
      break
    case "rename":
      await renameItem()
      break
    case "update":
      await udpateItem()
      break
    case "back-navigation":
      await backNavigation()
      break
  }

  // Don't send analytics event when back button is pressed
  if (type !== "back-navigation") {
    analytics.sendEvent("tap-item-button", type)
  }
  showLoading(false)
}

Looks like three new buttons have been added. Also, analytics events are now sent when buttons are pressed. However, some buttons don't need loading indicators or analytics events, so the code now checks the type to determine behavior.

The Problem

It's no longer immediately clear what happens when a button is pressed. Some buttons don't need certain functions, which breaks the original purpose of having shared code. What started as a clean, flowing river has become a muddy, tangled delta with unpredictable currents.

Why Did This Happen?

The engineer who took over after you saw the unified onTapButton and assumed there must be a good reason for this shared function. Naturally, they couldn't just delete onTapButton without risking bugs in existing buttons. So they carefully wrote code to bypass parts of the function for certain button types, trying their best to preserve the original behavior.

Yes, you guessed it—I've been on both sides of this situation. I've created these "helpful abstractions" and later returned to find them twisted into unrecognizable forms.

A Better Approach

What would ideal code look like? We'd remove the onTapButton river junction entirely and have each button directly call its own function, creating separate, clean streams. The showLoading and analytics code would be included in each function:

async function deleteItem() {
  showLoading(true)
  ...
  showLoading(false)
  analytics.sendEvent("tap-item-button", "delete")
}

async function addItem() {
  showLoading(true)
  ...
  showLoading(false)
  analytics.sendEvent("tap-item-button", "add")
}

async function renameItem() {
  showLoading(true)
  ...
  showLoading(false)
  analytics.sendEvent("tap-item-button", "rename")
}

async function updateItem() {
  ...
  analytics.sendEvent("tap-item-button", "update")
}

async function backNavigation() {
  ...
}

Why This Is Better

Doesn't this feel cleaner? It should. I know there's a voice in your head saying "but there's repeated code" or "this needs refactoring," but we need to ignore that voice.

Why? Because we're dealing with "different buttons" as a fundamental premise. The fact that "different buttons" have "different behaviors" has nothing to do with programming languages or code cleanliness. It's related to users, business needs, your boss's opinions, and designer preferences. It reflects the complexity of the real world.

Someone might decide tomorrow that "when this specific button is pressed, the loading spinner should have a different design." And that's perfectly valid.

By eliminating the merge-branch pattern and writing all necessary processing within each function, we've made the behavior of each button more transparent and made it easier to modify the behavior of specific buttons. Each stream flows independently, unaffected by changes to other streams.

In Reality

However, in real projects, changing code like this carries a high risk of introducing bugs. You'd need to verify existing behavior, which takes time and effort. Whether you extract functions one by one or rewrite everything at once, it's extra work.

So what should we do? Be vigilant about avoiding the convergence-divergence pattern from the start. This pattern can sneak in unintentionally. Here are some things to watch out for:

Advice

"Keep unrelated streams flowing separately."

  • Even if processes look similar, if they're triggered by different events, keep them separate—don't force different streams into the same river
  • Don't fear code that appears repetitive at first glance
  • If there truly is common processing, extract that specific processing into a function that can be used by both, like a shared water treatment facility that different streams can pass through

Higher-Level Advice

If repetition really bothers you, you can use functions that accept closures for common code. For example, in our previous code, you might be bothered by the repetition of showLoading calls and write something like this:

async function withLoading(fn) {
  showLoading(true)
  await fn()
  showLoading(false)
}

async function deleteItem() {
  await withLoading(async () => ...)
  analytics.sendEvent("tap-item-button", "delete")
}

async function addItem() {
  await withLoading(async () => ...)
  analytics.sendEvent("tap-item-button", "add")
}

But for simple code like this, I recommend directly calling showLoading. Be careful that withLoading doesn't become a new convergence-divergence pattern. This kind of code is useful when you have operations that absolutely must clean up after themselves. Depending on the language, consider RAII (Resource Acquisition Is Initialization) or C#'s using statement.

Is Redux an Anti-Pattern?

If you've read this far, you might be thinking, "Wait a minute, Redux Actions fit this pattern!" Indeed, state management libraries like Redux utilize the merge-branch pattern. Does that mean we shouldn't use Redux?

There are many great software projects built with Redux that have extremely polished codebases. At the risk of being controversial, I do consider Actions and Reducers to be a form of the merge-branch pattern and thus an anti-pattern. However, this is a design choice in Redux that enables powerful state management features. The pros and cons can't be summed up simply as "anti-pattern." If you can leverage the advantages, there's no need to abandon it. But consolidating all streams into one mighty river does make separation of concerns more difficult, requiring extra care to prevent flooding.

Personally, I prefer approaches that maintain only the necessary shared data stores and leverage derived state. In React, you can use libraries like Recoil or Jotai. Alternatively, paradigms like React Query that reframe state management as a caching strategy might be better. This approach calls necessary data fetching within each function while optimizing behind the scenes. Such code tends to be less high-context and easier for AI tools like Copilot to predict. Writing straightforward code rather than clever, concise code can actually be more efficient.

Conclusion

I've gone off on quite a tangent, but that's my introduction to what I call the "Merge-Branch Pattern." I've heard this concept might be described in some programming books, so if you know a more widely recognized name for this pattern, please let me know! 👉️ @ryoheyc