Function declarations and arrow functions are not interchangeable syntax preferences; they produce different execution contexts, different hoisting behavior, and critically different stack traces in production.
There is a specific kind of production incident that every senior JavaScript engineer has experienced at least once. The error logger fires. You open the stack trace. Every frame reads <Anonymous>. No component name, no function name, no meaningful entry point — just a column of identical placeholder labels that tell you the crash happened somewhere in your application, in some function, at some point during execution.
This does not happen because of a runtime bug or a framework failure. It happens because someone on the team, months or years ago, made a syntax decision that felt purely aesthetic at the time: export default () => {}.
Stop treating function declarations and arrow functions as interchangeable. In a modern JavaScript codebase, how you define a function dictates its execution context, its instantiation timing, and whether your stack traces are readable when production goes down. This is an architectural decision dressed up as a formatting preference.
By the end of this post, you will understand the precise mechanical differences between declarations and expressions, why the industry overcorrected toward arrow functions, what that overcorrection costs in production, and how to enforce the right boundaries across a team.
The mechanism: what actually differs between a declaration and an expression
Most engineers learn that arrow functions "fix this" and function declarations "get hoisted" and stop there. Both of those things are true — and both of them have architectural consequences that go well beyond syntax preference.
Hoisting means that function declarations are fully parsed and available before any code in their scope executes. The engine reads the entire module, registers all declarations, and only then begins executing line by line. This means a declaration defined at the bottom of a file can be called from the top of the file without any error.
// This works — declaration is hoisted
const result = processData(rawInput);
function processData(input) {
return input.map(transform);
}
Function expressions, including arrow functions assigned to const are subject to the Temporal Dead Zone. The variable binding exists from the start of the scope, but it is uninitialized until the assignment line executes. Calling it before that line throws a ReferenceError.
// This throws — expression is not yet initialised
const result = processData(rawInput); // ReferenceError
const processData = (input) => {
return input.map(transform);
};
Identifiers in stack traces are the more consequential difference in practice. The JavaScript engine assigns a name to a function declaration automatically — it uses the declaration's identifier. Arrow functions assigned to variables get inferred names in modern engines under certain conditions, but this inference breaks down reliably in two common patterns: anonymous default exports (export default () => {}) and arrow functions passed directly as arguments (array.map(item => item.id)).
When inference fails, the engine has no name to log. Your stack trace gets <Anonymous>.
Lexical this binding is where arrow functions genuinely win. Arrow functions do not have their own this, they close over the this of the enclosing scope. This is exactly right for callbacks, event handlers inside class methods, and closures where you need to preserve the caller's context. Traditional function declarations create their own this binding, which is the correct behavior for top-level exports, React components, and service functions that should be context-independent.
*The real-world cost: what the overcorrection actually looks like
*
Arrow functions were a genuine improvement for a specific problem. The industry's response was to apply them to every problem.
The proximate cause was tooling defaults. Prettier, ESLint's prefer-arrow-callback rule, and a generation of tutorials written during the height of arrow function enthusiasm set const Component = () => {} as the default pattern for React components. Teams adopted it wholesale, and it became the convention even for cases where it actively made things worse.
The anonymous stack trace problem is the most measurable cost. Here is what it looks like in practice:
// This component's stack trace shows "<Anonymous>" on crash
export default () => {
const [data, setData] = useState(null);
// ...
};
// This component's stack trace shows "UserDashboard" on crash
export default function UserDashboard() {
const [data, setData] = useState(null);
// ...
}
The runtime difference between these two is zero. The debugging difference, when this component throws in production at 2am, is the difference between a named entry point in your error tracker and an <Anonymous> frame that tells you nothing about where to start looking.
React DevTools compounds the problem. Component names in the DevTools tree are inferred from function names. An anonymous default export renders as an unnamed component in the tree, making it harder to identify the right component during development not just in production.
The Temporal Dead Zone readability problem is subtler but equally damaging at scale. Because const expressions are not hoisted, a file that uses arrow functions for everything must be written bottom-up: every helper defined before the thing that calls it. This inverts the natural reading order of a module.
// Expression-only file — forced bottom-up structure
const formatDate = (date) => date.toISOString();
const buildPayload = (user) => ({ ...user, created: formatDate(new Date()) });
const createUser = (data) => buildPayload(data); // must come last
export default createUser; // buried at the bottom
// Declaration-based file — readable top-down structure
export default function createUser(data) {
return buildPayload(data);
}
function buildPayload(user) {
return { ...user, created: formatDate(new Date()) };
}
function formatDate(date) {
return date.toISOString();
}
The second version puts the primary export where the next engineer expects to find it line one and buries implementation details below. This is not a style preference. It is a measurable reduction in the cognitive load required to understand a module's purpose.
The fix: a clear rule for when to use each
The goal is not to eliminate arrow functions. They solve a real problem. The goal is to use each form for the job it is mechanically suited for.
Use function declarations for top-level exports and named utilities
Any function that is the primary export of a module, any React component, any service function, and any named utility should be a declaration.
// React component — declaration gives it a name in DevTools and stack traces
export default function ProductCard({ product }) {
return <div>{product.name}</div>;
}
// Named export — hoisted, readable, identifiable in logs
export function formatCurrency(amount, currency) {
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount);
}
Reserve arrow functions for closures and inline callbacks
Arrow functions are the correct choice when you need to preserve the lexical this of an enclosing scope and for inline callbacks where naming the function would add ceremony without clarity.
class DataService {
constructor() {
this.cache = new Map();
}
fetchAndCache(key) {
// Arrow function preserves `this` from DataService instance
return fetch(`/api/${key}`).then(res => {
this.cache.set(key, res); // `this` works correctly here
return res;
});
}
}
// Short inline callbacks — arrow functions are appropriate
const activeUsers = users.filter(user => user.isActive);
const userNames = activeUsers.map(user => user.name);
Ban anonymous default exports at the linter level
This is the one rule worth enforcing automatically rather than relying on convention. Add this to your ESLint configuration:
{
"rules": {
"import/no-anonymous-default-export": "error"
}
}
This rule rejects export default () => {} and export default function() {}, both patterns that produce anonymous stack frames. Every default export must have an identifier. This single rule eliminates the entire class of "anonymous in production" bugs at the source.
Key takeaway
Syntax is not just syntax. It is an instruction set for both the JavaScript engine and the engineers who will inherit your code. The choice between a declaration and an expression encodes information about hoisting behavior, this binding, and debuggability that has real consequences in production, and those consequences compound across a large codebase where the pattern is applied consistently by a team.
The senior engineering instinct here is not to pick a side and enforce it universally. It is to understand the mechanical properties of each form precisely enough to apply the right one deliberately. Arrow functions for closures and callbacks. Declarations for top-level exports and primary logic. Named identifiers everywhere that matters for production debugging.
Your error tracker can only tell you what went wrong if your code tells the engine what things are named. That is a choice you make at the keyboard, not at runtime.
What to check in your codebase this week
Run these two searches across your source files:
Find anonymous default exports:
grep -rn "export default () =>" src/
grep -rn "export default function()" src/
Any result is a component or module that will show as <Anonymous> in your error tracker when it crashes. Rename it.
Find arrow functions used as top-level React components:
grep -rn "const [A-Z][a-zA-Z]* = (" src/components/
Components starting with an uppercase letter assigned as const expressions are candidates for conversion to function declarations, particularly if they are default exports.
Neither of these changes affects runtime behavior. Both of them improve your ability to debug production issues in the dark.

Top comments (0)