โ† Back to DevBytes

State Management in SWC: Patterns and Libraries

State Management in SWC: Patterns and Libraries

SWC (Speedy Web Compiler) is a Rust-based platform for building fast JavaScript and TypeScript tooling. While most developers know SWC as a drop-in replacement for Babel or Terser, its real power lies in its plugin system, where you write custom AST transformations. In any non-trivial transformation, you need to remember things as you walk the tree: which variables have been renamed, which imports are unused, whether you're inside an async function, and so on. This is where state management comes in.

This tutorial covers what state management means in the context of SWC plugins, why it matters, the common patterns you'll encounter, and the libraries and helpers that make it easier. By the end, you'll be able to write robust SWC plugins that track context safely across an entire AST traversal.

What Is State Management in SWC?

In SWC, a plugin is essentially a function that returns a VisitMut or Fold implementation. As SWC walks the AST, it calls your visitor methods on each node. The AST itself is immutable in spirit โ€” you mutate it in place via &mut references โ€” but the information you derive while walking (scope depth, current function, renamed identifiers) is not part of the AST. That derived information is your state.

State management is the discipline of storing, updating, and querying that derived information correctly as the traversal proceeds. Get it wrong, and your plugin will produce incorrect output, panic on edge cases, or leak state between sibling nodes.

Why It Matters

Core Patterns

Pattern 1: Struct-Field State on the Visitor

The simplest pattern is to store state as fields on your visitor struct. SWC's VisitMut trait gives you &mut self, so you can read and write these fields freely.

use swc_ecma_visit::{VisitMut, VisitMutWith};
use swc_ecma_ast::*;

pub struct FunctionCounter {
    pub function_count: usize,
    pub current_depth: usize,
}

impl FunctionCounter {
    pub fn new() -> Self {
        Self { function_count: 0, current_depth: 0 }
    }
}

impl VisitMut for FunctionCounter {
    fn visit_mut_function(&mut self, node: &mut Function) {
        self.function_count += 1;
        self.current_depth += 1;
        node.visit_mut_children_with(self);
        self.current_depth -= 1;
    }
}

This works well for simple counters and flags. The key discipline is the push/pop pair: increment before recursing into children, decrement after. If you forget the decrement, your depth tracking drifts.

Pattern 2: The Stack Pattern

When you need to know the chain of ancestors โ€” for example, "am I inside a class method inside a module?" โ€” use a Vec as a stack. Push on entry, pop on exit.

use swc_ecma_visit::{VisitMut, VisitMutWith};
use swc_ecma_ast::*;

#[derive(Debug, Clone)]
pub enum Context {
    Module,
    Function,
    Class,
    Arrow,
    Block,
}

pub struct ScopeTracker {
    pub stack: Vec<Context>,
    pub max_function_depth: usize,
}

impl VisitMut for ScopeTracker {
    fn visit_mut_function(&mut self, node: &mut Function) {
        self.stack.push(Context::Function);
        let depth = self.stack.iter()
            .filter(|c| matches!(c, Context::Function | Context::Arrow))
            .count();
        self.max_function_depth = self.max_function_depth.max(depth);
        node.visit_mut_children_with(self);
        self.stack.pop();
    }

    fn visit_mut_arrow_expr(&mut self, node: &mut ArrowExpr) {
        self.stack.push(Context::Arrow);
        node.visit_mut_children_with(self);
        self.stack.pop();
    }

    fn visit_mut_class(&mut self, node: &mut Class) {
        self.stack.push(Context::Class);
        node.visit_mut_children_with(self);
        self.stack.pop();
    }
}

Querying the stack with stack.iter().any(...) lets you answer questions like "am I inside a class?" without threading booleans through every method.

Pattern 3: Collected Output State

Sometimes the goal of the plugin is to collect information rather than transform. In that case, your state is the output collection, and the visitor methods just append to it.

use swc_ecma_visit::VisitMut;
use swc_ecma_ast::*;
use std::collections::HashSet;

pub struct ImportCollector {
    pub imports: HashSet<String>,
}

impl VisitMut for ImportCollector {
    fn visit_mut_import_decl(&mut self, node: &mut ImportDecl) {
        let src = node.src.value.clone();
        self.imports.insert(src);
        // No need to recurse; imports don't contain other imports.
    }
}

This pattern pairs well with a two-pass design: first collect, then transform. SWC plugins can be invoked multiple times in a pipeline, so you can run a collector plugin followed by a transformer plugin.

Pattern 4: Scope-Aware State with swc_ecma_utils

For real-world transformations like renaming or dead-code elimination, you need proper scope analysis. SWC ships swc_ecma_utils and the hygiene and resolver crates, which handle the hard parts of JavaScript scoping.

use swc_common::{SyntaxContext, Mark};
use swc_ecma_transforms_base::resolver::resolver;
use swc_ecma_visit::{VisitMut, VisitMutWith};
use swc_ecma_ast::*;

pub struct Renamer {
    pub from: String,
    pub to: String,
}

impl VisitMut for Renamer {
    fn visit_mut_ident(&mut self, node: &mut Ident) {
        // Only rename identifiers whose source text matches AND
        // whose SyntaxContext matches the top-level scope.
        if node.sym == self.from.as_str()
            && node.ctxt == SyntaxContext::empty()
        {
            node.sym = self.to.clone().into();
        }
        node.visit_mut_children_with(self);
    }
}

// Usage in plugin entry:
// 1. Run resolver to assign SyntaxContext to every Ident.
// 2. Run Renamer with a known target context.

The resolver pass annotates every Ident with a SyntaxContext that uniquely identifies its scope. This is what lets you rename x in one scope without touching x in another. Without the resolver, string-comparing identifier names is a bug factory.

Pattern 5: Shared Mutable State with RefCell / Cell

Occasionally you need state that is shared between the visitor and a closure or helper that the borrow checker won't let you pass &mut into. Wrap it in RefCell.

use std::cell::RefCell;
use swc_ecma_visit::{VisitMut, VisitMutWith};
use swc_ecma_ast::*;

pub struct DiagnosticCollector {
    pub messages: RefCell<Vec<String>>,
}

impl DiagnosticCollector {
    pub fn report(&self, msg: String) {
        self.messages.borrow_mut().push(msg);
    }
}

impl VisitMut for DiagnosticCollector {
    fn visit_mut_call_expr(&mut self, node: &mut CallExpr) {
        if let Callee::Expr(expr) = &node.callee {
            if let Expr::Ident(ident) = &**expr {
                if ident.sym == "eval" {
                    self.report(format!("eval() is not allowed"));
                }
            }
        }
        node.visit_mut_children_with(self);
    }
}

Use RefCell sparingly. It's a sign that your design might benefit from refactoring into a collector pattern instead.

Libraries and Crates

swc_ecma_visit

The foundation. Provides Visit (read-only), VisitMut (in-place mutation), and Fold (transform-and-replace). Most plugins use VisitMut because it avoids cloning and integrates cleanly with struct-field state.

swc_ecma_transforms_base

Contains the resolver, hygiene, and fixer passes. The resolver is almost always the first thing you run, because it produces the SyntaxContext data your state logic depends on.

swc_ecma_utils

Helpers like ident(&str), member_expr, undefined(), and scope-querying utilities. Useful when your state logic needs to construct or compare AST nodes.

swc_ecma_transforms_optimization

Reference implementations for dead-code elimination, inline globals, and simplification. Reading its source is the best way to learn how the SWC team structures state for complex passes.

swc_common

Provides SyntaxContext, Mark, Span, and the GLOBALS thread-local context. Mark is the primitive used to tag nodes as "introduced by this transformation," which is essential for hygiene-aware state.

Putting It Together: A Dead-Import Remover

Let's combine the patterns into a real plugin: one that removes imports which are never referenced. We use a two-pass approach โ€” collect references, then prune.

use std::collections::HashSet;
use swc_common::SyntaxContext;
use swc_ecma_ast::*;
use swc_ecma_visit::{VisitMut, VisitMutWith};

pub struct DeadImportRemover {
    // Bindings introduced by import declarations, keyed by SyntaxContext
    // so we don't confuse shadowed names.
    pub imported: HashSet<SyntaxContext>,
    pub referenced: HashSet<SyntaxContext>,
}

impl DeadImportRemover {
    pub fn new() -> Self {
        Self {
            imported: HashSet::new(),
            referenced: HashSet::new(),
        }
    }

    fn collect_imports(&mut self, module: &Module) {
        for item in &module.body {
            if let ModuleItem::ModuleDecl(ModuleDecl::Import(imp)) = item {
                for spec in &imp.specifiers {
                    let ctxt = match spec {
                        ImportSpecifier::Named(n) => n.local.ctxt,
                        ImportSpecifier::Default(d) => d.local.ctxt,
                        ImportSpecifier::Namespace(ns) => ns.local.ctxt,
                    };
                    self.imported.insert(ctxt);
                }
            }
        }
    }
}

impl VisitMut for DeadImportRemover {
    fn visit_mut_ident(&mut self, node: &mut Ident) {
        // Record any identifier whose context matches an import binding.
        if self.imported.contains(&node.ctxt) {
            self.referenced.insert(node.ctxt);
        }
        node.visit_mut_children_with(self);
    }

    fn visit_mut_module(&mut self, node: &mut Module) {
        // Pass 1: collect imports.
        self.collect_imports(node);
        // Pass 2: walk to find references.
        node.visit_mut_children_with(self);
        // Pass 3: drop unused import specifiers.
        node.body.retain(|item| {
            if let ModuleItem::ModuleDecl(ModuleDecl::Import(imp)) = item {
                imp.specifiers.retain(|spec| {
                    let ctxt = match spec {
                        ImportSpecifier::Named(n) => n.local.ctxt,
                        ImportSpecifier::Default(d) => d.local.ctxt,
                        ImportSpecifier::Namespace(ns) => ns.local.ctxt,
                    };
                    self.referenced.contains(&ctxt)
                });
                // Drop the import entirely if no specifiers remain and
                // it has no side-effect-only bare import semantics we want
                // to preserve.
                !imp.specifiers.is_empty() || matches!(imp.src.value.as_ref(), v if v.is_empty())
            } else {
                true
            }
        });
    }
}

Notice how SyntaxContext does the heavy lifting: we never compare identifier names, only contexts. This means a local useState in a function won't be confused with the imported useState from React.

Best Practices

Conclusion

State management is the difference between a toy SWC plugin and one you'd ship in production. The core idea is simple โ€” keep derived information on your visitor struct, balance every push with a pop, and lean on SyntaxContext and the resolver for anything scope-related โ€” but the discipline pays off in correctness and performance. Start with the struct-field pattern, graduate to the stack pattern when you need ancestor context, reach for RefCell only when you must, and always let swc_ecma_transforms_base do the scope analysis for you. With these patterns and the surrounding crates, you can build transformations that are as fast and reliable as SWC itself.

๐Ÿ›  Tools from DevBytes

Inventory Tracker Pro โ€” Excel inventory system, low-stock alerts ยท $19
AI Dev Kit for Mac โ€” local AI dev environment templates ยท $9.99
KeyMapper for Mac โ€” custom keyboard shortcut toolkit ยท $7.99

โ† Back to all articles