Dependency-free React hooks and a pub/sub bus for publish/subscribe messaging
npm i use-pubsub-jsyarn add use-pubsub-jspnpm add use-pubsub-jsYou can import the hooks or a service to use where you want
import { PubSub, usePublish, useSubscribe } from 'use-pubsub-js'There are two buses, and picking the right one up front avoids the most common gotcha:
PubSub— the shared singleton. Untyped payloads, hierarchical string topics (publishinga.b.calso notifiesa.banda). Use it for quick, app-wide messaging. The hooks use it by default.createPubSub<Events>()— an independent, flat, fully-typed bus. Use it when you want TypeScript-checked payloads per topic. It is a separate instance, so wire it into the hooks via theirbusparam everywhere you use it (otherwise the hooks stay on the singleton and won't see its messages).
import { PubSub, useSubscribe } from 'use-pubsub-js'
setTimeout(() => PubSub.publish('token', 'message'), 5000)
const ExampleUseSubscribe = () => {
const handler = (token, message) => {
console.log(`Message ${message} - Token ${token}`)
}
const { unsubscribe, resubscribe } = useSubscribe({ token: 'token', handler })
return (
<div>
<button type="button" onClick={unsubscribe}>
Unsubscribe
</button>
<button type="button" onClick={resubscribe}>
Resubscribe
</button>
</div>
)
}The useSubscribe is a hook to listen publications that are made using the same
token in publish and subscription. The hook returns two functions, one to
unsubscribe the token off your handler and one to resubscribe your function to
token.
You can only invoke the hook and dynamically unsubscribe and subscribe pass the
isUnsubscribe prop to hook.
import { PubSub, usePublish } from 'use-pubsub-js'
const handler = (token, message) => {
console.log(`Message ${message} - Token ${token}`)
}
PubSub.subscribe('token_two', handler)
const ExampleUsePublish = () => {
const { publish } = usePublish({ token: 'token_two', message: 'message' })
return (
<div>
<button type="button" onClick={publish}>
Publish
</button>
</div>
)
}The usePublish hook have more than one way to use, the above is a simple wrapper
to declare your publish function using a React approach with hooks.
import { PubSub, usePublish } from 'use-pubsub-js'
const handler = (token, message) => {
console.log(`Message ${message} - Token ${token}`)
}
PubSub.subscribe('token_three', handler)
const ExampleUsePublish = () => {
const { lastPublish } = usePublish({
token: 'token_three',
message: 'message',
isAutomatic: true,
})
return (
<div>
<p>{lastPublish ? 'Publishing success' : 'Publication failure'}</p>
</div>
)
}The other way to use usePublish is with automatic publishing, always message
change is called a new publish with a new message, by default have a debounce with
300ms, you can increase, decrease ou run immediately pass a specific prop.
The returned lastPublish value is true if have some subscribe to receive a
message and false if they don't referring to the last publication.
Since v2 the PubSub bus is built in (no pubsub-js dependency). It supports
subscribe(token, handler) (returns a token), unsubscribe(token | handler),
publish(token, data) (async; returns true if at least one subscriber
received it), subscribeOnce, on(token, handler, { signal }) (returns an
unsubscribe function and supports AbortSignal), and clearAllSubscriptions.
Tokens are string | symbol (symbols match by identity). String topics are
hierarchical: publishing a.b.c also notifies a.b and a subscribers.
For typed channels, create your own bus and import it where needed (also
available from the use-pubsub-js/pubsub subpath):
import { createPubSub } from 'use-pubsub-js'
type AppEvents = {
'user:login': { userId: string }
'cart:update': { itemCount: number }
}
export const bus = createPubSub<AppEvents>()
bus.publish('user:login', { userId: '42' }) // payload is type-checkedBoth hooks accept an optional bus param so they can drive a typed bus with
full end-to-end inference (the token and message/handler payload are typed
from the event map):
import { usePublish, useSubscribe } from 'use-pubsub-js'
import { bus } from './bus'
// message is type-checked as { userId: string }
usePublish({ bus, token: 'user:login', message: { userId: '42' } })
// handler's second arg is inferred as { userId: string }
useSubscribe({ bus, token: 'user:login', handler: (_, user) => console.log(user.userId) })Note:
createPubSub()returns a separate bus instance. Omitbus(or leave it as the default) to use the sharedPubSubsingleton. Keep thebusreference stable (e.g. module scope) — changing it re-subscribes/re-publishes.
createPubSub is flat (exact-key); the PubSub singleton is hierarchical but
untyped. createHierarchicalPubSub<Events>() gives you both — typed payloads on
a dotted-topic tree, where publishing order.created also notifies order:
import { createHierarchicalPubSub } from 'use-pubsub-js'
const bus = createHierarchicalPubSub<{
order: { id: string }
'order.created': { id: string; total: number }
}>()
bus.subscribe('order', (token, data) => {
// token: 'order' | 'order.created' (the precise descendant union)
// data is the union of those payloads — narrow it with a property check:
if ('total' in data) {
console.log(data.total)
}
})
bus.publish('order.created', { id: '1', total: 9 }) // also notifies 'order'Two things to know: a handler's data is narrowed by a property check, not
by token (TypeScript can't correlate the two parameters); and you can only
publish/subscribe declared keys.
useBusState returns a topic's most recent value as React state (backed by
useSyncExternalStore, so it's tear-free under concurrent rendering and
SSR-safe). Create the bus with { retained: true } so the value is available on
first render, before any publish happens this session:
import { createPubSub, useBusState } from 'use-pubsub-js'
export const bus = createPubSub<{ 'cart:count': number }>({ retained: true })
const CartBadge = () => {
const count = useBusState({ bus, token: 'cart:count', initialValue: 0 })
return <span>{count}</span> // updates whenever 'cart:count' is published
}initialValue is returned until a value is available (and as the server
snapshot during SSR). On a non-retained bus, useBusState still updates from
publishes that happen while mounted, but cannot show a value published before
mount.
SSR: the server snapshot is always
initialValue. If a retained bus already holds a different value when the client hydrates, React logs a hydration mismatch — keepinitialValuealigned with the server render, or populate the retained bus only after hydration.Memory:
retained: truekeeps one entry per distinct token forever, so avoid it with dynamic/unbounded token names.
The default entry works on React 18+. If you're on React 19.2+, an optional
useSubscribe built on useEffectEvent is available from a separate subpath —
same API, but it reads the latest handler through useEffectEvent instead of a
ref, closing a small window where a concurrent-render publish could call the
previous handler:
import { useSubscribe } from 'use-pubsub-js/react19/useSubscribe'The default use-pubsub-js export is unchanged and stays React-18 compatible.
- The minimum supported React version is now 18.0.0 (was 17). The hooks use
only stable React primitives, but React 18+ is required so the library can rely
on automatic batching and
useSyncExternalStore. pubsub-jsis no longer a dependency;PubSubis the library's own bus. If you also usedpubsub-jsdirectly elsewhere you were sharing one global singleton — in v2 the bus is independent, so those direct subscribers will no longer receive messages from the hooks'PubSub. ImportPubSubonly fromuse-pubsub-jsfor a single shared bus, and removepubsub-jsfrom your own dependencies if you no longer need it.- The subscriber
messagepayload type is nowunknown(wasany) — narrow or cast it in your handler.usePublish'smessageis likewise widened fromstringtounknown, so you can publish any payload through the hook. - The default export was removed — use named imports.
IUsePublishParams/IUsePublishResponsewere renamed toUsePublishParams/UsePublishResponse.- The
useSubscribetypesUseSubscriptionParams/UseSubscriptionResponsewere renamed toUseSubscribeParams/UseSubscribeResponsefor parity with theusePublishtypes. - Both hooks now accept an optional
busparam (defaults to thePubSubsingleton), so acreatePubSub<E>()bus can be driven through the hooks with typed payloads. - A subscriber that throws no longer re-throws asynchronously (which could crash
a Node process). The error now goes to an error handler that defaults to
console.error; delivery to the other subscribers always continues. Pass your own viacreatePubSub({ onError }). subscribe/subscribeOncenow return a brandedSubscriptionToken(still a string at runtime).unsubscribeonly accepts that token or a handler reference, so passing an arbitrary string (e.g. a topic name by mistake) is a type error. TheTokentype is kept as a deprecated alias ofSubscriptionToken.- Symbol tokens match by identity (two distinct
Symbol('x')no longer collide). - Removed rarely-used pubsub-js extras (
publishSync,subscribeAll/*wildcard,clearSubscriptions(topic),countSubscriptions,getSubscriptions,immediateExceptions).publish,subscribe,subscribeOnce,unsubscribe,clearAllSubscriptionsand hierarchical topics are kept. - The internal
use-pubsub-js/utils/debouncesubpath is no longer exported (it was undocumented). Use a dedicated debounce utility if you need one.
See the runnable demo in the example folder, which covers manual/automatic publishing, external messages, and a typed bus driven through the hooks.
- Arguments of
useSubscribe
| key | description | type | default/required |
|---|---|---|---|
| token | Token is used to subscribe listen a specific publisher | string | symbol | required |
| handler | Function that is going to be executed when a publication occurs | (token: string | symbol, message: unknown) => void | required |
| isUnsubscribe | Is the way to dynamically unsubscribe and subscribe based on some variable | boolean | false |
| bus | The bus to subscribe on; pass a createPubSub<E>() bus for typed payloads |
PubSubBus | TypedPubSub<E> | PubSub singleton |
The
handlermessageisunknownby default; when a typedbusis passed it is narrowed to the payload type for that token (Events[token]).
- Returns of
useSubscribe
| key | description | type |
|---|---|---|
| unsubscribe | A function to manual unsubscribe the token off your handler | () => void |
| resubscribe | A function to manual resubscribe the token in your handler, only have effects if the handler is not linked in token | () => void |
- Arguments of usePublish
| key | description | type | default/required |
|---|---|---|---|
| token | Token is used to subscribe listen a specific publisher | string | symbol | required |
| message | The value that will be send to subscriber | unknown | required |
| isAutomatic | Whether the publication should be automatic | boolean | false |
| isInitialPublish | Whether to make a publication in the first render | boolean | false |
| isImmediate | To disable debounce and publish without delay any change in the message | boolean | false |
| debounceMs | The delay value | number | string | 300 |
| bus | The bus to publish on; pass a createPubSub<E>() bus for typed payloads |
PubSubBus | TypedPubSub<E> | PubSub singleton |
- Returns of usePublish
| key | description | type |
|---|---|---|
| lastPublish | The value is true if you have a subscriber on last publication and false if you don't | boolean |
| publish | A function to manual publish a message | () => void |
Tests use Vitest + Testing Library.
We follow an assertive test-description style — no should prefix
(it('publishes on mount'), not it('should publish on mount')). To
normalize existing titles, run Spotify's
should-up:
pnpm dlx should-up ./srcIt strips should and conjugates common verbs; for verbs it doesn't know
you may need a small manual touch-up. (There's no Biome/lint rule for
test-title wording, so this is a convention, not a CI check.)
MIT © Reactivando