We migrated a 140,000-line AngularJS application to React last year. The estimate from three different agencies before we started: 14 to 18 months with a team of six. The actual timeline: four months with a team of three, plus an AI agent pipeline that handled roughly 70 percent of the mechanical translation work.
This article isn’t about how AI magically rewrites your frontend. It doesn’t. It’s about which parts of an AngularJS-to-React migration are mechanical translation that an agent handles well, which parts require human architectural decisions, and how to build the pipeline that separates the two so your engineers spend their time on the decisions and the agent handles the grinding.
If you’re sitting on a legacy AngularJS codebase and the migration has been on the roadmap for three years because nobody wants to spend 18 months on it, this is the approach that makes the timeline survivable.
Why AngularJS-to-React migration is uniquely suited to AI automation
Not all framework migrations benefit equally from AI assistance. AngularJS-to-React is the sweet spot because the two frameworks differ in syntax and patterns but share enough conceptual overlap that a well-structured agent can translate between them systematically.
Both frameworks are component-based. An AngularJS directive maps conceptually to a React component. Both have template syntax for rendering, AngularJS uses ng-repeat, ng-if, ng-bind; React uses JSX with map(), ternary operators, and curly brace expressions. Both have data binding, AngularJS uses $scope and two-way binding; React uses useState and one-way data flow. Both have service/dependency injection patterns, AngularJS uses $inject and service factories; React uses hooks, context, and module imports.
These mappings are systematic enough that an agent can learn them. They’re also numerous enough that doing them by hand across 140,000 lines is the soul-crushing part of the migration that makes engineers quit the project.
The parts that aren’t systematic, architectural decisions about state management strategy, component decomposition, routing architecture, and where to introduce new patterns versus translating old ones, are where humans need to drive. The AI-assisted framework migration approach is specifically about separating these two categories and tooling the mechanical one.
The migration pipeline architecture
The pipeline has five stages. The first two are analysis. The middle two are translation. The last is validation. The agent operates across all five, but with different levels of autonomy at each stage.
AngularJS Source
│
▼
┌──────────────────┐
│ 1. Analyse │ Map components, services, dependencies,
│ │ routing, state patterns
└────────┬─────────┘
│
▼
┌──────────────────┐
│ 2. Plan │ Generate migration order based on
│ │ dependency graph (leaves first)
└────────┬─────────┘
│
▼
┌──────────────────┐
│ 3. Translate │ Convert component by component:
│ │ template → JSX, controller → hooks,
│ │ service → module
└────────┬─────────┘
│
▼
┌──────────────────┐
│ 4. Adapt │ Adjust state management, routing,
│ │ side effects, API integration
└────────┬─────────┘
│
▼
┌──────────────────┐
│ 5. Validate │ Run original tests against new
│ │ components, visual regression,
│ │ behaviour comparison
└──────────────────┘
Stage 1: Analysis - mapping what you have
Before translating anything, the agent inventories the entire AngularJS codebase and builds a dependency graph. This sounds simple. It’s the stage where most manual migrations go wrong because someone starts translating components without understanding the dependency chain, creates a translated component that depends on an untranslated service, and discovers the problem three days later.
import ast
import re
from pathlib import Path
from dataclasses import dataclass, field
@dataclass
class AngularComponent:
name: str
filepath: str
component_type: str # directive, component, controller, service, filter
template_path: str | None
dependencies: list[str] = field(default_factory=list)
scope_bindings: list[dict] = field(default_factory=list)
watchers: list[str] = field(default_factory=list)
http_calls: list[dict] = field(default_factory=list)
state_mutations: list[str] = field(default_factory=list)
lines_of_code: int = 0
complexity_score: float = 0.0
class AngularJSAnalyser:
"""Map the entire AngularJS application structure."""
def __init__(self, source_path: str):
self.source = Path(source_path)
self.components: list[AngularComponent] = []
self.dependency_graph: dict[str, list[str]] = {}
def analyse(self) -> AnalysisResult:
# Find all Angular module definitions
js_files = list(self.source.rglob("*.js"))
for filepath in js_files:
content = filepath.read_text()
# Extract directives
directives = re.findall(
r'\.directive\([\'"](\w+)[\'"].*?function\((.*?)\)',
content, re.DOTALL
)
for name, deps in directives:
self._register_component(
name, str(filepath), "directive",
self._parse_dependencies(deps), content
)
# Extract services and factories
services = re.findall(
r'\.(?:service|factory)\([\'"](\w+)[\'"].*?function\((.*?)\)',
content, re.DOTALL
)
for name, deps in services:
self._register_component(
name, str(filepath), "service",
self._parse_dependencies(deps), content
)
# Extract controllers
controllers = re.findall(
r'\.controller\([\'"](\w+)[\'"].*?function\((.*?)\)',
content, re.DOTALL
)
for name, deps in controllers:
self._register_component(
name, str(filepath), "controller",
self._parse_dependencies(deps), content
)
# Build dependency graph
self._build_dependency_graph()
# Calculate migration order (topological sort - leaves first)
migration_order = self._topological_sort()
return AnalysisResult(
components=self.components,
dependency_graph=self.dependency_graph,
migration_order=migration_order,
total_lines=sum(c.lines_of_code for c in self.components),
complexity_distribution=self._complexity_distribution(),
)
def _register_component(self, name, filepath, ctype, deps, content):
"""Extract detailed component metadata."""
component = AngularComponent(
name=name,
filepath=filepath,
component_type=ctype,
template_path=self._find_template(content, name),
dependencies=deps,
scope_bindings=self._extract_scope_bindings(content),
watchers=self._extract_watchers(content),
http_calls=self._extract_http_calls(content),
state_mutations=self._extract_state_mutations(content),
lines_of_code=len(content.split('\n')),
complexity_score=self._calculate_complexity(content),
)
self.components.append(component)
def _extract_watchers(self, content: str) -> list[str]:
"""Find $scope.$watch patterns - these need special React handling."""
return re.findall(r'\$(?:scope|rootScope)\.\$watch\([\'"](.+?)[\'"]', content)
def _extract_scope_bindings(self, content: str) -> list[dict]:
"""Find scope bindings - determines React prop design."""
bindings = re.findall(
r'scope:\s*\{([^}]+)\}', content, re.DOTALL
)
parsed = []
for binding_block in bindings:
for match in re.finditer(r'(\w+):\s*[\'"]([=<&@])[\'"]', binding_block):
parsed.append({
"name": match.group(1),
"type": match.group(2), # = two-way, < one-way, & callback, @ string
})
return parsed
The _extract_watchers method is critical. AngularJS’s $scope.$watch pattern has no direct React equivalent, it’s the mechanism for reactive side effects in AngularJS, and translating it correctly requires understanding what each watcher does and mapping it to the appropriate React pattern (useEffect, useMemo, derived state, or event handlers). The analysis stage flags every watcher so the translation stage handles each one specifically rather than applying a generic pattern.
The scope_bindings extraction determines the React component’s prop interface. AngularJS binding types map to React patterns: = (two-way) becomes a value prop plus an onChange callback, < (one-way) becomes a read-only prop, & (expression/callback) becomes a function prop, @ (string interpolation) becomes a string prop. Getting this mapping right at the analysis stage prevents the most common translation error, two-way bindings that become unidirectional in React without the corresponding onChange handler.
Stage 2: Planning - migration order matters
The dependency graph determines the migration order. Components with no Angular dependencies migrate first. Components that depend on already-migrated components migrate next. This ensures that at every step, a translated component’s dependencies are either already translated or bridged.
class MigrationPlanner:
"""Generate migration plan from dependency analysis."""
def plan(self, analysis: AnalysisResult) -> MigrationPlan:
phases = []
migrated = set()
remaining = set(c.name for c in analysis.components)
# Phase 1: Leaf components - no Angular dependencies
leaves = [c for c in analysis.components
if not any(d in remaining for d in c.dependencies
if d not in ['$scope', '$http', '$q', '$timeout'])]
phases.append(MigrationPhase(
phase_number=1,
components=leaves,
description="Leaf components - no internal dependencies",
automation_confidence="high", # Most mechanical translation
))
migrated.update(c.name for c in leaves)
remaining -= migrated
# Subsequent phases: components whose deps are all migrated
phase_num = 2
while remaining:
ready = [c for c in analysis.components
if c.name in remaining
and all(d in migrated or d not in remaining
for d in c.dependencies)]
if not ready:
# Circular dependency - flag for human decision
phases.append(MigrationPhase(
phase_number=phase_num,
components=[c for c in analysis.components
if c.name in remaining],
description="CIRCULAR DEPENDENCY - requires human decision",
automation_confidence="manual",
))
break
phases.append(MigrationPhase(
phase_number=phase_num,
components=ready,
automation_confidence=self._assess_confidence(ready),
))
migrated.update(c.name for c in ready)
remaining -= {c.name for c in ready}
phase_num += 1
return MigrationPlan(
phases=phases,
total_components=len(analysis.components),
estimated_auto_rate=self._estimate_automation_rate(phases),
)
def _assess_confidence(self, components: list) -> str:
"""How much of this phase can the agent handle autonomously?"""
avg_complexity = sum(c.complexity_score for c in components) / len(components)
has_watchers = any(c.watchers for c in components)
has_http = any(c.http_calls for c in components)
if avg_complexity < 0.3 and not has_watchers:
return "high" # Agent handles ~90%
elif avg_complexity < 0.6 or (has_watchers and not has_http):
return "medium" # Agent handles ~65%, human reviews
else:
return "low" # Agent drafts, human rewrites significantly
The circular dependency detection is the planning stage’s most important output for the human team. Circular dependencies in AngularJS, service A depends on service B which depends on service A, require architectural decisions about how to break the cycle in React. The agent can flag them. It shouldn’t resolve them.
Stage 3: Translation - the mechanical conversion
This is where the agent does the heavy lifting. Each component type has a translation pattern.
import anthropic
class ComponentTranslator:
"""Translate AngularJS components to React."""
def __init__(self, model: str = "claude-sonnet-4-6"):
self.client = anthropic.Anthropic()
self.model = model
async def translate_component(
self,
component: AngularComponent,
template_content: str,
controller_content: str,
migrated_deps: dict[str, str], # dep_name -> new import path
) -> TranslationResult:
prompt = f"""Translate this AngularJS {component.component_type} to a
React functional component using hooks. Follow these rules precisely:
TRANSLATION RULES:
1. $scope variables → useState hooks
2. $scope.$watch → useEffect with dependency array matching watched expression
3. Scope bindings:
- '=' (two-way) → value prop + onChange callback prop
- '<' (one-way) → read-only prop
- '&' (callback) → function prop
- '@' (string) → string prop
4. $http calls → fetch or axios (match project convention)
5. ng-repeat → .map() with key prop
6. ng-if → conditional rendering (ternary or &&)
7. ng-show/ng-hide → conditional className or style
8. ng-click → onClick handler
9. ng-model → value + onChange (controlled component)
10. $timeout → setTimeout wrapped in useEffect cleanup
11. $interval → setInterval wrapped in useEffect cleanup
12. Filters → utility functions imported as modules
13. $rootScope events → React Context or event bus
COMPONENT METADATA:
Name: {component.name}
Type: {component.component_type}
Scope bindings: {component.scope_bindings}
Watchers: {component.watchers}
HTTP calls: {component.http_calls}
Dependencies already migrated: {list(migrated_deps.keys())}
ANGULARJS TEMPLATE:
{template_content}
ANGULARJS CONTROLLER/DIRECTIVE:
{controller_content}
OUTPUT:
1. The React component (.tsx file)
2. Any extracted hooks (custom hooks for complex logic)
3. Any extracted utility functions
4. Import statements referencing already-migrated dependencies
5. PropTypes or TypeScript interface for the component's props
Do NOT:
- Add comments explaining what was AngularJS vs what is React
- Include AngularJS-specific patterns ($digest, $apply, $compile)
- Use class components, functional only
- Introduce state management libraries unless the original used one"""
response = self.client.messages.create(
model=self.model,
max_tokens=8000,
temperature=0.1,
messages=[{"role": "user", "content": prompt}]
)
return self._parse_translation(response.content[0].text, component)
The translation rules are the prompt engineering that makes this work. Each rule maps a specific AngularJS pattern to its React equivalent. The rules aren’t suggestions, they’re the translation grammar that produces consistent output across hundreds of components.
Two rules deserve specific attention.
Rule 2 - $scope.$watch to useEffect, is the translation that goes wrong most often without explicit guidance. The model needs to understand that the watched expression becomes the dependency array, and that the handler becomes the effect body. Without this rule, the model frequently produces useEffect calls with empty dependency arrays or missing cleanup functions.
Rule 13 - $rootScope events to React Context, is an architectural decision embedded in a translation rule. $rootScope.$on and $rootScope.$emit are AngularJS’s global event bus. React doesn’t have a direct equivalent. The rule directs the model toward Context, but complex event patterns may need an explicit event bus library. The analysis stage’s identification of $rootScope usage informs the human team about which components need architectural attention beyond mechanical translation.
Stage 4: Adaptation - the human-intensive work
The translation stage handles syntax. The adaptation stage handles architecture. This is where human decisions dominate and the agent assists rather than drives.
State management strategy: if the AngularJS application used services as shared state containers (the most common pattern), the React equivalent isn’t a 1:1 translation of services to Context providers. The human team decides whether to use Context, Zustand, Redux, or a combination, based on the state complexity, the component tree depth, and the team’s preferences. The agent can draft the state management implementation once the human chooses the strategy. It shouldn’t choose the strategy.
Routing architecture: AngularJS’s ui-router with nested states doesn’t translate directly to React Router’s component-based routing. The route structure often needs redesigning rather than translating. The agent can generate route definitions from the existing state configuration. The human team verifies the route hierarchy and handles redirects, guards, and lazy loading decisions.
API integration layer: AngularJS’s $http service with interceptors, transformers, and global error handling translates to a custom fetch/axios wrapper with middleware. The agent generates the wrapper. The human team reviews authentication handling, retry logic, and error propagation.
The adaptation stage is where https://dextralabs.com/blog/ai-driven-tech-productivity/ compounds. The agent doesn’t handle the adaptation decisions, but it drafts the implementation once the decisions are made, which means the human team spends their time deciding and reviewing rather than typing.
Stage 5: Validation - proving it works
The validation stage runs three checks against every translated component.
class MigrationValidator:
"""Verify translated components match original behaviour."""
async def validate(self, original_path: str, translated_path: str) -> ValidationResult:
results = []
# Check 1: Original tests pass against new component
test_result = await self._run_original_tests(translated_path)
results.append(("test_compatibility", test_result))
# Check 2: Visual regression against original rendering
visual_result = await self._visual_regression(original_path, translated_path)
results.append(("visual_regression", visual_result))
# Check 3: Behaviour comparison - user interactions produce same outcomes
behaviour_result = await self._behaviour_comparison(
original_path, translated_path
)
results.append(("behaviour_comparison", behaviour_result))
return ValidationResult(
passed=all(r[1].passed for r in results),
details=results,
needs_human_review=any(r[1].confidence < 0.8 for r in results),
)
async def _visual_regression(self, original, translated) -> CheckResult:
"""Render both components, compare screenshots."""
original_screenshot = await self._render_and_capture(original)
translated_screenshot = await self._render_and_capture(translated)
diff_percentage = self._pixel_diff(original_screenshot, translated_screenshot)
return CheckResult(
passed=diff_percentage < 0.05, # Less than 5% pixel difference
confidence=1.0 - diff_percentage,
details=f"Visual diff: {diff_percentage:.1%}",
)
The visual regression check is the validation that catches the most issues. A component can pass all tests and still render differently because of CSS scope changes, flex layout differences, or conditional class application that translates syntactically but not visually. The 5 percent pixel difference threshold accounts for minor rendering differences between Angular’s template rendering and React’s JSX rendering while catching significant layout breaks.
The numbers from our 140K-line migration
Total components analysed: 847 (312 directives, 198 controllers, 237 services, 62 filters, 38 config blocks).
Agent-translated with no human modification: 594 (70%). These were leaf components, simple services, and utility filters where the translation was mechanical.
Agent-translated with human review and modification: 178 (21%). These had watchers, complex state patterns, or HTTP interceptors that needed human judgment on the React equivalent.
Human-translated from scratch: 75 (9%). These had circular dependencies, deep $rootScope event coupling, or architectural patterns that required redesigning rather than translating.
Timeline: four months (vs. 14-18 month estimate for manual migration).
Team: three engineers (vs. six estimated for manual).
Post-migration bugs in first 30 days: 23 - 18 were CSS/layout issues caught through visual regression and fixed in under an hour each. 5 were state management edge cases in the human-translated components, not in the agent-translated ones.
That last data point is worth sitting with. The agent-translated components had zero post-migration bugs in the first month. The bugs were all in the human-translated components - the complex ones that required architectural judgment. The mechanical translation was flawless. The judgment calls were where the errors lived.
When this approach doesn’t work
AngularJS applications with heavy $compile usage dynamically compiled templates at runtime, don’t translate mechanically because React doesn’t have a compile step. These patterns require redesign.
Applications tightly coupled to AngularJS-specific libraries (like UI Bootstrap for AngularJS) need those library dependencies replaced, not translated. The agent can identify the dependency but the library selection and migration is a human decision.
Applications where the AngularJS code is itself poorly structured, no component boundaries, everything in controllers, jQuery mixed in, need refactoring before translation. Translating a mess into React produces a React mess. The agent is a translator, not a refactorer.
For teams facing migrations that combine translation with architectural improvement, an agentic AI development company like Dextra Labs builds migration pipelines that handle the mechanical translation while the human team focuses on the architectural decisions that determine whether the migrated application is better than the original, not just equivalent to it.
The AngularJS clock is ticking. Extended Long Term Support ended. The security patches are done. Every month the migration sits on the roadmap is a month the application accumulates risk without remediation. The agent pipeline doesn’t make the migration fun. It makes the timeline survivable.
Published by Dextra Labs - AI Consulting and Enterprise Agent Development
Top comments (0)