PDF.js EventBus guide: Events, hooks, and custom pub/sub patterns
Table of contents
EventBus is PDF.js’s internal pub/sub system that powers every viewer interaction. This guide covers built-in events, React hook patterns, and how to extend it for custom annotation features.
- The
EventBusis PDF.js’s internal pub/sub channel — every interaction (page change, zoom, render, search) fires through it. - Subscribe with
eventBus.on(name, handler)and clean up witheventBus.off(name, handler)inuseEffectreturns. - Dispatch your own events (e.g.
annotationcolorchange) to bridge React components and PDF.js’s vanilla JS class instances.
Prerequisites
This guide assumes you have a PDFViewer composed from pdfjs-dist/web/pdf_viewer.mjs and an EventBus instance shared between the viewer, link service, and find controller. If you haven’t wired those up yet, start with our blog on how to set up a custom PDF.js viewer in React.
You’ll also need:
pdfjs-dist4.x or later- A React app (the hook patterns below use React 18+ effect semantics)
Creating an EventBus
Import the viewer layer and construct a single EventBus instance that every component will share:
const pdfjs = await import("pdfjs-dist/web/pdf_viewer.mjs");const eventBus = new pdfjs.EventBus();The EventBus is shared across all PDF.js components — PDFViewer, PDFLinkService, PDFFindController — and your own custom code.
Subscribing and unsubscribing
Register a handler with eventBus.on(name, handler) and remove it with eventBus.off(name, handler):
// Subscribe.eventBus.on("pagechanging", (evt) => { console.log("Now on page:", evt.pageNumber);});
// Unsubscribe.eventBus.off("pagechanging", handler);Built-in events reference
PDF.js fires a fixed set of built-in events. The tables below group the most useful ones — by page lifecycle, scale and rotation, and search — with their payloads and when each fires.
Page lifecycle
These events fire as the document loads and as pages scroll into view and finish rendering:
| Event | Payload | When |
|---|---|---|
pagesinit | {} | All pages initialized after document load |
pagechanging | { pageNumber } | User scrolls to a different page |
pagerendered | { pageNumber, cssTransform, timestamp } | A page canvas finishes rendering |
textlayerrendered | { pageNumber } | Text layer overlay is ready on a page |
Scale and rotation
These events fire whenever the zoom level or page rotation changes:
| Event | Payload | When |
|---|---|---|
scalechanging | { scale, presetValue } | Zoom level changes |
rotationchanging | { pagesRotation } | Page rotation changes |
Search/find
These events drive find operations and report their results back to your UI:
| Event | Payload | When |
|---|---|---|
find | { type, query, caseSensitive, ... } | You dispatch this to trigger search |
updatefindmatchescount | { matchesCount: { current, total } } | Match count updates |
updatefindcontrolstate | { state, matchesCount } | Find state changes (FOUND, NOT_FOUND, WRAPPED, PENDING) |
Pattern: React hook for EventBus events
Wrap subscription and cleanup in a reusable hook so each listener is removed when the component unmounts:
function useEventBus(eventBus, eventName, handler) { React.useEffect(() => { eventBus?.on(eventName, handler); return () => { eventBus?.off(eventName, handler); }; }, [eventBus, eventName, handler]);}
// Usage.useEventBus(eventBus.current, "pagechanging", (evt) => { setCurrentPage(evt.pageNumber);});Pattern: Custom events via EventBus
The EventBus isn’t limited to built-in events. You can dispatch your own custom events and use it as a cross-component communication channel:
class PDFAnnotationState { #eventBus; #color = "red"; #tool = null; #showAnnotations = true;
constructor({ eventBus }) { this.#eventBus = eventBus; }
get color() { return this.#color; }
set color(newColor) { this.#color = newColor; this.#eventBus.dispatch("annotationcolorchange", { source: this, color: newColor, }); }
get tool() { return this.#tool; }
set tool(newTool) { this.#tool = newTool; this.#eventBus.dispatch("annotationtoolchange", { source: this, tool: newTool, }); }
get showAnnotations() { return this.#showAnnotations; }
set showAnnotations(show) { this.#showAnnotations = show; this.#eventBus.dispatch("annotationshowchange", { source: this, showAnnotations: show, }); }}Then listen to these events from any component:
function useAnnotationState(eventBus, annotationState) { const [color, setColor] = React.useState(annotationState?.color); const [tool, setTool] = React.useState(annotationState?.tool); const [show, setShow] = React.useState(annotationState?.showAnnotations);
React.useEffect(() => { const onColor = (evt) => setColor(evt.color); const onTool = (evt) => setTool(evt.tool); const onShow = (evt) => setShow(evt.showAnnotations);
eventBus?.on("annotationcolorchange", onColor); eventBus?.on("annotationtoolchange", onTool); eventBus?.on("annotationshowchange", onShow);
return () => { eventBus?.off("annotationcolorchange", onColor); eventBus?.off("annotationtoolchange", onTool); eventBus?.off("annotationshowchange", onShow); }; }, [eventBus, annotationState]);
return { color, tool, show };}Why EventBus instead of React state?
PDF.js components (PDFViewer, PDFFindController, etc.) are not React components — they’re vanilla JS class instances. They can’t subscribe to React state. The EventBus bridges this gap:
- Toolbar changes annotation color → sets
annotationState.color = "blue" - Setter dispatches
annotationcolorchangeviaEventBus - Annotation component listens via
.on("annotationcolorchange")and updates its React state
This pattern keeps your custom PDF features integrated with PDF.js’s own event system instead of creating a parallel state management layer.
Key events for common features
This table maps common viewer features to the events you’ll typically subscribe to:
| Feature | Events to listen to |
|---|---|
| Page navigation toolbar | pagechanging |
| Zoom controls | scalechanging |
| Custom overlay layers | textlayerrendered, pagerendered |
| Search results display | updatefindcontrolstate, updatefindmatchescount |
| Annotation rendering | textlayerrendered (to create overlay containers) |
| Highlight positioning | scalechanging, rotationchanging |
Gotchas
- Always clean up listeners in
useEffectreturn functions — PDF.jsEventBusdoesn’t clean up automatically - The
dispatchmethod requires asourceproperty in the event object by convention EventBusevents are synchronous — heavy handlers will block renderingtextlayerrenderedfires per page, not once for the whole document
How Nutrient Web SDK handles this
Nutrient Web SDK exposes the same kinds of viewer events through standard DOM-style addEventListener/removeEventListener calls. Event names are TypeScript-typed, so your editor can complete them and flag typos.
instance.addEventListener("viewState.currentPageIndex.change", (pageIndex) => { console.log("Now on page:", pageIndex + 1);});
instance.addEventListener("viewState.zoom.change", (zoom) => { console.log("Zoom level:", zoom);});For broader viewer changes, listen to viewState.change and receive the full state diff.
Learn more about Nutrient Web SDK | Migration guide | Contact Sales
FAQ
eventBus.on or eventBus._on?Use eventBus.on/eventBus.off. The _on/_off forms exist as legacy internal aliases, but PDF.js’s public documentation and viewer source use the non-underscored names. Sticking to the public form makes your code easier to read and less likely to break on a future PDF.js version.
dispatch need a source property?PDF.js’s own components inspect evt.source to avoid reacting to events they themselves dispatched (preventing feedback loops). When you call eventBus.dispatch("myevent", { source: this, ... }), you’re following the same convention so downstream listeners can filter by origin.
EventBus events synchronous?Yes. Handlers fire in the order they were subscribed, on the same microtask. Long-running work in a listener will block subsequent listeners and delay the rendering pipeline — wrap expensive operations in queueMicrotask, setTimeout, or requestIdleCallback when needed.
textlayerrendered fire once or per page?Per page. Each PageView fires its own textlayerrendered event when its text layer overlay finishes rendering. That makes it a useful hook for inserting per-page overlays (highlights, annotations), but a poor signal for “the whole document is ready.”
EventBus instances?Technically yes, but you almost never want to. PDF.js’s viewer components are constructed with a shared EventBus so that PDFViewer, PDFLinkService, PDFFindController, and your code all see the same events. Separate buses defeat the purpose of having one.
eventBus.off?The handler keeps running for the lifetime of the EventBus, even after the React component that registered it has unmounted. That typically manifests as memory leaks and stale-state warnings (handler closes over old state). Always clean up in the useEffect return.