skip to content

Props and Callbacks

Only plain serialisable data crosses into the browser, so callbacks live in the rendered scenario and record what fired into a hidden input the test reads. Interviewers probe that boundary.

on this pageshow

explore

questions

5

In a Playwright component test, why can't you pass an onSelect callback in mount's props?

level: juniorimportance: must knowfreq 58%

answer

  1. Test in Node, component in browser
  2. The props argument is serialised
  3. Data crosses, closures do not
  4. Callback wired inside the story export
  5. Effect written to DOM for readback

basics

~20 s

Playwright sends mount's props across a process boundary into the browser, so only plain serialisable data survives. A function carries behaviour that cannot be transferred, so callbacks belong in the story file, not in the props object.

solid answer

~50 s

Playwright renders the component in a real browser while the test file runs in Node, so the props argument of `mount` is serialised across that boundary. Plain data crosses: strings, numbers, booleans, `null`, arrays and plain objects. A function does not, because its closure lives in the test process and there is nothing meaningful to serialise. For a design-system date picker you therefore pass `value` and `locale` as props, and wire `onSelect` inside the story export that `mount` renders. To let the test see that the callback fired, the story records what the handler received into a hidden input carrying a `data-testid`, and the test asserts on it with `toHaveValue`. The rule is worth stating plainly in review: props are data, callbacks are part of the scenario, and whatever a callback learns must be written into the DOM before a test can read it.

code

typescript · 17 lines
typescript
// date-picker.story.tsx - the scenario that renders inside the browser
import { useState } from 'react';
import { DatePicker } from '../src/date-picker';

export function PickerWithRecorder(props: { value: string; locale: string }) {
  const [picked, setPicked] = useState('');
  return (
    <>
      <DatePicker
        value={props.value}
        locale={props.locale}
        onSelect={(iso: string) => setPicked(iso)}
      />
      <input type="hidden" data-testid="selected" readOnly value={picked} />
    </>
  );
}

go deeper

for a junior

Remember the shape of the rule: the props argument takes plain data only, and any callback the component needs is wired inside the story file that gets rendered.

for a middle

Explain the why: the test process and the browser page are separate, props are serialised to cross, and a closure has nothing meaningful to serialise into.

for a senior

Show how you stop this biting a team: story files that own their handlers, one recorder convention for callback payloads, and a review rule that rejects function props on sight.

for a principal

Own the boundary as a design constraint. The props surface is the contract between the suite and the component, and keeping it plain data is what makes stories reviewable, portable and cheap to maintain.

## Two processes, one serialised hop A Playwright component test is not a simulated render inside the test process. The test file runs in Node; the component is rendered by a real browser engine in a page. `mount` therefore has to ship everything it is given across that gap, and in Playwright 1.63 the only values that survive the trip are ones Playwright can serialise: strings, numbers, booleans, `null`, arrays and plain objects made of those. A function is not such a value. It is behaviour plus a closure over variables that only exist in the Node process. There is no representation of "this function, still bound to that scope" that can be handed to a browser page, so a callback in the props argument is not a subtle mistake that degrades quietly. The thing you meant to pass simply does not exist on the other side. ## What crosses and what does not | Value in the props object | Crosses into the page? | Do this instead | |---|---|---| | `value: '2026-03-14'`, `locale: 'en-GB'` | Yes, plain data | Pass it | | `rows: [{ id: 'r1', label: 'Jan' }]` | Yes, array of plain objects | Pass it | | `onSelect: (iso) => picked.push(iso)` | No, a function | Wire it inside the story | | `emptyState: <EmptyGrid />` | No, a framework element | Add a story export that composes it | | `formatter: new Intl.NumberFormat('de')` | No, a class instance | Pass a locale string, build it in the story | The pattern behind the table: **data crosses, identity and behaviour do not**. Anything whose usefulness depends on its prototype, its closure, or a live handle it holds - a class instance, a DOM node, a framework element, a stream - belongs on the browser side of the boundary. ## Where the callback actually lives The story export that `mount` renders is ordinary browser-side code. It imports the real component, so it can hand it a real handler: - the story declares the state it wants to observe; - the story passes its own function as the component's callback prop; - the handler writes what it received into the DOM, typically a hidden `<input>` with a stable `data-testid`; - the test acts on the component and reads that input back with `toHaveValue`. Nothing about the callback crosses the boundary. What crosses back is the string the story chose to write, and it crosses the way everything else does in Playwright: as DOM state the test can query. ## The recorder pattern, step by step 1. Add a piece of state to the story for each callback you care about. 2. Wire the component's callback prop to a handler that updates that state. 3. Render a hidden input whose `value` is the serialised state and whose `data-testid` names the callback. 4. In the test, drive the UI, then assert `expect(component.getByTestId('selected')).toHaveValue('2026-03-15')`. Because `toHaveValue` retries until its timeout, step four needs no manual wait after the click. The assertion settles as soon as the story re-renders the recorder. ```ts // The first argument identifies the story export; only the second carries data. const component = await mount<typeof PickerWithRecorder>(pickerStoryId, { value: '2026-03-14', locale: 'en-GB', }); await component.getByRole('button', { name: '15' }).click(); await expect(component.getByTestId('selected')).toHaveValue('2026-03-15'); ``` ## Typing does not rescue you `mount<typeof PickerWithRecorder>(...)` types the props object against the story's own signature, which is genuinely useful: a misspelled prop or a missing required one becomes a compile error. But TypeScript has no idea what is serialisable. If a story declares `onSelect: (iso: string) => void`, the compiler will happily accept a function that can never arrive. The defence is the story's prop type itself - design it so the unsayable thing cannot be said. A story whose props are `{ value: string; locale: string; mode: 'default' | 'rejects' }` cannot be misused, because every member of that type is data. ## Common mistakes - Passing a test-side spy as a prop and then wondering why it never records anything. - Assuming the component renders in the Node process and shares memory with the test. - Treating a framework element prop as just another object; it is not plain data. - Expecting a callback's argument to reach the test without ever touching the DOM. - Putting the branching in the test when it belongs in the story: send a plain flag such as `mode: 'rejects'` and let the story choose the handler. Once the boundary is explicit, most component-test confusion in a design-system package disappears: the props argument is a data contract, and everything with behaviour is written in the story file that renders in the browser.

  • If props must be serialisable, how does a test choose which callback behaviour the story uses?
    Send a plain discriminator and let the story map it to a handler. A prop such as `mode: 'rejects'` makes the story wire an `onSelect` that refuses the date, while the test still transmits nothing but a string. All branching stays in browser-side story code.
  • Does a framework element passed as a prop behave any differently from a function?
    No. An element is an object holding component references and closures, so it is no more serialisable than a function. The composed tree has to be written in the story file and exported as its own story, leaving only the plain parts in the props argument.
  • Can the story reach back into the test process at all?
    Not directly. Everything the test learns has to be observable in the page: DOM text, an attribute, or an input value. That is why the recorder input exists, and why teams keep one naming convention for those test ids across a package.

Props are a postcard: you can write data on it, but you cannot mail a live phone line. The phone has to be installed at the other end already.

saying these in an interview costs you the question

  • Claiming mount props accept any JavaScript value
  • Passing a test-side spy function as a prop
  • Believing the component renders in the Node process
  • Expecting a callback payload to reach the test without touching the DOM
  • Treating a JSX element prop as plain serialisable data
open as a page

How does a Playwright component test prove that a toast's onUndo callback fired with the right payload?

level: middleimportance: must knowfreq 51%

basics

~20 s

The story wires the callback and writes what it received into a hidden input marked with a data-testid. The Playwright test drives the UI, then asserts on that input with toHaveValue, which retries until the recorded value appears.

open as a page

In Playwright component tests, how do you mount a data grid that needs children rendered inside it?

level: middleimportance: should knowfreq 37%

basics

~20 s

Children cannot travel in the props object because a framework element is not serialisable data. Write the composed tree as its own story export, mount that export, and keep only plain values such as labels and row arrays in props.

open as a page

In a Playwright component test, what fails when mount's props carry a renderCell function and a JSX empty state?

level: seniorimportance: should knowfreq 29%

basics

~20 s

Neither prop can cross into the browser: a function and a framework element are not serialisable, so the grid renders as if they were missing. Move both into the story file and send a plain variant flag instead.

open as a page

As owner of a design system's Playwright story registry, when do you add a story export instead of a prop?

level: principalimportance: nice to knowfreq 22%

basics

~20 s

Add an export when the rendered tree or the wiring differs, and a prop when only data differs. Exports are code you maintain forever, so each one should stand for a composition consumers actually build, not a data permutation.

open as a page