JavaScript Decorators (TC39 Stage 3, shipping in Babel 7.21+ and TypeScript 5.0+) are a meta-programming feature that lets you annotate and modify classes, methods, and accessors declaratively. For WordPress JavaScript development they are most useful for memoising expensive computations, logging Gutenberg store actions, and marking deprecated APIs.
Problem: WordPress admin JavaScript tools — data tables, export utilities, analytics dashboards — contain repetitive cross-cutting logic: logging function calls, caching expensive results, and marking deprecated APIs. This logic is duplicated across modules with no consistent mechanism.
Solution: Use JavaScript Decorators (stage 3) to implement these cross-cutting concerns: a @memoize decorator caches return values by arguments, a @log decorator records calls to a debug panel, and a @deprecated decorator emits a console warning. Apply them declaratively to class methods with no changes to the method body.
The examples below implement a @memoize decorator for block editor selector functions, a @log method decorator for debugging WP data store actions, and a @deprecated decorator that mirrors PHP's #[Deprecated] attribute.
// Decorators need Babel with @babel/plugin-proposal-decorators
// or TypeScript 5.0+ with experimentalDecorators: true (legacy)
// or the new TC39 decorators (--experimentalDecorators not needed in TS 5.2+)
// ── @memoize — cache method results by serialised arguments ──
function memoize(target, context) {
// context.kind === 'method'
const cache = new Map();
return function(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = target.call(this, ...args);
cache.set(key, result);
return result;
};
}
class PostSelector {
@memoize
getPostsByCategory(categoryId) {
// Expensive computation — only runs once per unique categoryId
return wp.data.select('core').getEntityRecords('postType', 'post', {
categories: [categoryId],
per_page: 100,
});
}
}
// ── @readonly — prevent method reassignment ──
function readonly(target, context) {
return function(...args) {
return target.call(this, ...args);
};
// In TC39 decorators, set descriptor.writable = false for fields
}
// ── @log — trace method calls with timing ──
function log(target, context) {
const name = context.name;
return function(...args) {
console.time(`${name}()`);
try {
return target.apply(this, args);
} finally {
console.timeEnd(`${name}()`);
}
};
}
Class and accessor decorators for WordPress data stores:
// ── @deprecated — warn on usage ──
function deprecated(message) {
return function(target, context) {
return function(...args) {
console.warn(`[Deprecated] ${String(context.name)}: ${message}`);
return target.apply(this, args);
};
};
}
// ── @bound — auto-bind method to instance (replaces constructor binding) ──
function bound(target, context) {
context.addInitializer(function() {
this[context.name] = target.bind(this);
});
}
// ── Usage in a Gutenberg block component ──
class BlockToolbar extends React.Component {
@bound
handleClick(event) {
// `this` is always the component instance — no .bind(this) needed
this.setState({ active: !this.state.active });
}
@deprecated('Use getPostsByStatus() instead')
getPublishedPosts() {
return this.getPostsByStatus('publish');
}
@memoize
computeAttributes(rawData) {
// Heavy transformation — cached after first call
return rawData.map(item => ({ ...item, processed: true }));
}
render() {
return ;
}
}
// ── Auto-accessor decorator (TC39 stage 3 only) ──
class Store {
@log
accessor currentPostId = null;
// Generates get/set backed by a private slot, with @log wrapping both
}
NOTE: Configure Babel for TC39 Stage 3 decorators in a WordPress block plugin by adding {"plugins": [["@babel/plugin-proposal-decorators", {"version": "2023-11"}]]} to babel.config.json. The new TC39 decorators are NOT compatible with TypeScript's legacy experimentalDecorators — if upgrading an existing codebase, migrate one file at a time using --experimentalDecorators alongside the new syntax only after separating the files.