@metamorphik/react-named-effects
v0.0.6
Published
React effects with names, stable identities, debug-logging, and built-in prev/current value comparison.
Maintainers
Readme
@metamorphik/react-named-effects
Named, snapshot-based effects for React — give every side-effect a clear, stable identity.
A small layer on top of React’s effect hooks that adds names, prev/current snapshots, and optional scheduling modes — without changing how you write components.
This README is the presentation / overview.
For the full formal API spec, see:
👉APIs/docs/named-effects.md
✨ Features
- 🏷️ Named effects — every effect has a readable
namefor logs and tooling. - 🧩 Spec-based API — configure each effect with a single
NamedEffectSpecobject. - 🔄 Prev & current snapshots — your handler receives both the previous and current values you care about.
- 🎚️ Flexible scheduling — choose between
effect,layout,insertion,raf, oridlescheduling. - 🎛️
whenguard — keep snapshot history even when you skip running the handler. - 🧯 Per-effect error handling — optional
onErrorfor handler + cleanup. - 🔍 Debug logging in dev — see what changed and which effect ran.
- ⚡ Tiny and focused — no runtime deps beyond React.
The goal is to make your side-effects explicit, named, and easier to reason about, while staying as close as possible to React’s built-in hooks.
🚀 Installation
npm install @metamorphik/react-named-effects
# or
pnpm add @metamorphik/react-named-effects
# or
yarn add @metamorphik/react-named-effects🏁 Quickstart
Classic useEffect with manual prev-tracking:
import * as React from "react";
function Profile({ userId }: { userId: string }) {
const prevUserIdRef = React.useRef<string | undefined>(undefined);
React.useEffect(() => {
if (prevUserIdRef.current !== userId) {
console.log("Loading profile for:", userId);
}
prevUserIdRef.current = userId;
}, [userId]);
return <div>Profile: {userId}</div>;
}With useNamedEffect, you remove the ref and gain a name + snapshot:
import { useNamedEffect } from "@metamorphik/react-named-effects";
export function Profile({ userId }: { userId: string }) {
useNamedEffect({
name: "load-profile",
dependencySnapshot: { userId },
handler: (prev, current) => {
if (!prev || prev.userId !== current.userId) {
console.log("Loading profile for:", current.userId);
}
},
});
return <div>Profile: {userId}</div>;
}Key points:
- You pass a spec object with
name,dependencySnapshot, andhandler. dependencySnapshotis any object; its fields become theprev/currentvalues you use.- On the first run,
previsundefined,currentis your snapshot. - On later runs,
previs the previous snapshot andcurrentis the latest one.
🧭 High-level API Overview
The main hook accepts a NamedEffectSpec:
import type {
NamedEffectSpec,
NamedEffectDependencySnapshot,
} from "@metamorphik/react-named-effects";
function useNamedEffect<TSnapshot extends NamedEffectDependencySnapshot>(
spec: NamedEffectSpec<TSnapshot>
): void;There are also convenience wrappers:
useNamedLayoutEffect(spec); // kind: "layout"
useNamedInsertionEffect(spec); // kind: "insertion" (if available)
useNamedRafEffect(spec); // kind: "raf"
useNamedIdleEffect(spec); // kind: "idle"For full type definitions and semantics, see
👉 APIs/docs/named-effects.md
🔄 Snapshot-based dependencies
Instead of a plain dependency array, you pass a snapshot object:
useNamedEffect({
name: "sync-selection",
dependencySnapshot: {
selectedIds,
filterText,
},
handler: (prev, current) => {
if (!prev || prev.selectedIds !== current.selectedIds) {
console.log("Selection changed:", current.selectedIds);
}
if (!prev || prev.filterText !== current.filterText) {
console.log("Filter changed:", current.filterText);
}
},
});Snapshot semantics:
- On every run, the latest
dependencySnapshotis stored internally. - On the next run, that stored snapshot is passed as
prev. - The new snapshot is passed as
current. - If there was no previous run,
previsundefined.
You never need to manually juggle useRef just to remember previous values.
🎚️ Scheduling with kind
The options.kind flag controls how and when the effect runs.
useNamedEffect({
name: "measure-layout",
dependencySnapshot: { width, height },
handler: (prev, current) => {
// do layout-sensitive work here
},
options: { kind: "layout" },
});Supported kinds:
"effect"— default, usesReact.useEffect."layout"— usesReact.useLayoutEffect."insertion"— usesReact.useInsertionEffectwhen available."raf"— schedules viarequestAnimationFrame."idle"— schedules viarequestIdleCallback(orsetTimeoutas a fallback).
⚠️ Important: For a given hook call,
kindshould be stable across renders to respect React’s Rules of Hooks.
You can also use the dedicated wrappers instead of setting kind manually:
import {
useNamedLayoutEffect,
useNamedRafEffect,
} from "@metamorphik/react-named-effects";
useNamedLayoutEffect({
name: "layout-effect",
dependencySnapshot: { foo },
handler: (prev, current) => { /* ... */ },
});
useNamedRafEffect({
name: "raf-effect",
dependencySnapshot: { bar },
handler: (prev, current) => { /* ... */ },
});✅ Conditional execution with when
Use options.when to skip running the handler while still updating snapshots.
useNamedEffect({
name: "maybe-track",
dependencySnapshot: { userId, isEnabled },
options: { when: isEnabled },
handler: (prev, current) => {
// Only runs when isEnabled is true
console.log("Tracking user", current.userId);
},
});Behavior:
prevandcurrentstill advance every render.- If
whenisfalse, the handler (and cleanup) are skipped for that run.
This is useful for feature flags, opt-in telemetry, or expensive operations.
🧯 Per-effect error handling (onError)
You can provide an onError callback per effect:
useNamedEffect({
name: "load-dashboard",
dependencySnapshot: { dashboardId },
options: {
onError: (err) => {
console.error("[dashboard effect] failed", err);
},
},
handler: async (prev, current) => {
await fetchDashboard(current.dashboardId);
return () => {
console.log("Dashboard cleanup");
};
},
});Semantics:
- If
onErroris provided:- Errors from the handler or cleanup are caught and passed to
onError. - They are not re-thrown.
- Errors from the handler or cleanup are caught and passed to
- If
onErroris not provided:- Errors bubble as usual and may surface in React’s error boundaries / console.
🔍 Debug logging
Enable logging via options.debug: true in development builds:
useNamedEffect({
name: "refresh-orders",
dependencySnapshot: { customerId, statusFilter },
options: { debug: true },
handler: (prev, current) => {
// ...
},
});Example logs (in dev):
[useNamedEffect] run → refresh-orders (kind=effect, when=true) | initial run
[useNamedEffect] run → refresh-orders (kind=effect, when=true) | changes: [customerId] 1 → 2, [statusFilter] "open" → "all"
[useNamedEffect] cleanup → refresh-orders (kind=effect, when=true)This helps you see:
- Which effect ran.
- Whether it was an initial run or a change.
- Which fields changed in the snapshot.
🧩 Example: animation with raf
import * as React from "react";
import { useNamedRafEffect } from "@metamorphik/react-named-effects";
function Spinner({ isActive }: { isActive: boolean }) {
const [angle, setAngle] = React.useState(0);
useNamedRafEffect({
name: "spin",
dependencySnapshot: { isActive },
handler: (prev, current) => {
if (!current.isActive) return;
let frameId: number;
const loop = () => {
setAngle((a) => (a + 5) % 360);
frameId = requestAnimationFrame(loop);
};
loop();
return () => cancelAnimationFrame(frameId);
},
});
return <div style={{ transform: `rotate(${angle}deg)` }}>⏳</div>;
}🧠 Design Philosophy
React’s useEffect family is intentionally low-level and anonymous:
- multiple effects in a component can be hard to distinguish
- tracking previous values typically requires
useRef - describing “which effect is this?” to teammates or tools is awkward
@metamorphik/react-named-effects keeps the React model but adds:
- 📛 Identity — give each effect a name.
- 🧠 Memory — snapshot previous values for you.
- 🧪 Intention— encode scheduling, guards, and error handling in a single spec.
This makes your code easier to read, debug, and eventually analyze, while still feeling like “just React hooks”.
Higher-level concepts like behavioral inheritance live in sister libraries such as @metamorphik/react-behavior. This package focuses purely on named effects and snapshot-based handlers.
🧱 Example Use Cases
- Distinguishing multiple effects in large components.
- Implementing logging/instrumentation with clear effect names.
- Managing animations (
raf) and idle work in a structured way. - Replacing ad-hoc
useRefpatterns for previous-value comparisons. - Building internal tooling on top of effect names and debug logs.
📚 Full API Reference
This README is intentionally narrative.
For the authoritative, versioned API specification — including all TypeScript types and detailed semantics — see:
📝 License
MIT © 2025 Metamorphik Technologies
Part of the Metamorphik Dev Tools collection.
⚡ Performance (generated by CI)
These numbers come from a synthetic microbenchmark using React Test Renderer + happy-dom. They measure wrapper overhead for 10,000 empty effects (no real work in the body):
- 10k plain useEffect:
2.909ms - 10k useNamedEffect:
11.062ms - Overhead:
8.153msper 10k effects (~0.815µsper effect, ~280.26%in this synthetic test)
The raw numbers exaggerate the difference because useEffect does almost no work in this environment.
In real components, effect bodies perform useful work (subscriptions, DOM I/O, analytics), and the fixed ~0.8µs per-effect overhead of useNamedEffect is diluted into that cost.
