@usefy/use-signal
v0.2.4
Published
A React hook for event-driven communication between components
Maintainers
Readme
Overview
@usefy/use-signal enables event-driven communication between React components without prop drilling or complex state management setup. Components subscribe to a shared "signal" by name, and when any component emits that signal, all subscribers receive an update.
Part of the @usefy ecosystem — a collection of production-ready React hooks designed for modern applications.
Why use-signal?
- Zero Dependencies — Pure React implementation with no external dependencies
- TypeScript First — Full type safety with exported interfaces
- Stable References —
emitandinfomaintain stable references across re-renders - SSR Compatible — Works seamlessly with Next.js, Remix, and other SSR frameworks
- Lightweight — Minimal bundle footprint (~1KB minified + gzipped)
- Well Tested — Comprehensive test coverage with Vitest
- React 18+ Optimized — Uses
useSyncExternalStorefor concurrent mode compatibility
Use Cases
- Dashboard Refresh — Refresh multiple widgets with a single button click
- Form Reset — Reset multiple form sections simultaneously
- Cache Invalidation — Invalidate and reload data across components
- Multi-step Flows — Coordinate state across wizard steps
- Event Broadcasting — Notify multiple listeners about system events
⚠️ What This Hook Is NOT
useSignal is NOT a global state management solution.
This hook is designed for lightweight event-driven communication—sharing simple "signals" between components without the overhead of complex state management setup.
If you need:
- Complex shared state with derived values
- Persistent state across page navigation
- State that drives business logic
- Fine-grained state updates with selectors
→ Use dedicated state management tools like React Context, Zustand, Jotai, Recoil, or Redux.
About info.data: The data payload feature exists for cases where you need to pass contextual information along with a signal (e.g., which item was clicked, what action was performed). It's meant for event metadata, not as a global state container.
// ✅ Good: Signal with contextual data
emit({ itemId: "123", action: "refresh" });
// ❌ Bad: Using as global state
emit({ user: userData, cart: cartItems, settings: appSettings });Installation
# npm
npm install @usefy/use-signal
# yarn
yarn add @usefy/use-signal
# pnpm
pnpm add @usefy/use-signalPeer Dependencies
This package requires React 18 or 19:
{
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0"
}
}Quick Start
import { useSignal } from "@usefy/use-signal";
import { useEffect } from "react";
// Emitter Component
function RefreshButton() {
const { emit } = useSignal("dashboard-refresh");
return <button onClick={emit}>Refresh Dashboard</button>;
}
// Subscriber Component
function DataWidget() {
const { signal } = useSignal("dashboard-refresh");
useEffect(() => {
fetchData(); // Refetch when signal changes
}, [signal]);
return <div>Widget Content</div>;
}API Reference
useSignal(name, options?)
A hook that subscribes to a named signal and provides emit functionality.
Parameters
| Parameter | Type | Description |
| --------- | --------------- | ---------------------------------- |
| name | string | Unique identifier for the signal |
| options | SignalOptions | Optional configuration (see below) |
Options
interface SignalOptions {
emitOnMount?: boolean; // Emit when component mounts (default: false)
onEmit?: () => void; // Callback executed on emit
enabled?: boolean; // Enable/disable subscription (default: true)
debounce?: number; // Debounce emit calls in milliseconds
}Returns UseSignalReturn<T>
| Property | Type | Description |
| -------- | ----------------- | ------------------------------------------- |
| signal | number | Current version number (use in deps arrays) |
| emit | (data?: T) => void | Function to emit the signal with optional data |
| info | SignalInfo<T> | Metadata object (see below) |
SignalInfo
interface SignalInfo<T = unknown> {
name: string; // Signal name
subscriberCount: number; // Active subscribers
timestamp: number; // Last emit timestamp
emitCount: number; // Total emit count
data: T | undefined; // Data passed with last emit
}Note:
infois a stable reference (ref-based) that doesn't trigger re-renders. Usesignalin dependency arrays to react to changes, and access the latestinfo.datainsideuseEffect.
Examples
Dashboard Refresh Pattern
import { useSignal } from "@usefy/use-signal";
import { useEffect, useState } from "react";
function RefreshButton() {
const { emit, info } = useSignal("dashboard-refresh");
return (
<button onClick={emit}>
Refresh All ({info.subscriberCount} widgets)
</button>
);
}
function SalesChart() {
const { signal } = useSignal("dashboard-refresh");
const [data, setData] = useState([]);
useEffect(() => {
fetchSalesData().then(setData);
}, [signal]);
return <Chart data={data} />;
}
function UserStats() {
const { signal } = useSignal("dashboard-refresh");
const [stats, setStats] = useState(null);
useEffect(() => {
fetchUserStats().then(setStats);
}, [signal]);
return <Stats data={stats} />;
}Form Reset
import { useSignal } from "@usefy/use-signal";
function ResetButton() {
const { emit } = useSignal("form-reset");
return <button onClick={emit}>Reset All Fields</button>;
}
function PersonalInfoSection() {
const { signal } = useSignal("form-reset");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
useEffect(() => {
if (signal > 0) {
setName("");
setEmail("");
}
}, [signal]);
return (
<section>
<input value={name} onChange={(e) => setName(e.target.value)} />
<input value={email} onChange={(e) => setEmail(e.target.value)} />
</section>
);
}With Data Payload
import { useSignal } from "@usefy/use-signal";
import { useEffect } from "react";
interface NotificationData {
type: "success" | "error" | "info";
message: string;
}
function NotificationEmitter() {
const { emit } = useSignal<NotificationData>("notification");
return (
<button
onClick={() =>
emit({ type: "success", message: "Operation completed!" })
}
>
Send Notification
</button>
);
}
function NotificationReceiver() {
const { signal, info } = useSignal<NotificationData>("notification");
useEffect(() => {
if (signal > 0 && info.data) {
// info.data is guaranteed to contain the latest data
console.log(`[${info.data.type}] ${info.data.message}`);
}
}, [signal]);
return <div>Last: {info.data?.message ?? "No notifications"}</div>;
}With Debounce
import { useSignal } from "@usefy/use-signal";
function SearchInput() {
const { emit } = useSignal<string>("search-update", { debounce: 300 });
return (
<input
type="text"
onChange={(e) => {
emit(e.target.value); // Debounced - uses latest value after 300ms
}}
/>
);
}Conditional Subscription
import { useSignal } from "@usefy/use-signal";
function ConditionalWidget({ visible }: { visible: boolean }) {
// Only subscribe when visible
const { signal } = useSignal("updates", { enabled: visible });
useEffect(() => {
if (signal > 0) {
refreshData();
}
}, [signal]);
if (!visible) return null;
return <div>Widget Content</div>;
}With onEmit Callback
import { useSignal } from "@usefy/use-signal";
function LoggingEmitter() {
const { emit } = useSignal("analytics-event", {
onEmit: () => {
console.log("Event emitted at", new Date().toISOString());
trackAnalytics("signal_emitted");
},
});
return <button onClick={emit}>Track Event</button>;
}Emit on Mount
import { useSignal } from "@usefy/use-signal";
function AutoRefreshComponent() {
const { signal } = useSignal("data-refresh", { emitOnMount: true });
useEffect(() => {
// This runs immediately on mount due to emitOnMount
fetchLatestData();
}, [signal]);
return <div>Auto-refreshed content</div>;
}Using Info for Conditional Logic
import { useSignal } from "@usefy/use-signal";
function SmartEmitter() {
const { emit, info } = useSignal("notification");
const handleClick = () => {
// Only emit if there are subscribers
if (info.subscriberCount > 0) {
emit();
} else {
console.log("No subscribers, skipping emit");
}
};
return (
<button onClick={handleClick}>
Notify ({info.subscriberCount} listeners)
</button>
);
}TypeScript
This hook is written in TypeScript and exports all interfaces. Use generics to type the data payload.
import {
useSignal,
type UseSignalReturn,
type SignalOptions,
type SignalInfo,
} from "@usefy/use-signal";
// With typed data payload
interface MyEventData {
action: string;
payload: Record<string, unknown>;
}
const { signal, emit, info }: UseSignalReturn<MyEventData> = useSignal<MyEventData>(
"my-signal",
{
emitOnMount: true,
onEmit: () => console.log("emitted"),
enabled: true,
debounce: 100,
}
);
// signal: number
// emit: (data?: MyEventData) => void
// info: SignalInfo<MyEventData>
// info.data: MyEventData | undefinedPerformance
Stable References
The emit function and info object maintain stable references across re-renders:
const { emit, info } = useSignal<MyData>("my-signal");
// emit reference remains stable
useEffect(() => {
// Safe to use as dependencies
}, [emit]);
// info is a ref-based object - stable reference, live values via getters
console.log(info.subscriberCount); // Always current
console.log(info.data); // Latest data from last emitData Ordering Guarantee
When emit(data) is called, the data is stored before the signal version increments:
const { signal, info } = useSignal<string>("my-signal");
useEffect(() => {
// This is guaranteed: info.data contains the data passed to emit()
// that triggered this signal change
console.log(info.data); // Always the latest data
}, [signal]);Minimal Re-renders
- Only subscribed components re-render when signal is emitted
- The signal value is a primitive number, optimized for dependency arrays
- Uses React 18's
useSyncExternalStorefor optimal concurrent mode support
How It Works
- Global Store: A singleton Map stores signal data (version, subscribers, metadata, payload data)
- Subscription: Components subscribe via
useSyncExternalStore - Emit: Sets data → Increments version → Updates timestamp → Notifies all subscribers
- Cleanup: Automatic unsubscription on unmount
Key Design: Data is set before version increment to ensure
useEffectcallbacks always see the latestinfo.data.
┌─────────────┐ emit() ┌─────────────┐
│ Component │ ───────────────▶│ Signal │
│ Emitter │ │ Store │
└─────────────┘ └──────┬──────┘
│
notify all subscribers
│
┌────────────────────────┼────────────────────────┐
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Subscriber │ │ Subscriber │ │ Subscriber │
│ A │ │ B │ │ C │
└─────────────┘ └─────────────┘ └─────────────┘Testing
This package maintains comprehensive test coverage to ensure reliability and stability.
Test Categories
- Initial signal value is 0
- Returns all required properties (signal, emit, info)
- Info object contains correct properties
- Subscriber count tracking
- Signal increments on emit
- Multiple emits increment correctly
- Emit count and timestamp update
- Rapid successive emits
- Data stored in info.data on emit
- Data updates on each emit
- Data shared across subscribers
- Data ordering guarantee (available before signal changes)
- Complex object data support
- Debounced emit uses latest data
- All subscribers receive signal updates
- Subscriber count accuracy
- Cleanup on unmount
- emitOnMount behavior
- onEmit callback execution
- enabled option subscription control
- debounce timing
- Emit function stability
- Info object stability
- Values update with stable reference
License
MIT © mirunamu
This package is part of the usefy monorepo.
