@optiwari.india/react-event-state
v1.0.0
Published
An event-driven React state primitive using useSyncExternalStore with WeakMap deduplication
Maintainers
Readme
@optiwari.india/react-event-state
An event-driven React state primitive using React 18/19's
useSyncExternalStorewith automatic listener deduplication,WeakMapgarbage collection, and strict TypeScript inference.
Highlights
- ⚡ Zero Tearing & Concurrent Safe: Built natively with React 18/19's
useSyncExternalStore. - 🔄 Listener Deduplication: 100 components listening to the same event share one underlying DOM listener.
- 🧹 Automatic Garbage Collection: Uses a
WeakMap<EventTarget, Map<string, Store>>registry with reference-counted cleanup so unmounted components and detached DOM nodes never leak memory. - 🌐 SSR & Universal Safe: No
ReferenceError: document is not definedin Next.js, Remix, or Node SSR environments. - 🎯 Rich Target Support: Accepts
document,window, directHTMLElements, ReactRefObjects (useRef), and lazy getter functions. - 🧠 Full TypeScript Inference: Automatically infers
MouseEvent,KeyboardEvent,UIEvent, and custom events from standard DOM maps (DocumentEventMap,WindowEventMap,HTMLElementEventMap). - 🏭 Companion Factory: Includes
createEventState()for pre-declaring reusable event streams at module or domain scope.
Installation
npm install @optiwari.india/react-event-statePeer dependencies: react >= 18.0.0
Quick Start
1. Document Events (Default Target)
By default, useEventState targets document:
import React from "react";
import { useEventState } from "@optiwari.india/react-event-state";
export function App() {
// Strongly typed as MouseEvent | null
const click = useEventState("click");
return (
<div>
<h3>Click anywhere</h3>
{click ? (
<p>Last clicked at ({click.clientX}, {click.clientY})</p>
) : (
<p>No clicks yet</p>
)}
</div>
);
}2. Window Events
import React from "react";
import { useEventState } from "@optiwari.india/react-event-state";
export function WindowWatcher() {
// Strongly typed as UIEvent | null
const resize = useEventState("resize", window, { passive: true });
return (
<div>
<p>Latest resize timestamp: {resize?.timeStamp ?? "None"}</p>
</div>
);
}3. Element Target via React useRef
Passing a React ref is fully supported; listeners attach automatically once the DOM node commits:
import React, { useRef } from "react";
import { useEventState } from "@optiwari.india/react-event-state";
export function ButtonTracker() {
const buttonRef = useRef<HTMLButtonElement>(null);
// Automatically attaches to the DOM node when mounted
const click = useEventState("click", buttonRef);
return (
<div>
<button ref={buttonRef}>Click Me</button>
<p>Clicked: {click ? "Yes" : "Not yet"}</p>
</div>
);
}4. Custom Initial Value
// Return type is MouseEvent | "idle"
const click = useEventState("click", document, { initialValue: "idle" as const });5. Factory Pattern (createEventState)
Define reusable event streams at module scope or subscribe outside React:
import { createEventState } from "@optiwari.india/react-event-state";
// Safe to define at module level even during SSR
export const globalKeydown = createEventState("keydown");
// In React:
function ShortcutListener() {
const keyEvent = globalKeydown.use();
return <div>Key: {keyEvent?.key}</div>;
}
// Outside React (e.g. state machines or vanilla analytics):
const unsubscribe = globalKeydown.subscribe((event) => {
console.log("Key pressed:", event.key);
});Architecture & Design Decisions
1. Why useSyncExternalStore over useState + useEffect?
A naive implementation using useState and useEffect creates multiple severe problems:
- Duplicate Listeners: Every mounted component registers its own duplicate listener on the DOM target.
- Concurrent Tearing: React 18/19 concurrent rendering transitions can read differing event values across components in the same tree.
- Mounting Render Cascade:
useEffectruns asynchronously after paint, causing extra re-render cycles and potential hydration mismatch during SSR.
useSyncExternalStore guarantees:
- Synchronous snapshot consistency without tearing.
- Unified subscription management.
- Complete hydration safety via
getServerSnapshot().
2. Store Registry & Deduplication
target + eventName + options
│
┌────────────────┴────────────────┐
▼ ▼
Component A: useEventState Component B: useEventState
│ │
└────────────────┬────────────────┘
▼
EventStore
(Single DOM Event Listener)The internal registry uses:
WeakMap<EventTarget, Map<string, InternalEventStore>>- Target deduplication: If 50 components subscribe to
"keydown"ondocument, exactly onedocument.addEventListener("keydown", ...)is registered. - Option discrimination: Separate stores are cleanly isolated if different capture/passive options are used (e.g.
{ capture: true }vs{ capture: false }). - Zero Memory Leaks: The
WeakMapensures that if anEventTarget(such as a temporary DOM node) is removed from the DOM and holds no other references, it is eligible for garbage collection immediately. - Reference Counting with Microtask Deferral: When the last subscriber unmounts, DOM listeners are detached (
removeEventListener) and registry entries are purged. Cleanup is deferred across microtasks so that React re-renders or StrictMode double-invocations do not unnecessarily churn DOM listeners or drop snapshots.
Edge Cases & Limitations
[!IMPORTANT] Event State vs Event Action: An event in the DOM is an instantaneous action. State in React is a persistent snapshot.
No
preventDefault()/stopPropagation()in Render: By the time React schedules and executes a component render, the browser has already finished dispatching the DOM event. Callingevent.preventDefault()inside your component body oruseEffecthas no effect. Use traditional DOM event handler props (onClick={...}) when you need imperative flow control or default cancellation.Browser Post-Dispatch Nullification: The DOM specification mandates that browsers set
event.currentTargettonullonce an event finishes dispatching. Therefore,event.currentTargetwill benullwhen inspected in your component. Useevent.targetinstead.DOM Subtree Retention: Holding a reference to a raw DOM
Eventobject keeps a reference toevent.target. Ifevent.targetwas a DOM node that later unmounts, it will remain in memory until the next event fires or the subscribing component unmounts.
API Reference
useEventState(eventName, target?, options?)
React hook to subscribe to DOM events as state.
eventName(string): Name of the event to observe (e.g."click","resize","keydown").target(TargetResolvable, optional): Target source. Defaults todocument. Can be:EventTarget(window,document,HTMLElement)- React
RefObject(useRef) - Getter function (
() => EventTarget | null) nullorundefined
options(EventStateOptions, optional):capture(boolean): Use capture phase. Defaults tofalse.passive(boolean): Hint that handler will not callpreventDefault(). Defaults tofalse.initialValue(TInitial): Initial value before any event fires. Defaults tonull.
createEventState(eventName, target?, options?)
Factory creating an EventStateSource object.
.use(): React hook bound to this store..subscribe(listener): Non-React subscription function returning an unsubscribe callback..getSnapshot(): Returns latest event or initial value..getTarget(): Resolves the target instance.
License
MIT © 2026
