DEV Community

rem
rem

Posted on • Originally published at frontendmastery.com on

A guide to frontend migrations

Introduction

The frontend world innovates fast. In the new wave of React state management, we saw how new approaches to a problem space let us tackle common pain points, in new ways that make our life easier.

Despite the cycle of new tools and patterns becoming available. Working with "legacy" technologies and patterns is the day-to-day norm as developers.

The adoption of new tools and coding patterns is gradual for larger projects.

For projects that have been around a while, it's common to have collected multiple overlapping approaches, and tools, that have been introduced over time.

When we start collecting more than one tool or pattern to solve the one specific problem, complexity starts to multiply.

Performance also suffers because we require users to download and run code that solves the same problem multiple times unnecessarily.

Actively managing these migrations between tools, patterns, and frameworks, is key to maintaining simple and fast frontends at scale.

So what are the proven best practices for migrating between frontend tools and frameworks?

This guide will first go over the strategies for tackling frontend framework migrations. We'll then flesh out the principles for managing the adoption of new libaries and tools in our frontends.

The types of frontend migrations

If you stay in the frontend world long enough, it's likely at some point you'll work on a migration type project. Let's go over the two most common ones.

Framework migrations

These are a large, macro style migration and often the most complex. Some of the ways one may encounter these:

  • Through acquisitions where the company that got acquired has a different legacy frontend tech stack, and we now need to integrate it in our existing frontend.
  • A legacy application needs to be modernized. Sometimes a legacy application that hasn't been touched in years, suddenly needs a set of new features. That now makes sense to build in the new stack. Or the experience needs to align with existing products.
  • Scaling issues can crop up. Organizational ones, like hiring developers. It also includes performance and reliability issues. This occurs when working on a legacy code base that has turned into spaghetti, that relies on a few people who understand it. We'll touch on the topic of rewrites in a bit.

Library and tool migrations

These come when we introduce a new tool or pattern that makes our job easier in some way. For smaller projects, quick migrations tend to be easily manageable.

For larger projects, we need strategies. Because the adoption often has second order knock on effects, like introducing new concepts, coding patterns, or what folder structures now make sense with the new approach.

Here are some common examples of when adoption makes sense:

  • Solves the problem in a simpler way. Simple here means it solves the specific problem without any extraneous kilobytes or concepts. A lot of times we adopt tools that are much more powerful than what the problem demands.

  • Solves the problem more performantly. One example here is date formatting. A common frontend problem. Solved with moment at 290kb and date-fns at 89kb.

  • Has wider community support. Sometimes the new approach may not necessarily solve it better than the previous one. But has a wider community around it.

Making it easier to hire, find answers to common issues, and receive future upgrades. A common example is migrating from Flow to Typescript.

Migration strategies: gradual vs big bang

The analogy of waterfall versus agile is a good framing to understand the trade-offs between big bang migrations and incremental ones.

Waterfall methods can work well for projects that are relatively predictable (like the Flow to Typescript example above).

Big bang approaches batch up the changes, and update things in a single fell swoop. This means the change is "atomic" in the sense that the codebase avoids having a prolonged transitional state.

This is risky though, because we can end up going dark for lengthy periods of time. It assumes things won't change during the time we spend migrating in the dark, before pushing it out.

In contrast, an agile approach deals with unknowns through fast feedback loops and smaller iterations, that allow us to learn as we go. This is also the benefit of going gradual.

The risk here is staying in a transitional period indefinitely. Where we need to manage the split brain between the two different approaches. A major source of complexity.

In practice different priorities often come up, which can pause migration efforts.
Over time this leads to stale code no on wants to touch, that becomes risky to change.

A gradual approach is effective, but also has strong negatives if not managed proactively.

For very large code bases under active feature development, where stopping the world for a migration is not feasible. Defaulting to a gradual migration will often be the pragmatic choice.

Frontend framework migrations

Sometimes we may find ourselves needing to migrate to an entirely different framework. Or integrate a legacy framework into our existing one. The example we'll use is migrating a Backbone SPA to an existing tech stack in React.

Should you rewrite?

There’s lots of interesting debate around this. Some famously taking the position that you should never rewrite your software.

Where others disagree pointing out that sometimes a rewriting from scratch is good.

We won't go into the debate here. But here are some common pitfalls to be aware of when migrating to a new frontend framework:

  • Not considering potential trade-offs. We might adopt a new framework thinking all our problems will be solved. Unfortunately there's no guarantee the second time will be better than the first.

This is because first draft of anything is usually bad. Often times it takes a while to find the patterns for your app when adopting a new framework.

And these may change during the migration process (think class based components to functions with hooks). Leaving you in multiple states of transition at once.

  • Under-estimating the challenge. A big part of underestimating the challenge is knowing that it's been done before.

Legacy code has lots of small bug fixes that developers have accumulated over the years. Maybe without tests, comments, or any context.

That tribal knowledge gets lost to the sands of time. It's easy to miss the edge cases when starting in anew without understanding how the old way works.

  • Making updates to the UX at the same time. Scope creep is common in these migrations. This contributes to prolonged timelines because we end up replacing the engine while the plane is still flying, and also building new features at the same time.

  • Not fully committing. Large frontend migrations take time. Sometimes they are multi-year projects. It's difficult when momentum is lost, and other priorities take place. As long as we have at least one user on the old stack, it means we can't delete the old code, and need to maintain a split brain between the old and new.

Progressive strategies for migrating frameworks

Let's take a look at two common progressive migration strategies:

Migrating "outside in"

This is a top down approach. We can think about this as an adaption of the strangler pattern, often used with backend service migrations.

The idea here is that we first migrate the application's shell to the new stack. And migrate the legacy pages one by one at the route level.

The "shell" in this example is referring to the container that handles the rendering of pages.

Which means the router, and top level page layout components. It can also extend to include the shared data layer (if applicable) and things like analytics and monitoring etc.

Let's take an example migrating our legacy Backbone SPA to React.

Assuming we have our new (or existing) React application set up. We now need to create an abstraction that allows us to render Backbone pages as React components.

Here's some pseudoish code on what this might look like:

  const LegacyBackbonePage = ({ pageKey }) => {
    // run any effects that may be needed
    // e.g subscribe to events the legacy can send to the React world
    // ..
    // can also render any common infrastructure components and set up analytics etc
    // ..
    // render element container the Backbone SPA takes over when rendered on the page
    // in the legacy Backbone app this is usually the <body>
    return <div id="legacy-backbone-root" />
  }
Enter fullscreen mode Exit fullscreen mode

From the outside looking in this is just a React component. The component does some set-up and renders an element container for the Backbone page to render into.

To keep everything together, it's often easier to copy the legacy code into a directory in the same repo as the React repo.

Getting set up is the easy part. If you're lucky things may "just work" depending on how the legacy pages work. But probably not.

The hard part of legacy migrations like this, is once the legacy code has taken over that element and rendered itself, there's usually a series of weird bugs to address one by one.

A common challenge is intermediating the client side routers if they conflict. In addition to managing any global styles or scripts that have side effects, that run as a result of loading the legacy code into the React SPA.

We'll hand-wave over these hairy bits for now.

This approach allows you to start getting the benefits of the new stack once the shell has been migrated. And creates clear boundaries between legacy and new.

It allows you to release newly migrated pages progressively at the route level which can be controlled via feature flags.

Migrating "inside out"

This is a bottom up approach. Where the apps shell and entry point stays the same, where legacy pieces are replaced piecemeal from the "inside out".

Going back to our Backbone to React example. This would involve creating a way for Backbone code to create new react roots and render them into sections on the page. That React can then take over.

// .. other imports
// import our React component into the Backbone world
const ComponentWrittenInReact = require('/path/to/cool-new-component')
// ...
// a bunch of legacy Backbone app code 
// ...
// render the new component into an element container
ReactDOM.render(
  // no JSX so we use the imperative API
  React.createElement(
    ComponentWrittenInReact,
    // component props and handler functions passed from legacy
    { someData, someCallback } 
  ),
  containerElement // the root HTML element React will render into and take over
)
Enter fullscreen mode Exit fullscreen mode

Here we pass the initial data and function references to methods the React component can invoke.

In practice orchestrating communication between components in the legacy world and the new world is a challenge.

One simple approach is using custom events, which keeps things decoupled. But doesn't scale that well, and can be a pain to debug.

What approach is best to take?

Outside in is the often better approach if we know for certain we want to migrate to the new framework. It requires us to bite the bullet upfront, and set up the initial shell and infrastructure. Which may not always be feasible.

However this pays off once done, because you can reap the benefits of the new framework early. Which builds strong momentum. And you have clear boundaries at the route level between what is old and what is new.

Inside out is good for exploring whether or not you want to commit to a full migration. It's easier to get started with because you don’t have to touch any of the surrounding shell or infrastructure like routing. It allows replacing sections piecemeal to experiment. In addition to going route by route.

You can still switch to a top-down, outside-in model once you decide that the framework is worth investing in further.

Testing and releasing incrementally

In either case, testing can be tricky. It's often effective to rely on end to end tests to ensure the pages or components load correctly, and are interactable on a real instance.

Using a feature flag tool also allows us to gate newly migrated pieces that can be turned on and off during runtime. This allows for progressively releasing migrated pieces to avoid going dark for long periods of time.

Frontend library and tool migration strategies

We introduce a new tool or pattern that will make our lives easier in the long term. The cost of this in the short to medium term (and often times indefinitely) is the transitional period.

Some common pitfalls in large projects:

  • Old patterns keep replicating. Especially as a new starters, we often look around to see how other people did something and take inspiration from those approaches. This effect often tends to multiply quickly when deprecated patterns are replicated. Extending the transition and making it harder to switch to newer approaches.

  • Performance degradation. As different tools and approaches accumulate for the same problem, we end up shipping that code down to users.

As a somewhat contrived extreme example. We can image an app that started with Redux, later adopted react-query with another other team using apollo-client somewhere else.

Multiply this for each problem domain and throw a framework migration in the mix, and that's how some web apps end up sending megabytes of code down the wire.

This phenomenon of so much Javascript is a main driver for the trend of pushing more back to the server, and only sending what is absolutely necessary.

  • Decreased velocity. When these transitional states linger, it creates stale parts of the code. This creates a reluctance to touch old portions of the code base with the increased risk of causing issues.

It also becomes harder to onboard new developer's, as tribal knowledge usually becomes necessary to understand the older parts that become untouched.

Strategies for mitigating these pain points

  • Break transitions into distinct phases. Often times a migration will need several intermediary stages. Knowing what stage you are in, and what comes next can help you track progress and maintain momentum with clear goals.

  • Get buy in on the migration plan. In the 3'ds of frontend feature leading we highlighted the fact that frontend software engineering is a team sport, and building consensus on what's important is a huge part of that.

  • Track progress through charts showing the percentage of remaining code required migrate can help maintain momentum. Having this clearly laid out helps assign owners who can be accountable for driving the work to eventual completion.

  • Investing in static code analysis helps when leveraging automated change tools like code mods. Writing code that is statically analyzable is a key part of this. This means being consistent across the board. Which helps us find and update common patterns in bulk. Some strategies to help with this:

    • Avoid import aliasing - renaming imports like { Button as RenamedButton } can make things hard to find at consumption points because you lose the context.
    • Dynamic behaviour such as spreading props into components can be hard to search for usages, and statically analyze.
    • Rename with overly explicit names for things that deprecated and shouldn't be used anymore. See this variable name in the React code base as an example. Can also apply to package names and directories.
  • Lean on automation. There are regex based tools you can leverage that detect deprecated usages and automatically leave comments on pull requests.

You can have bots automatically comment to use the new pattern in instead. It helps if you have documentation around your choices that you can point to.

  • Custom ES lint rules Following the point above, you can write custom es-lint rules to help enforce the migration approach you are committed to.

  • Documentation if it actually exists, and gets updated, is very useful. Especially for new starters who join who can read up and get up to speed with the specific tools, technologies and patterns that are used.

Recap

For large frontend code bases that have been around a while, it's easy to accumulate multiple transitions between different tools, coding patterns and even frameworks.

This is an inevitable part of starting with one approach and over time introducing newer ways that attempt to make our lives easier in the longer term.

For large frontend code bases, managing these migrations is a key part in taming the complexity of the frontend architecture and avoiding performance nightmares.

Today's cool approach is tomorrow's legacy. Whenever introducing a new pattern or tool, it's useful to include a plan for both migrating to and also away from the previous way.

For larger framework migrations - it's often better to go top down, "outside in" if you know for certain of the framework you want to commit to.

A bottom-up "inside out" approach works better for exploration on whether or not you want to commit to a migration by replacing smaller sections of your app with the new framework.

There's a lot more to say on this topic than has been covered in this guide. One aspect of the micro frontend tend can potentially be useful for handling these types of larger framework migrations at scale.

References

Top comments (0)