Introduction
Real programs need side effects—I/O, DOM updates, network calls. The key is isolating them from pure logic.
Key Concepts
Side Effect: Any observable change outside the function's return value—mutations, I/O, logging, network calls.
Effect Boundary: The layer of your application where side effects are allowed (e.g., controllers, event handlers).
Dependency Injection: Passing effectful functions as parameters so the core logic remains pure.
Real World Context
Every production app has side effects—database writes, API calls, DOM updates. The goal isn't to eliminate them but to isolate them. Frameworks like React enforce this: side effects go in useEffect, not in the render function. NestJS services separate pure business logic from effectful controllers.
Deep Dive
Identify Side Effects
javascript// Side effects: // - Mutating arguments // - Mutating external state // - Console logging // - DOM manipulation // - Network requests // - Reading/writing files // - Getting current date/time // - Random number generation
Push Effects to the Edges
javascript// Bad: Mixed logic and side effects function processUser(id) { const user = fetch(`/users/${id}`); // Side effect const name = user.name.toUpperCase(); // Logic document.body.innerHTML = name; // Side effect } // Good: Separate concerns const formatName = name => name.toUpperCase(); // Pure async function processUser(id) { const user = await fetchUser(id); // Effect at edge const name = formatName(user.name); // Pure logic render(name); // Effect at edge }
Dependency Injection
javascript// Hard to test - hidden dependency function getUser(id) { return fetch(`/users/${id}`); // Hardcoded side effect } // Testable - inject dependency function getUser(id, fetcher = fetch) { return fetcher(`/users/${id}`); } // Test with mock getUser(1, mockFetch);
Deterministic Cleanup with using (ES2025)
ES2025's using keyword enables automatic resource disposal when a block exits, making side-effect cleanup deterministic.
javascript{ using connection = acquireDbConnection(); // connection is automatically disposed when block exits } // connection[Symbol.dispose]() called automatically
This replaces try/finally patterns and guarantees cleanup even if an exception is thrown.
Common Pitfalls
- Hidden side effects in utility functions — A
formatDate()that callsDate.now()internally is impure. Pass the date as a parameter instead. - Mutating function arguments —
Array.sort()mutates in place. Use[...arr].sort()orArray.prototype.toSorted()(ES2023) to avoid surprises. - Global state in closures — A function that reads or writes a module-level variable is impure, even if it looks self-contained.
Best Practices
- Use the "functional core, imperative shell" pattern — Pure business logic in the center, side effects at the edges.
- Make effects explicit in function signatures — A function that fetches data should have
fetch(or a client) as a parameter, not hidden inside. - Test pure logic separately from effects — Unit test the pure core, integration test the effectful shell.
Summary
Isolate side effects at system edges. Keep core logic pure. Use dependency injection for testability.
Code Examples
// Impure: mixed logic and side effects
function processUser(id) {
const user = fetchUser(id); // Side effect
const name = user.name.toUpperCase(); // Pure logic
document.body.innerHTML = name; // Side effect
}
// Better: separate pure logic from effects
const formatName = name => name.toUpperCase(); // Pure
async function processUser(id) {
const user = await fetchUser(id); // Effect at edge
const name = formatName(user.name); // Pure core
render(name); // Effect at edge
}