React Component Migration Toolkit
Local Setup
# Install dependencies
npm install -D typescript ts-node @types/node ts-morph
{
"scripts": {
"convert": "ts-node ./scripts/convert.ts",
"convert:dry": "ts-node ./scripts/convert.ts --dry-run",
"convert:watch": "nodemon --watch src --ext js,jsx,ts,tsx --exec npm run convert"
}
}
Core Conversion Script
import { Project, IndentationText, ScriptKind } from 'ts-morph'
import { createTransformerPipeline } from './transformers'
const project = new Project({
tsConfigFilePath: 'tsconfig.json',
manipulationSettings: {
indentationText: IndentationText.TwoSpaces,
scriptKind: ScriptKind.TSX
}
})
async function convertFile(filePath: string, dryRun = false) {
const sourceFile = project.addSourceFileAtPath(filePath)
const originalContent = sourceFile.getText()
try {
sourceFile.transform(traversal => {
createTransformerPipeline().forEach(transformer =>
traversal.addVisitor(transformer)
)
})
if (dryRun) {
console.log(`\nDiff for ${filePath}:\n`)
console.log(createDiff(originalContent, sourceFile.getText()))
} else {
await sourceFile.save()
console.log(`Converted ${filePath}`)
}
} catch (error) {
console.error(`Failed ${filePath}: ${error.message}`)
sourceFile.replaceWithText(originalContent) // Revert changes
}
}
// Execute with: npm run convert -- src/components/Button.tsx
const files = process.argv.slice(2).filter(arg => !arg.startsWith('--'))
const dryRun = process.argv.includes('--dry-run')
files.forEach(file => convertFile(file, dryRun))
Transformers Pipeline
import { NodeVisitor, Project } from 'ts-morph'
import { LifecycleTransformer } from './lifecycle'
import { StateTransformer } from './state'
import { ContextTransformer } from './context'
export const TRANSFORMER_ORDER = [
new LifecycleTransformer(),
new StateTransformer(),
new ContextTransformer()
]
export function createTransformerPipeline(project?: Project) {
return TRANSFORMER_ORDER.map(t =>
t.withProject(project).withLogger(console)
)
}
State Management Transformer
export class StateTransformer extends NodeVisitor {
visitPropertyDeclaration(node) {
if (node.getModifiers().some(m => m.getText() === 'static')) return node
const initializer = node.getInitializer()?.getText() || 'undefined'
return this.factory.createVariableStatement(
undefined,
this.factory.createVariableDeclarationList(
[this.factory.createVariableDeclaration(
node.getName(),
undefined,
node.getType().getText(),
initializer
)],
this.ts.VariableDeclarationKind.Const
)
)
}
}
Lifecycle Methods Transformer
export class LifecycleTransformer extends NodeVisitor {
visitMethodDeclaration(node) {
const methodName = node.getName()
const hookMap = new Map([
['componentDidMount', { hook: 'useEffect', deps: '[]' }],
['componentDidUpdate', { hook: 'useEffect', deps: undefined }],
['shouldComponentUpdate', { hook: 'useMemo' }]
])
if (hookMap.has(methodName)) {
const { hook, deps } = hookMap.get(methodName)
return this.createHookCall(hook, node.getBody(), deps)
}
return node
}
private createHookCall(hookName, body, deps) {
return this.factory.createCallExpression(
this.factory.createIdentifier(hookName),
undefined,
[
this.factory.createArrowFunction(
undefined,
undefined,
[],
undefined,
this.ts.SyntaxKind.EqualsGreaterThanToken,
body
),
deps && this.factory.createIdentifier(deps)
].filter(Boolean)
)
}
}
Error Boundaries
export class ErrorBoundaryTransformer extends NodeVisitor {
visitClassDeclaration(node) {
try {
return super.visitClassDeclaration(node)
} catch (error) {
this.context.logError(error)
return this.factory.createFunctionDeclaration(
[this.ts.SyntaxKind.ExportKeyword],
undefined,
`UNSAFE_${node.getName()}`,
undefined,
[this.factory.createParameterDeclaration(
undefined,
undefined,
'props',
undefined,
this.factory.createTypeReferenceNode('any')
)],
undefined,
this.factory.createBlock([], true)
)
}
}
}
Migration Workflow
- Dry run:
npm run convert:dry -- src/components/LegacyComponent.tsx
- Convert single component:
npm run convert -- src/components/Button.tsx
- Watch mode:
npm run convert:watch
- Full conversion:
npm run convert -- src/
Example Conversion
Input:
class Counter extends React.Component {
state = { count: 0 }
componentDidMount() {
document.title = `Count: ${this.state.count}`
}
render() {
return (
<button onClick={()=> this.setState({ count: this.state.count + 1 })}>
{this.state.count}
</button>
)
}
}
Output:
const Counter = () => {
const [count, setCount] = useState(0)
useEffect(() => {
document.title = `Count: ${count}`
}, [count])
return (
<button onClick={()=> setCount(c=> c + 1)}>
{count}
</button>
)
}
operations:
- [ ] convert state initialization
- [ ] transform lifecycle methods
- [ ] update context consumers
- [ ] convert class methods
- [ ] add hook dependencies
- [ ] verify prop types
- [ ] update test files
- [ ] third-party HOCs