Emittify is a tiny event emitter written with first class Typescript support. It supports caching, event deduplication, and has React hooks.
π Try the live demo β - Interactive examples showcasing all features
yarn add @iremlopsum/emittify- Zero dependencies. React is an optional peer dependency: the core (
@iremlopsum/emittify) works in any framework or plain JS/TS. You only needreact(>= 18) if you import the hooks from@iremlopsum/emittify/react. - ESM-only, for Node
^20.19.0 || >=22.12.0and any bundler.require('@iremlopsum/emittify')also works on those Node versions (via Node'srequire(esm)), and returns the module namespace, so use.default.
Upgrading from 1.x? See the 2.0.0 migration notes.
// events-core.ts
// Import the emittify module.
import Emittify from '@iremlopsum/emittify'
// Importing toast notification component props type to use in the emittify module.
import type { ToastNotificationPropsType } from '@components/ToastNotification'
// Type for the emitter key is the name of the event and value is the type of the event.
interface EventsType {
'direct-message-count': number
'toast-notification': ToastNotificationPropsType
}
const emitter = new Emittify<EventsType>({
// Cache is used to cache events and provide initial values to new listeners
cachedEvents: ['direct-message-count'],
// Deduplication prevents emitting events when values haven't changed
deduplicatedEvents: [
{ event: 'direct-message-count', comparison: 'shallow' }, // For primitives/simple objects
{ event: 'toast-notification', comparison: 'deep' }, // For nested objects
],
})
export default emitter// File where you want to use it
import emitter from './events-core'
// Register a listener for the 'toast-notification' event.
emitter.listen('toast-notification', data => {
const { message, type } = data // All is typed and auto-completed
console.log({ message, type })
})
// Emit the 'toast-notification' event.
// All is typed and auto-completed.
emitter.send('toast-notification', {
message: 'Hello World',
type: 'success',
})
// Emit the 'direct-message-count' event.
emitter.send('direct-message-count', 10)
// Get the cached event.
const cachedEvent = emitter.getCache('direct-message-count', 0) // Can provide second argument as default value if none is sent yet.Event deduplication prevents redundant emissions when the same value is sent multiple times. This is useful for reducing unnecessary re-renders, network calls, or other side effects.
- Performance: Avoid triggering listeners when data hasn't actually changed
- Spam Prevention: Prevent rapid identical events from flooding your system
- React Optimization: Reduce unnecessary component re-renders
- Network Efficiency: Skip redundant API calls or state updates
Deep comparison recursively checks all nested properties. Use for complex objects and arrays.
interface EventsType {
'user-profile': {
id: number
name: string
settings: { theme: string; notifications: boolean }
}
}
const emitter = new Emittify<EventsType>({
deduplicatedEvents: [{ event: 'user-profile', comparison: 'deep' }],
})
// First send always emits
emitter.send('user-profile', {
id: 1,
name: 'John',
settings: { theme: 'dark', notifications: true },
}) // β
Emitted
// Same nested values - blocked
emitter.send('user-profile', {
id: 1,
name: 'John',
settings: { theme: 'dark', notifications: true },
}) // β Blocked
// Changed nested value - emitted
emitter.send('user-profile', {
id: 1,
name: 'John',
settings: { theme: 'light', notifications: true },
}) // β
Emitted (theme changed)Shallow comparison only checks first-level properties. Faster, but doesn't detect nested changes.
interface EventsType {
counter: number
status: { active: boolean; count: number }
}
const emitter = new Emittify<EventsType>({
deduplicatedEvents: [
{ event: 'counter', comparison: 'shallow' },
{ event: 'status', comparison: 'shallow' },
],
})
// Primitives work the same as deep comparison
emitter.send('counter', 5) // β
Emitted
emitter.send('counter', 5) // β Blocked
emitter.send('counter', 10) // β
Emitted
// Shallow only checks top-level properties
emitter.send('status', { active: true, count: 5 }) // β
Emitted
emitter.send('status', { active: true, count: 5 }) // β Blocked (same values)
emitter.send('status', { active: false, count: 5 }) // β
Emitted (active changed)| Use Case | Strategy | Why |
|---|---|---|
| Primitives (string, number, boolean) | shallow |
Faster, works the same as deep |
| Flat objects | shallow |
10x faster than deep |
| Nested objects | deep |
Detects changes in nested properties |
| Arrays | deep |
Compares array contents |
| Large objects (>1000 keys) | shallow |
Better performance |
The cache and deduplication keep a reference to the value you send, not a copy. If you mutate an object after sending it and send it again, deduplication compares the object with itself and drops the change. Send a new object instead:
emitter.send('form-state', { ...state, name: 'New name' }) // β
new object
state.name = 'New name'
emitter.send('form-state', state) // β same object, dropped as a duplicateDeduplication works seamlessly with caching:
const emitter = new Emittify<EventsType>({
cachedEvents: ['counter'],
deduplicatedEvents: [{ event: 'counter', comparison: 'shallow' }],
})
emitter.send('counter', 5) // β
Emitted and cached
emitter.send('counter', 5) // β Blocked, but cache still updated
// New listeners get cached value
emitter.listen('counter', callback) // Receives 5 immediatelySometimes you want to reset deduplication to force re-emission:
// Clear for specific event
emitter.clearDeduplicationCache('counter')
emitter.send('counter', 5) // β
Will emit even if 5 was the previous value
// Clear for all events
emitter.clearAllDeduplicationCache()interface EventsType {
'api-data': { users: User[]; timestamp: number }
}
const emitter = new Emittify<EventsType>({
cachedEvents: ['api-data'],
deduplicatedEvents: [{ event: 'api-data', comparison: 'deep' }],
})
// Poll API every 5 seconds
setInterval(async () => {
const data = await fetchFromAPI()
// Only emits if data actually changed
emitter.send('api-data', data)
}, 5000)interface EventsType {
'form-state': { name: string; email: string; isValid: boolean }
}
const emitter = new Emittify<EventsType>({
deduplicatedEvents: [{ event: 'form-state', comparison: 'deep' }],
})
// Multiple rapid updates
input.addEventListener('input', () => {
const state = getFormState()
// Only emits when state actually changes
emitter.send('form-state', state)
})@iremlopsum/emittify/mock exports a class with the same methods as the React emitter, each a spy. By default
listen() returns { event, clearListener }, and getCache() / useEventListener() return the fallback value.
Jest β in a setup file:
jest.mock('@iremlopsum/emittify', () => require('@iremlopsum/emittify/mock'))
jest.mock('@iremlopsum/emittify/react', () => require('@iremlopsum/emittify/mock'))Vitest β spies come from vi.fn:
import { vi } from 'vitest'
vi.mock('@iremlopsum/emittify/react', async () => {
const { default: EmittifyMock } = await import('@iremlopsum/emittify/mock')
return {
default: class extends EmittifyMock {
constructor() {
super(vi.fn)
}
},
}
})With Vitest globals: true, new EmittifyMock() picks up the global vi.fn on its own.
import Emittify from '@iremlopsum/emittify/react'// import previously created emitter
import emitter from '../core/events-core.ts'
const Component = () => {
// Returns the cached value if one exists, otherwise the fallback (second argument), and re-renders on every send
const count = emitter.useEventListener('direct-message-count', 0)
return <button onClick={() => emitter.send('direct-message-count', 100)}>{count}</button>
}// Send an event with specified name and value.
emittify.send('event-name', value)// Listen to events with specified name and triggers a callback on each event.
const listener = emittify.listen('event-name', callback)
// Listener is an object.
listener.event // Name of the event
listener.clearListener() // Clears the listenerThe same callback can be registered more than once; each registration is cleared on its own. A listener added while an event is being sent only receives later sends, and a listener cleared during a send is not called.
If a listener throws, the other listeners still run and send() does not throw. The error is re-thrown asynchronously
(in a microtask), so it still reaches window.onerror, Node's uncaughtException and error trackers such as Sentry.
If the event is in cachedEvents and has a cached value, the callback is called immediately with that value.
Falsy values such as 0, false, '' and null are replayed like any other value. A cached undefined is treated as
"no value" (for example after send('event-name', undefined) to clear it) and is not replayed. Active listeners still
receive every send(), including undefined.
// Subscribes to the event and returns its latest value, or `initialValue` when nothing (or `undefined`) has been
// received. Starts from the cached value if one exists (same rules as `getCache()`).
emittify.useEventListener('event-name', initialValue)Built on useSyncExternalStore, so it is safe under concurrent rendering. Changing the event name behaves like a
fresh mount: it shows the new event's cached value or initialValue, never the previous event's value. Function
payloads are returned as values. Without initialValue the return type includes undefined.
// Gets the cached value for event name, or `initialValue` if nothing is cached.
emittify.getCache('event-name', initialValue)Cached 0, false, '' and null are returned as-is. A cached undefined counts as "no value", so
getCache() returns initialValue after send('event-name', undefined). Without initialValue the return type
includes undefined.
// Clears cache for given event name.
emittify.clearCache('event-name')// Clears all of the cache.
emittify.clearAllCache()// Removes all listeners, cached values and deduplication state. The options passed to the constructor are kept.
emittify.clearAll()// Clears the previous value for a specific deduplicated event.
// The next send will always emit since there's no previous value to compare.
emittify.clearDeduplicationCache('event-name')// Clears all previous values for deduplicated events.
// Next sends will always emit since there are no previous values to compare.
emittify.clearAllDeduplicationCache()To set up the development environment, run:
yarn setupThis command will:
- Install all dependencies
- Set up git hooks for code quality
The project includes a pre-commit hook that automatically runs before each commit to ensure code quality. The hook performs:
- TypeScript Type Checking - Validates all TypeScript types
- Code Formatting - Checks Prettier formatting
- Test Suite - Runs the full test suite
If any check fails, the commit will be blocked until the issues are fixed.
If you need to reinstall the git hooks manually:
bash scripts/install-hooks.shWhile not recommended, you can bypass the pre-commit hook in emergency situations:
git commit --no-verify -m "your message"# Run tests
yarn test
# Run tests in watch mode
yarn test:watch
# Run tests with coverage report
yarn test:coverage
# Build the project
yarn build
# Load the built package in plain Node (run after build)
yarn test:dist
# Check the bundle size budget (run after build)
yarn size
# Lint the published package shape (publint, arethetypeswrong)
yarn lint:package
# Clean build artifacts
yarn clean:buildThis library has adopted a Code of Conduct that we expect project participants to adhere to. Please read the full text so that you can understand what actions will and will not be tolerated.
Emittify is licensed under the MIT License.