AST:React:Class Components

January 24, 2025

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

  1. Dry run:
npm run convert:dry -- src/components/LegacyComponent.tsx
  1. Convert single component:
npm run convert -- src/components/Button.tsx
  1. Watch mode:
npm run convert:watch
  1. 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