Back to Blogs

Vue 3 Composables: The Right Pattern for Wrapping Third-Party Libraries

The Pattern I Kept Rediscovering

When I built the PDF tools on this site, each one started as a single component. Drop in a few imports, wire up PDF.js or Fabric.js, ship it.

By the time the first tool worked, the component was 300 lines of library configuration, event listeners, and lifecycle code sitting between the template tags. When I needed the same library in a second tool, I copied all of it. Then the two copies diverged. Then one of them had a memory leak and I couldn't tell which.

I extracted everything into composables. I should have done it from the start. I now do it from the start.

This post is the pattern I settled on — not the naive version, but the full version with lifecycle, singletons, and SSR guards.


Why a Composable, Not a Utility File

The first instinct is to put library init in a plain utility file — lib/pdfRenderer.ts — and import it where needed. That works for pure functions. It breaks down for anything stateful.

Third-party libraries are almost always stateful. PDF.js maintains a worker thread. Fabric.js manages a canvas event loop. Lenis runs an animation frame. These things need to be torn down when you're done with them, and the timing of "done" is tied to component lifecycle.

A composable has access to onMounted and onUnmounted. A utility file doesn't. That's the whole reason to use a composable here — not code organization, but lifecycle.


The Core Pattern

Here's the skeleton I use for any library wrapper:

// composables/useLibraryName.ts
import { ref, onMounted, onUnmounted } from 'vue'

export function useLibraryName() {
  const isReady = ref(false)
  const error = ref<string | null>(null)

  let instance: LibraryInstance | null = null

  onMounted(() => {
    // Initialize the library here, not at module scope
    instance = new Library({ ...options })
    isReady.value = true
  })

  onUnmounted(() => {
    // Clean up, always
    instance?.destroy()
    instance = null
    isReady.value = false
  })

  function doSomething() {
    if (!instance) return
    instance.doSomething()
  }

  return { isReady, error, doSomething }
}

Three rules that matter:

  1. Library init goes in onMounted, not at the top of the function
  2. Cleanup goes in onUnmounted, every time, no exceptions
  3. The instance variable is module-local — the caller never touches it directly

What Goes Wrong Without onMounted

The temptation is to initialize in the function body:

export function useLibraryName() {
  const instance = new Library()  // ← runs on import or first call
  // ...
}

In a Nuxt app, composables run on the server during SSR. new Library() probably references window, document, or canvas. None of those exist on the server. You get a crash or, worse, a silent failure that only surfaces in production.

Wrapping it in onMounted defers it to after hydration, when you're definitely in the browser. The component mounts, the library initializes, done.

For the PDF tools I built, every composable — usePdfRenderer, useFabricEditor, usePdfLib, useSignature — initializes inside onMounted for exactly this reason. I broke this rule once early on and spent an hour debugging a build that worked locally and failed on deploy.


A Real Example: Wrapping PDF.js

Here's a stripped-down version of the actual usePdfRenderer composable that powers the PDF editor and PDF to image tool:

import * as pdfjs from 'pdfjs-dist'
import { ref, onMounted, onUnmounted } from 'vue'

export function usePdfRenderer() {
  const pageCount = ref(0)
  const isLoading = ref(false)

  let pdfDoc: pdfjs.PDFDocumentProxy | null = null
  let activeRenderTask: pdfjs.RenderTask | null = null

  onMounted(() => {
    pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.js'
  })

  onUnmounted(() => {
    activeRenderTask?.cancel()
    pdfDoc?.destroy()
    pdfDoc = null
  })

  async function loadDocument(data: ArrayBuffer) {
    isLoading.value = true
    try {
      pdfDoc = await pdfjs.getDocument({ data }).promise
      pageCount.value = pdfDoc.numPages
    } finally {
      isLoading.value = false
    }
  }

  async function renderPage(pageNum: number, canvas: HTMLCanvasElement, scale = 1.5) {
    if (!pdfDoc) return
    activeRenderTask?.cancel()

    const page = await pdfDoc.getPage(pageNum)
    const viewport = page.getViewport({ scale })
    canvas.width = viewport.width
    canvas.height = viewport.height

    const ctx = canvas.getContext('2d')!
    activeRenderTask = page.render({ canvasContext: ctx, viewport })
    await activeRenderTask.promise
  }

  return { pageCount, isLoading, loadDocument, renderPage }
}

The component that uses this is about 10 lines. All the lifecycle and cleanup noise stays in the composable where it belongs.

Note the activeRenderTask?.cancel() at the start of renderPage. PDF.js renders asynchronously. If the user switches pages before a render finishes, you need to cancel the in-flight task or you get two renders fighting over the same canvas. This is exactly the kind of detail that gets lost when library code lives inside a component.


The Singleton Variation

Sometimes you want one shared instance across the entire app, not a new one per component. Lenis — the smooth scroll library running on this site — is the classic example. You need exactly one scroll instance.

The trick: declare the instance at module scope, outside the composable function.

// composables/useLenis.ts
import Lenis from 'lenis'
import gsap from 'gsap'
import ScrollTrigger from 'gsap/ScrollTrigger'

let instance: Lenis | null = null  // ← module-level singleton

export function useLenis() {
  function init() {
    if (instance) return  // already running, skip
    instance = new Lenis({ smoothTouch: false })
    instance.on('scroll', () => ScrollTrigger.update())
    gsap.ticker.add((time) => instance?.raf(time * 1000))
    gsap.ticker.lagSmoothing(0)
  }

  function destroy() {
    instance?.destroy()
    instance = null
  }

  function stopScroll() { instance?.stop() }
  function startScroll() { instance?.start() }

  return { init, destroy, stopScroll, startScroll }
}

One place in the app calls init() at startup. Every component that needs to pause scroll — the mobile menu, the PDF editor overlay — calls stopScroll() without knowing or caring about the Lenis instance itself.

The key difference from the per-component pattern: no onMounted/onUnmounted in this composable. The singleton lives for the entire app lifecycle and gets destroyed when the app is torn down, not when any individual component unmounts.


The SSR Guard

For anything that can't be deferred to onMounted, use Nuxt's environment check:

if (import.meta.client) {
  // runs only in the browser
}

I use this for one-time global setup — registering a GSAP plugin, loading a worker script path — that needs to happen before a composable even mounts. In plain Vue (not Nuxt), use typeof window !== 'undefined'.

The composable pattern doesn't change. Just wrap the browser-only lines.


When to Skip the Composable

Not everything needs this treatment. A composable is an extra file, an extra indirection. If you're calling a library once in one place and it's a pure function with no state or lifecycle — just import and call it.

The test I apply: does this library need setup, teardown, or shared state? If yes, wrap it. If it's a marked(text) call with no side effects, just call it.

Most libraries worth integrating have at least one of the three.


The Short Version

Library init in onMounted. Cleanup in onUnmounted. Shared instances at module scope, outside the function. That covers 90% of what you'll need.

The harder parts — coordinate transforms, memory management across page switches, rasterizing canvas to PDF output — I wrote about in the PDF editor tech stack post. But the composable structure is the same throughout. One file per library, one job per composable, lifecycle handled internally.

If you're building something with Fabric.js, PDF.js, a chart library, or anything else that isn't a pure function: wrap it first. It's faster to build the wrapper upfront than to extract it later when the component is already tangled.

Enjoyed this?

Share it with your network