IKivan-kozenko -aqa
Try yourself as a QA tester
All topics·Intermediate·23 / 27

Handles

Handles are references to JavaScript objects (JSHandle) or DOM elements (ElementHandle) that live in the browser. I almost never use ElementHandle directly anymore — Locators replaced them and are much better. The one case I still reach for JSHandle: when I need to hold a reference to a browser-side object across multiple evaluate() calls without serializing it each time.

Locator vs ElementHandle — why Locator wins

ElementHandle holds a stale reference after re-render; Locator re-queries the DOM on every use

An ElementHandle points to a specific DOM node captured at a moment in time. If React re-renders and replaces that node with a new one, the handle is stale — it still points to the old detached node. This causes subtle bugs where actions succeed but operate on a ghost element.

A Locator stores the query logic, not the element. Every time I call .click(), .fill(), or any assertion, Playwright re-queries the DOM fresh. This means locators work correctly even after React re-renders, navigation within a SPA, or any DOM mutation.

ts
// ElementHandle — вказує на конкретний вузол DOM (застаріє при перерендері)
const handle = await page.$('text=Submit')
// ... React може перерендерити між цим і наступним рядком ...
await handle.hover()   // може оперувати над старим вузлом!
await handle.click()

// Locator — заново робить запит при кожному використанні (безпечно)
const locator = page.getByText('Submit')
// ... перерендер не проблема ...
await locator.hover()  // знаходить поточний елемент
await locator.click()  // знаходить його знову

JSHandle — when I actually use it

A JSHandle is a reference to any JavaScript object in the browser — including non-DOM objects like window, arrays, or complex objects from the page's JavaScript environment. The object stays alive in the browser; the handle is just a pointer from Node.js.

The use case where JSHandle saves multiple round-trips: when I create a large JavaScript object in the browser and want to call multiple operations on it without serializing and deserializing the whole thing over the protocol each time.

ts
// Отримати посилання на window (не серіалізується)
const windowHandle = await page.evaluateHandle('window')

// Передати handle в evaluate — не потрібно його серіалізувати
const userAgent = await page.evaluate(win => win.navigator.userAgent, windowHandle)

// Використовувати JSHandle для роботи з масивом в браузері
const arrayHandle = await page.evaluateHandle(() => {
  window.myArray = [1, 2, 3]
  return window.myArray
})

// Кілька операцій над тим самим об'єктом браузера без повторної серіалізації
const length = await page.evaluate(arr => arr.length, arrayHandle)
await page.evaluate(arr => arr.push(4), arrayHandle)
const newLength = await page.evaluate(arr => arr.length, arrayHandle)

// Завжди звільняти handle коли більше не потрібен
await arrayHandle.dispose()

ElementHandle — legacy, use Locator instead

The old way to interact with elements before Locators existed. page.$() returns an ElementHandle (equivalent to document.querySelector). I might see this in older codebases — the migration path is to replace page.$() calls with page.locator() or getBy* locators.

The one remaining legitimate use: ElementHandle.boundingBox() to get an element's pixel coordinates for visual assertions or manual gesture simulations. Locators don't expose boundingBox() directly.

ts
// Єдиний кейс де ElementHandle може знадобитися: boundingBox()
const element = await page.waitForSelector('#chart-canvas')
const box = await element.boundingBox()
if (box) {
  // Клік у конкретній точці елемента (наприклад, координати на canvas)
  await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2)
}

// Старий стиль (не рекомендовано) — використовуй замість цього Locator:
// const el = await page.$('text=Submit')
// await el?.click()
// ↓ Новий стиль:
// await page.getByText('Submit').click()

Handle lifecycle — dispose when done

Handles keep the referenced JavaScript object alive in the browser, preventing garbage collection. When I'm done with a handle, I should call dispose() to release it. If the page navigates, all handles become invalid automatically.

ts
// Звільнення handle після використання
const handle = await page.evaluateHandle(() => ({ large: 'data object' }))
// ... використати handle ...
await handle.dispose()  // звільнити посилання, дозволити GC

// При навігації — всі handles автоматично недійсні
await page.goto('/other-page')
// handle тут вже недійсний