What I Learned from My First Legacy Frontend Project
This article was originally published on Medium. This was my first time taking over a legacy frontend codebase - one that had been around for over five years. The system was built with React 16.8 and relied heavily on class components. This code was tightly coupled, hard to follow, and even harder to extend. Some components had over thousand lines, with tangled logic and strong dependencies. If you change one thing, you couldn't be sure what else might break. My task sounded straightforward: support a new business type with some custom fields and a different layout. But as I dug in, I realized it was more than just adding a few inputs. I had to reuse existing logic, introduce new behaviors, and make sure I didn't break anything already in use. It wasn't just development - it was kind of software surgery. Every change had to be precise. One wrong move, and things could break in ways you wouldn't expect. This article doesn't dive into technical implementation, nor is it meant to complain about how messy legacy code can be. Instead, I want to document the three main pitfalls I encountered during my first legacy delivery - and the three key lessons I learned from them. Pit #1: Don't Refactor Logic Without Fully Understanding the System First Before I started the actual delivery of the new feature, I had a bit of extra time, so I decided to take the opportunity to familiarize myself with the module that I was about to modify. As I dug into the code, I noticed that some parts of the logic had very tight coupling. This made me think that it might be worth refactoring those sections as part of the preparation for the upcoming delivery. It seemed like a good idea at the time. But I made a crucial mistake. I didn't have a complete understanding of the logic I was about to refactor. I thought I had a good grasp of it, but in reality, I didn't know the ins and outs of the code well enough. I didn't fully comprehend how it interacted with other parts of the system, what its true dependencies were, or how the inputs and outputs were structured. Here's the key lesson: when you're refactoring code, you need to have 100% clarity on the logic you're working with. That doesn't mean understanding the entire system, but understanding your specific module - what dependencies it has, and what its inputs and outputs are - is essential. Some of you might be thinking, "Why not just write tests first, and then refactor? Isn't that what TDD is for?" Well, yes… and no. While TDD is a widely recommended approach, and it could certainly help in a clean, modern codebase, the reality is quite different when you're dealing with a legacy system like the one I was working with. And let's be honest - who really enjoys writing tests? After I refactored the code, I found that the behavior had changed unexpectedly. This made it difficult for me to trace back the original logic and behavior, as I had unintentionally altered it without fully understanding it in the first place. To fix this, I needed to identify where the issue came from. I ran tests in my UAT environment, where I could compare the new behavior with the original. By doing this, I was able to debug the inconsistencies between what was happening in my local development environment and the behavior in the higher environment. This process helped me pinpoint where my logic change had led to unexpected outcomes, and I was able to correct the mistake. So the core lesson here: never blindly refactor code without fully understanding the business context and logic behind it. Even if it seems like a "pure UI improvement", it can have unintended consequences that affect the overall system. Pit #2: Don't Wait Until You Fully Understand the System to Start Refactoring - Embrace the "Incremental Approach" While it's essential to have a solid understanding of the logic you're working with before making changes (as discussed in the first pit), there's a fine line between being cautious and getting stuck in analysis paralysis. The key lesson here is: don't wait to understand every single detail of the system before taking action. Instead, aim for an incremental approach - make small, controlled changes and expand your understanding as you go. The reality is that achieving a comprehensive understanding of the entire system is nearly impossible, especially when working with complex legacy code. The system is intricate, with too many moving parts, and expecting to grasp it all upfront can lead to endless analysis with little actual progress. So, what's the solution? Find a reasonable entry point where you can start making small, manageable changes - this will help build your confidence and gradually expand your understanding. For example, when approaching frontend tasks, it's common practice to start with static UI. Static UI tends to have simpler, more isolated logic, making it a good entry point for understanding the structure of the code. Take a look at how the static UI is built.

This article was originally published on Medium.
This was my first time taking over a legacy frontend codebase - one that had been around for over five years.
The system was built with React 16.8 and relied heavily on class components. This code was tightly coupled, hard to follow, and even harder to extend. Some components had over thousand lines, with tangled logic and strong dependencies. If you change one thing, you couldn't be sure what else might break.
My task sounded straightforward: support a new business type with some custom fields and a different layout. But as I dug in, I realized it was more than just adding a few inputs. I had to reuse existing logic, introduce new behaviors, and make sure I didn't break anything already in use. It wasn't just development - it was kind of software surgery. Every change had to be precise. One wrong move, and things could break in ways you wouldn't expect.
This article doesn't dive into technical implementation, nor is it meant to complain about how messy legacy code can be. Instead, I want to document the three main pitfalls I encountered during my first legacy delivery - and the three key lessons I learned from them.
Pit #1: Don't Refactor Logic Without Fully Understanding the System First
Before I started the actual delivery of the new feature, I had a bit of extra time, so I decided to take the opportunity to familiarize myself with the module that I was about to modify. As I dug into the code, I noticed that some parts of the logic had very tight coupling. This made me think that it might be worth refactoring those sections as part of the preparation for the upcoming delivery. It seemed like a good idea at the time.
But I made a crucial mistake. I didn't have a complete understanding of the logic I was about to refactor. I thought I had a good grasp of it, but in reality, I didn't know the ins and outs of the code well enough. I didn't fully comprehend how it interacted with other parts of the system, what its true dependencies were, or how the inputs and outputs were structured.
Here's the key lesson: when you're refactoring code, you need to have 100% clarity on the logic you're working with. That doesn't mean understanding the entire system, but understanding your specific module - what dependencies it has, and what its inputs and outputs are - is essential.
Some of you might be thinking, "Why not just write tests first, and then refactor? Isn't that what TDD is for?" Well, yes… and no. While TDD is a widely recommended approach, and it could certainly help in a clean, modern codebase, the reality is quite different when you're dealing with a legacy system like the one I was working with. And let's be honest - who really enjoys writing tests?
After I refactored the code, I found that the behavior had changed unexpectedly. This made it difficult for me to trace back the original logic and behavior, as I had unintentionally altered it without fully understanding it in the first place.
To fix this, I needed to identify where the issue came from. I ran tests in my UAT environment, where I could compare the new behavior with the original. By doing this, I was able to debug the inconsistencies between what was happening in my local development environment and the behavior in the higher environment. This process helped me pinpoint where my logic change had led to unexpected outcomes, and I was able to correct the mistake.
So the core lesson here: never blindly refactor code without fully understanding the business context and logic behind it. Even if it seems like a "pure UI improvement", it can have unintended consequences that affect the overall system.
Pit #2: Don't Wait Until You Fully Understand the System to Start Refactoring - Embrace the "Incremental Approach"
While it's essential to have a solid understanding of the logic you're working with before making changes (as discussed in the first pit), there's a fine line between being cautious and getting stuck in analysis paralysis. The key lesson here is: don't wait to understand every single detail of the system before taking action. Instead, aim for an incremental approach - make small, controlled changes and expand your understanding as you go.
The reality is that achieving a comprehensive understanding of the entire system is nearly impossible, especially when working with complex legacy code. The system is intricate, with too many moving parts, and expecting to grasp it all upfront can lead to endless analysis with little actual progress.
So, what's the solution?
Find a reasonable entry point where you can start making small, manageable changes - this will help build your confidence and gradually expand your understanding. For example, when approaching frontend tasks, it's common practice to start with static UI. Static UI tends to have simpler, more isolated logic, making it a good entry point for understanding the structure of the code.
Take a look at how the static UI is built. Focus on things like the render
function in your React components, which handles the visual part of the UI. This part is generally less complicated, so it gives you a solid foundation to start making small adjustments. Once you're comfortable with that, you can move on to more interactive parts of the code, tackling them one step at a time.
This incremental approach works especially well with legacy systems. Trying to understand everything upfront before making any changes can slow you down unnecessarily. Instead, by taking it step-by-step, you can start making progress right away and build your understanding as you go.
Pit #3: Migrating Lifecycle Methods Isn't Just About Replacing Them
When I took over this legacy code, I was already accustomed to React 18 and functional components with hooks, so I wasn't very familiar with class components and their lifecycle methods - especially the ones like componentWillReceiveProps
, which is deprecated, and getDerivedStateFromProps
, which is the recommended alternative. My initial instinct was to replace these deprecated methods with their newer counterparts. But soon, I realized that migration isn't just about swapping one lifecycle method for another.
React provides alternatives, such as getDerivedStateFromProps
for componentWillReceiveProps
. However, migrating lifecycle methods isn't as simple as finding a "replacement." Each lifecycle method is triggered at specific points in the component's lifecycle and serves particular use cases. Understanding how and when each method is invoked is critical. Without this understanding, you risk introducing new issues or inconsistencies.
Take my specific case, for example. In the project I was working on, componentWillReceiveProps
was in use, and my goal was to migrate it to getDerivedStateFromProp
s. The reason for this migration was that the new requirement was to update the component's state based on new props, which made getDerivedStateFromProps
a suitable alternative. However, there's a catch - componentWillReceiveProps
and getDerivedStateFromProps
have different behaviors and usage patterns.
After analyzing the existing code, I realized that componentWillReceiveProps
was already being used to update the state based on new props, but it was updating different properties of the state. Given that the new requirements aligned with what getDerivedStateFromProps
is intended for - updating state based on new props - I saw an opportunity to merge the new requirements with the existing logic. By doing this, I was able to combine the old logic with the new behavior, making the migration smoother.
Lesson learned: Migrating lifecycle methods isn't just about replacing deprecated methods with the new ones React provides. It's about understanding the logic behind each method, when it's triggered, and whether it fits your specific needs. In my case, I needed to ensure that the new and old logic could be combined effectively before proceeding with the migration.
Respect the System, Respect the Unknown
Legacy systems aren't monsters. They've grown over time to support real, often messy business needs. The complexity you see is rarely accidental - it reflects years of decisions, constraints, and trade-offs.
After delivering my first feature on this project, I started to see things more clearly. Respecting the system doesn't mean staying hands-off - it just means knowing what's already there, and being cautious about what might break when you change it. You can still refactor boldly, but only if you understand the impact.
When things feel overwhelming, it helps to find one small, solid starting point. Something you're sure about. From there, piece by piece, you build up enough context to move forward. That's how I've learned to work with legacy code - not by fully mastering it first, but by finding my footing one step at a time.
What about you? Have you worked on legacy projects? Let me know below!