Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 076 / 100

Adding React to a legacy AngularJS app, explained simply

How we modernized a legacy UI without a rewrite.

Expona Admin9 min read

Adding React to a legacy AngularJS app

Rewriting an entire legacy AngularJS application from scratch is a recipe for burnout and missed deadlines. We faced this exact situation at one of my clients: a decade-old e-commerce platform running on AngularJS with thousands of lines of spaghetti code, but a product roadmap that couldn't wait for a ground-up rewrite. The solution wasn't to rip and replace—it was to gradually introduce React components alongside the existing AngularJS views, letting both frameworks coexist peacefully while we modernized the UI piece by piece.

This approach has become my go-to strategy for legacy UI modernization. Over 100 days of React and Next.js, we've covered component design, state management, and performance optimization. Today, we're tackling a real-world scenario: how to blend a modern framework into a system built on older technology without derailing the business.

Why incremental migration matters

A full rewrite sounds clean on a whiteboard. In practice, it means freezing features for months, training the team on an entirely new codebase, and betting the product's stability on untested code. I've seen this fail repeatedly.

The incremental approach lets you:

  • Ship new features in React immediately while older screens stay in AngularJS
  • Prove the pattern before committing the entire team
  • Keep the product stable and deployable every week
  • Reduce the bus factor—knowledge transfer happens gradually, not all at once

We used this strategy with a CRM platform running AngularJS 1.6. Within three months, we'd migrated 40% of the UI to React, shipping better performance and developer velocity with zero downtime.

Mount React islands—Render React components into AngularJS views with a small bridge directive

The core challenge is this: AngularJS doesn't know what React is, and vice versa. We need a bridge—a small AngularJS directive that acts as a mounting point for React components.

Here's the concept. In your AngularJS template, you add a custom element:

tsx
<!-- In your AngularJS view -->
<div ng-controller="DashboardCtrl">
  <react-component 
    component="ReactDashboardWidget"
    props="widgetData"
    on-event="handleWidgetEvent">
  </react-component>
</div>

Then define an AngularJS directive that mounts a React component at that DOM node:

tsx
// react-bridge.js
app.directive('reactComponent', ['$timeout', function($timeout) {
  return {
    restrict: 'E',
    scope: {
      component: '=',
      props: '=',
      onEvent: '&'
    },
    link: function(scope, element, attrs) {
      // Wait for AngularJS digest to finish
      $timeout(function() {
        const componentClass = window.ReactComponents[scope.component];
        if (!componentClass) {
          console.error('React component not found: ' + scope.component);
          return;
        }

        const handleCallback = (eventName, data) => {
          scope.$apply(function() {
            scope.onEvent({ event: eventName, data: data });
          });
        };

        const props = {
          ...scope.props,
          onEvent: handleCallback
        };

        const root = ReactDOM.createRoot(element[0]);
        root.render(
          React.createElement(componentClass, props)
        );

        // Cleanup on destroy
        scope.$on('$destroy', function() {
          root.unmount();
        });
      });
    }
  };
}]);

On the React side, your component is just a normal component:

tsx
// ReactDashboardWidget.tsx
export const ReactDashboardWidget = ({ 
  data, 
  onEvent 
}: Props) => {
  const handleRefresh = () => {
    onEvent('refresh', { timestamp: Date.now() });
  };

  return (
    <div className="widget">
      <h3>{data.title}</h3>
      <button onClick={handleRefresh}>Refresh</button>
    </div>
  );
};

The directive sits in the middle, translating AngularJS's `$scope` into React's props and callbacks back into AngularJS events. No side effects, no leaky abstractions.

We used this pattern to replace the product dashboard. The AngularJS controller passed widget data to the React component, React handled the UI rendering and interactions, and when the user clicked "export," the React component called the callback, which triggered an AngularJS service to save to the backend. Both frameworks worked in harmony.

Share state carefully—Pass data in through props, events out through callbacks

The moment you start sharing mutable state between AngularJS and React, you've introduced a bug factory. AngularJS uses watchers and digest cycles; React uses reconciliation and re-renders. They don't play well when they're both mutating the same object.

The rule is simple: unidirectional data flow.

  • Down: AngularJS passes immutable data to React via props
  • Up: React emits events; AngularJS listens and updates its state

Here's what this looks like in practice. Your AngularJS controller fetches data:

tsx
// legacy-controller.js
app.controller('OrdersCtrl', function($scope, OrderService) {
  $scope.orders = [];

  OrderService.list().then(function(data) {
    // Snapshot: convert to immutable copy
    $scope.orderData = Object.freeze(data.map(order => ({ ...order })));
  });

  // Listen for events from React component
  $scope.handleOrderUpdate = function(eventData) {
    const { orderId, status } = eventData.data;
    
    OrderService.update(orderId, { status }).then(function(updated) {
      // Update the snapshot, which triggers re-render in React
      $scope.orderData = Object.freeze([
        ...$scope.orderData.map(o => o.id === orderId ? updated : o)
      ]);
    });
  };
});

The React component receives this data and emits events:

tsx
// OrdersTable.tsx
export const OrdersTable = ({ orders, onOrderUpdate }) => {
  const updateStatus = (orderId, newStatus) => {
    onOrderUpdate('orderUpdate', { orderId, status: newStatus });
  };

  return (
    <table>
      <tbody>
        {orders.map(order => (
          <tr key={order.id}>
            <td>{order.id}</td>
            <td>
              <select 
                value={order.status}
                onChange={(e) => updateStatus(order.id, e.target.value)}
              >
                <option>pending</option>
                <option>processing</option>
                <option>shipped</option>
              </select>
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  );
};

Notice the `Object.freeze()` call. This prevents accidental mutations. It's a small guard that saves hours of debugging.

We applied this pattern across a trading platform with real-time data. React components consumed order snapshots, emitted trade events, and the AngularJS layer handled WebSocket updates. The isolation was crucial—if React tried to mutate the order object directly, the WebSocket updates would corrupt the data.

Migrate screen by screen—New features in React, old screens replaced over time

Don't try to migrate everything at once. Pick one screen, move it entirely to React, ship it, iterate. Then pick the next one.

A practical migration order:

  1. High-traffic screens first: More users, more ROI from React's performance. We started with the dashboard and customer list.
  2. Self-contained screens: Avoid screens with deep dependencies on other AngularJS controllers. The product detail page had 12 nested controllers—we left it for month two.
  3. Screens with performance pain: If AngularJS watchers are causing jank, React's reconciliation will feel like night and day.

Here's how to structure the migration. Create a router middleware or guard that decides which version to load:

tsx
// router-config.js
const screenRegistry = {
  '/dashboard': { type: 'react', component: ReactDashboard },
  '/orders': { type: 'react', component: ReactOrdersPage },
  '/settings': { type: 'angular', controller: 'SettingsCtrl' },
  '/reports': { type: 'angular', controller: 'ReportsCtrl' }
};

app.config(['$routeProvider', function($routeProvider) {
  Object.entries(screenRegistry).forEach(([path, config]) => {
    if (config.type === 'react') {
      $routeProvider.when(path, {
        template: '<react-root></react-root>',
        resolve: {
          component: () => config.component
        }
      });
    } else {
      $routeProvider.when(path, {
        templateUrl: `views/${path}.html`,
        controller: config.controller
      });
    }
  });
}]);

This registry lets you see, at a glance, which screens are migrated and which are still AngularJS. As you migrate more screens, you update the registry. Within a few weeks, the balance shifts.

On the e-commerce platform, we migrated in this order: product search (highest traffic, self-contained) → cart (complex interactions, worth the effort) → checkout (business-critical, so we tested extensively before moving) → settings (low-traffic, left for last).

Unify the build—One bundler for both so the team has one workflow

If you have separate build pipelines, developers will hate you. "Is this a webpack config issue or a Grunt issue?" becomes a daily question.

Migrate the entire build to a single bundler. We chose Webpack because the team was already familiar with it from frontend experiments. Vite would be my choice today—it's faster and has excellent documentation.

Your Webpack config should handle both AngularJS and React:

tsx
// webpack.config.js
module.exports = {
  entry: './src/index.js',
  output: {
    filename: '[name].[contenthash].js',
    path: path.resolve(__dirname, 'dist')
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: 'ts-loader',
        exclude: /node_modules/
      },
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              '@babel/preset-env',
              ['@babel/preset-react', { runtime: 'automatic' }]
            ]
          }
        }
      },
      {
        test: /\.scss$/,
        use: ['style-loader', 'css-loader', 'sass-loader']
      }
    ]
  },
  resolve: {
    extensions: ['.tsx', '.ts', '.js'],
    alias: {
      '@components': path.resolve(__dirname, 'src/components'),
      '@services': path.resolve(__dirname, 'src/services')
    }
  }
};

Then in your entry file, bootstrap both frameworks:

tsx
// src/index.js
import angular from 'angular';
import ReactDOM from 'react-dom/client';

// AngularJS app
import './app.module';
import './controllers';
import './directives';

// React components registry
import * as ReactComponents from './react-components';
window.ReactComponents = ReactComponents;

// Bootstrap AngularJS
angular.bootstrap(document.documentElement, ['app']);

// Register React root for pages that use React entirely
const reactRoot = document.getElementById('react-root');
if (reactRoot) {
  const root = ReactDOM.createRoot(reactRoot);
  root.render(<App />);
}

One bundler, one build command, one mental model. When a junior developer joins, there's no "wait, how do I rebuild AngularJS templates?" confusion.

The CRM platform ran `npm run build` and generated assets for both frameworks. Deployment was identical whether a screen was AngularJS or React.

Common mistakes

Mutating shared state directly: "It's faster" is a trap. The moment AngularJS and React both mutate the same object, you're debugging race conditions. Always freeze or clone data at the boundary.

Creating tight coupling between layers: React components shouldn't import AngularJS services directly. Pass everything through props. This makes components testable and reusable.

Migrating before the pattern is proven: Test your bridge directive on a low-stakes screen first. We used a notification widget before touching the entire dashboard. Failures there were harmless; failures on the main order screen would've blocked the business.

Forgetting to handle errors at the boundary: If a React component crashes, the entire page goes white unless you wrap it in an error boundary and integrate that with AngularJS error handling.

Key takeaways

  • A bridge directive lets React and AngularJS coexist. The directive mounts React into AngularJS templates and translates events between the two frameworks.
  • Enforce unidirectional data flow: props down, events up. Never mutate shared state; always snapshot it.
  • Migrate one screen at a time. Start with high-traffic, self-contained screens. Keep a registry so the team knows what's been moved.
  • Unify the build system first. One bundler, one build command, one workflow. This removes friction and confusion.
  • Test the pattern on low-stakes components before betting the product on it. A widget failure teaches you more than a dashboard failure.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles