React Integration
All React-specific APIs live at the pulscheck/react subpath so that React never lands in the main bundle:
import {
TwProvider,
useScopedEffect,
useScopedLayoutEffect,
usePulse,
usePulseMount,
usePulseMeasure,
} from 'pulscheck/react'TwProvider
Drop-in provider. Wraps devMode() with a proper mount/unmount lifecycle:
import { TwProvider } from 'pulscheck/react'
function App() {
return (
<TwProvider>
<YourApp />
</TwProvider>
)
}Props
| Prop | Type | Description |
|---|---|---|
children | ReactNode | Your app |
options? | DevModeOptions | Same options as devMode() — fetch, timers, events, websocket, reporter |
<TwProvider options={{ reporter: { intervalMs: 3000 }, events: { exclude: ['input'] } }}>
<YourApp />
</TwProvider>TwProvider is not automatically production-gated. If you render it unconditionally in production, it will call devMode() and patch the eight globals. To keep it out of production, render it only when process.env.NODE_ENV !== 'production' (or the Vite equivalent), or import TwProvider dynamically behind that check.
useScopedEffect
The most important React hook. Drop-in replacement for useEffect that auto-scopes the effect, so every fetch / setTimeout / setInterval / addEventListener started inside it is bound to the component's lifecycle.
import { useScopedEffect } from 'pulscheck/react'
function UserProfile({ id }: { id: string }) {
useScopedEffect(() => {
fetch(`/api/user/${id}`)
.then((r) => r.json())
.then(setUser)
const interval = setInterval(pollStatus, 5000)
return () => clearInterval(interval)
}, [id])
}What happens under the hood:
- On effect run, opens a
tw.scope()named after the component (inferred from the call stack). - Inside the effect body, every auto-instrumented async call captures the scope's
correlationIdas itsparentId. - When React tears the effect down, the scope's
end()emits ascope-endevent. - Any async callback that fires after the scope ended — e.g. the
fetch().then(setUser)resolving after unmount — is flagged as after-teardown by the analyzer, with both sides of the call site.
The scope pops from the active stack immediately after the setup function returns, so sibling components don't inherit it. Only operations started synchronously during setup are bound to this scope.
Signature
function useScopedEffect(
effect: EffectCallback,
deps?: DependencyList,
name?: string,
): voidPass an explicit name to override component-name inference (useful inside HOCs or when the inferred name is unknown):
useScopedEffect(() => { /* ... */ }, [id], 'UserProfile')useScopedLayoutEffect
Same as useScopedEffect but uses useLayoutEffect. Use this when your effect must run synchronously after DOM mutations.
useScopedLayoutEffect(() => {
const node = ref.current
const observer = new ResizeObserver(handler)
observer.observe(node)
return () => observer.disconnect()
}, [])Pulse hooks
Three small hooks for manual event emission. These work alongside (or without) TwProvider and useScopedEffect.
usePulse(label, options?)
Fire a pulse after every committed render. Safe in Concurrent Mode — it only fires for renders React actually commits, so you never get phantom events from abandoned renders.
function ProductCard({ id }: { id: string }) {
usePulse('product-card:render', { lane: 'ui', meta: { id } })
return <div>…</div>
}usePulseMount(label, options?)
Fire label:mount on mount and label:unmount on cleanup:
function Dashboard() {
usePulseMount('dashboard', { lane: 'ui' })
// ...
}This establishes a manual lifecycle boundary. Most apps don't need this — useScopedEffect gives you a scoped lifecycle automatically for every effect.
usePulseMeasure(label, options?)
Measure time between consecutive commits. The emitted event carries durationMs and renderCount in its metadata:
function LiveChart() {
usePulseMeasure('live-chart:render-gap', { lane: 'ui' })
return <svg>…</svg>
}Useful for spotting jank — if durationMs stays above ~16 ms, you're dropping frames.
Combining with scopes directly
You can also create scopes imperatively when the component-level granularity of useScopedEffect isn't what you want:
import { tw } from 'pulscheck'
function CheckoutFlow() {
useEffect(() => {
const scope = tw.scope('checkout-flow')
// All async operations started here are scoped to 'checkout-flow'
void preloadCheckoutAssets()
return () => scope.end()
}, [])
return <CheckoutSteps />
}Dev-only by convention
None of the React hooks or TwProvider automatically disable themselves in production — they will run whatever JavaScript they contain. What does happen is that the underlying registry.emit() short-circuits when process.env.NODE_ENV === 'production' and the event is not marked public, so manual usePulse / usePulseMount calls become near no-ops in production. However, TwProvider still calls devMode(), which still applies the global patches — those happen regardless of NODE_ENV.
To guarantee pulscheck code does not run in production, gate TwProvider and any direct devMode() call at the call site:
{import.meta.env.DEV && <TwProvider>{children}</TwProvider>}The bundler will then eliminate the entire subtree at build time.