Skip to main content

One React, Four Outputs: DOM, PDF, Email, and Ink

· 15 min read
Gergely Sipos
Frontend Architect

An order has two units at $25 each. The browser editor shows $50. So do its downloadable invoice, confirmation email, and the terminal tool used by operations. Then someone changes the quantity to three. Every new representation should say $75—but updating a screen, producing a PDF, and sending another email are very different jobs.

React can participate in all four. That sounds like “write once, render anywhere,” until you ask what actually receives the component tree. A browser node? A document record? A string of HTML? A terminal frame?

The interesting part is not that several libraries accept JSX. It is where React's responsibilities stop and each destination's begin. We'll follow this hypothetical order across four outputs, separating shared business meaning from layout, input, and delivery. These are four output contracts, not four equivalent renderer implementations: email takes a different architectural branch.

JSX is a description, not a destination​

“Virtual DOM” is a poor umbrella for everything happening here. It collapses several distinct representations:

  • JSX is authoring syntax. It describes elements; it is neither HTML nor a layout engine.
  • React elements describe a type, props, and children. A type can be a component or a host primitive. Creating an element does not execute that component or create its eventual host instance, as the element API documentation explains.
  • Fiber is React's internal reconciliation bookkeeping for live client and custom-renderer trees. It tracks work and mounted component state; it is not the element description or the destination's node tree.
  • Host instances are the destination-specific objects a renderer manages. A host is the environment receiving React's changes: browser nodes, PDF document records, or Ink-owned nodes.
  • Delivered output is what someone finally sees or receives: browser paint, PDF bytes, email HTML/text, or terminal writes.

A function component can return other components without having its own host node. An OrderSummary is an organizational boundary, not necessarily a browser element, PDF box, or terminal cell.

That distinction lets React offer composition without dictating the destination. It does not make destinations interchangeable:

Shared order data + calculations
|
+-- destination-specific React components / element descriptions
|
+-- React DOM client
| reconciler + DOM host config -> DOM nodes
| -> browser layout and paint
|
+-- @react-pdf/renderer
| reconciler + document host config -> document records
| -> layout + pagination -> PDFKit drawing -> PDF output
|
+-- React Email
| React DOM server -> collected HTML
| -> email processing -> HTML / plain text
|
+-- Ink
reconciler + terminal host config -> Ink host nodes
-> Yoga layout + terminal output -> stream

Three branches share React's reconciler and differ in their host configuration. The email branch skips the live-host commit model entirely and serializes to HTML instead.

The common trunk means shared domain meaning, not one unchanged JSX tree. Our invoice and terminal display agree on $50 because they consume the same order facts and calculation rules, not because they share a browser-shaped OrderSummary.

Where reconciliation ends and the host begins​

For a mounted root, a state update or a new root element schedules work. React re-evaluates the affected components and reconciles the result against the current tree. Render computes what should come next. Commit applies completed changes to the host representation. Layout, drawing, or serialization can still remain afterward.

The familiar render-and-commit explanation uses browser examples. Custom renderers supply another destination through a host configuration: operations for creating instances and text, attaching or removing children, and applying updates.

Some concrete names from React's custom host boundary make the division less abstract:

OperationConceptual responsibility
createInstanceAllocate a host-specific instance for a primitive
createTextInstanceRepresent text in the host's vocabulary
Child attachment/removalMaintain the destination's tree relationships
Host updatesApply changed content or properties
resetAfterCommitLet the renderer perform post-commit work

This is a sketch of the interface, not the whole of it. The point is the division of labor: React decides what changed, and the host configuration knows how to express that change in the destination's own objects.

For our order, a host update might replace a document text value, a DOM node's text, or Ink's stored text. Those changes mean “the representation now contains $75.” They do not by themselves choose a page break, paint pixels, or send an email. Inspecting the host tree can answer whether the value changed while still missing a delivery failure.

React need not know how to encode a PDF page or move a terminal cursor. The renderer knows how host objects change; downstream systems know how to turn them into output. Consequently, “React finished committing” is not synonymous with “the customer received the file.”

Same React does not mean identical guarantees

react-reconciler is experimental infrastructure, with a less stable API than React/React DOM. Renderer integrations are coupled to supported React/reconciler versions. Do not infer identical concurrent behavior, Suspense capabilities, or React Server Components support. Running in Node does not establish RSC support.

Source links in this article point to specific commits; details may differ in other versions. Email, the server-HTML branch, gets its own section below because it does not follow this live-root model.

In the browser, the host is the DOM​

The browser editor is our baseline. React DOM's createRoot, imported from react-dom/client, mounts into a browser DOM node. For server-generated React HTML that needs hydration, hydrateRoot is the separate entry point.

Follow the quantity change:

  1. A browser interaction invokes the application's quantity state setter.
  2. React reevaluates the affected components. The total description now says $75.
  3. React DOM commits the necessary browser-node changes.
  4. The browser performs layout and paint to display the result.

React DOM updates the document; it does not implement browser CSS layout. The DOM is a document-and-node API, not the pixels themselves.

This also separates two architecture questions. Rendering architectures asks where and when web rendering happens. Here we ask what host receives the description. Server-generated HTML and a live DOM application can belong to one web product, but they are different stages—not evidence that every React destination works like a browser.

For PDF, committing a tree is not producing a file​

A document vocabulary, not HTML​

@react-pdf/renderer generates PDFs. It is distinct from the similarly named react-pdf library for viewing PDFs. Its vocabulary includes Document, Page, View, and Text, rather than browser tags.

In the inspected host implementation, createInstance produces ordinary records containing type, box, style, props, and children. Text instances hold text values. Reconciliation creates and updates this intermediate document tree.

That is a genuine custom host, but it is not yet a PDF. Nor is a browser div a portable instruction for laying out an invoice page. Your domain-level “order total” survives the move; its presentation needs document-specific primitives.

Layout, pagination, then drawing​

The export path constructs a PDFKit document, awaits document layout, and hands the laid-out result to the drawing stage. The layout pipeline includes assets, styles, text, dimensions, and pagination. Yoga contributes geometry; it neither encodes PDF bytes nor supplies the complete browser CSS engine.

This extra work matters when the quantity becomes three. Updating the producer's tree can change the total to $75. Generating the next invoice still requires layout and drawing. A longer item description can wrap onto another line, push content down, and change later page boundaries. A small data change need not remain a small geometric change.

Think regeneration of an artifact: nothing patches the file a customer already downloaded.

A browser-based invoice preview can itself be a live React DOM interface. The generator's usePDF integration queues regeneration and tracks loading/Blob state. Preview controls may react immediately while a new document is still being produced. That is a live producer wrapped around an artifact, not React running inside the PDF. In an order editor, distinguish “quantity is now three” from “the new invoice is ready”; otherwise a download action can expose an older representation.

Here is a minimal Node export with the quantity already resolved:

invoice.tsx
import { Document, Page, Text, renderToBuffer } from '@react-pdf/renderer';

const order = { id: 'A-104', totalLabel: '$75' }; // Resolved quantity: three
const bytes = await renderToBuffer(
<Document>
<Page>
<Text>Order {order.id}: {order.totalLabel}</Text>
</Page>
</Document>,
);

renderToBuffer is the Node export API used here. Its implementation collects stream chunks into a Buffer; reconciliation alone does not produce bytes.

The exported PDF does not carry your React runtime, hooks, or React event handlers. That does not make PDFs inherently non-interactive: PDF-native links and forms are separate capabilities. The application's next state update cannot reach back into the recipient's existing download.

Email takes the server-HTML branch​

React Email is an email-oriented component and rendering toolkit built on React DOM server HTML generation, not an independent email host reconciler. Its inspected Node implementation makes the distinction explicit.

The asynchronous render function imports react-dom/server. It chooses a streaming server API, waits for completion, and collects HTML. Email-oriented processing follows: removing image preload links, handling the doctype, optionally formatting, or converting to plain text.

That post-processing is email hygiene, not a compatibility layer: it does not make arbitrary CSS work in every mail client.

Using the rendering utility, the same resolved total becomes another artifact:

confirmation.tsx
import { render } from '@react-email/render';

const order = { id: 'A-104', totalLabel: '$75' };
const message = <p>Order {order.id} confirmed. Total: {order.totalLabel}.</p>;
const html = await render(message);
const text = await render(message, { plainText: true });

Real templates need email-specific markup, styling constraints, and client checks; this excerpt only shows the boundary.

When quantity changes, supply new props and render a new body. That does not update a confirmation already sent. Rendering the body and sending the mail are separate responsibilities.

There is also no mounted React application exported to the recipient. React event handlers are not email-client interactions. Effects do not run during server rendering, so fetching the order in useEffect cannot populate this export. Resolve its data before rendering the message.

Notice the contrast with PDF: both deliver artifacts, but only PDF in this comparison builds a custom reconciled host tree. Similar delivery contracts do not imply identical implementation paths.

Ink keeps React alive in the terminal​

Ink supplies a different custom host. Box and Text become Ink-owned nodes, not browser nodes. The reconciler creates and updates that tree; a source filename like dom.ts does not turn these objects into the browser DOM.

Ink attaches Yoga layout nodes and measures text. Its output traversal reads computed geometry and wraps text into an output buffer. Interactive terminal updating writes replacement text plus cursor/erase control sequences—ANSI-style redraw, not DOM mutation.

Again, Yoga supplies geometry, not browser CSS. And a reconciled update does not guarantee a minimal character diff on every write.

Follow an arrow-key update​

Unlike the exported invoice or email, the terminal order editor can remain a mounted application. This excerpt keeps the scenario's fixed, whole-dollar price so the arithmetic stays visible:

order-terminal.tsx
import { useState } from 'react';
import { render, Text, useInput } from 'ink';

function OrderEditor() {
const [quantity, setQuantity] = useState(2);
const unitPriceDollars = 25;

useInput((_input, key) => {
if (key.upArrow) setQuantity(current => current + 1);
});

return (
<Text>Order A-104: {quantity} units, ${quantity * unitPriceDollars}</Text>
);
}

render(<OrderEditor />);

Ink parses stdin input, and useInput subscribes to parsed input/key information, not a browser keyboard event. Pressing the up arrow calls the functional state updater: two becomes three. React reconciles the new description; Ink updates its nodes, calculates layout, and writes the $75 display to the terminal stream.

“Non-browser” does not mean “no hooks.” Ink's own counter example uses state, an effect-driven timer, and cleanup. Subscriptions and timers in a mounted CLI need lifecycle cleanup just as other running applications do.

An interactive TTY is not the same environment as CI or piped output. Ink documents a non-interactive mode without cursor/erase behavior, writing the final frame at unmount. Do not assume arrow-key input is available wherever the process runs.

Share the order model, test each output contract​

Reuse meaning, adapt presentation​

The strongest reuse boundary is usually earlier than JSX. Share order validation, total calculation, explicit formatting rules, and renderer-independent view-model data. For this order, a model can retain quantity and a unit price of 2,500 cents; a presentation model can supply the agreed $75 label. All destinations then consume the same decision about what the number means.

Keep destination-specific responsibilities explicit:

  • Browser: DOM refs, focus, browser events, and browser styles.
  • PDF: page structure, document layout, and pagination.
  • Email: email markup, styling constraints, and recipient-client appearance.
  • Ink: terminal input, width-sensitive layout, and operating mode.

Two libraries exporting something called Text does not make their components interchangeable. Even an unstyled browser widget can depend on browser focus and events. Headless UI separates behavior from styling; that is not the same as separating behavior from its host.

Components and hooks can be shared when their dependencies and lifecycle assumptions genuinely fit both environments. Pure transformations travel more easily than a hook that expects a DOM ref or a server-rendered template that expects an effect to fetch its data.

State ownership is another explicit boundary. Separate live roots and export calls do not automatically share state because they use React. Intentionally connect live browser and terminal views to an application store or service if they must observe the same order. Pass resolved snapshots into PDF/email exports. Otherwise, changing the CLI's local quantity has no reason to update the browser, regenerate the invoice, or send a message.

For an order-processing workflow, choose the snapshot intentionally: an editable draft and an accepted order may have different lifetimes. Consistency means each artifact represents the intended order version, not that every historical confirmation must magically become current.

The $50-to-$75 transition therefore has four delivery contracts:

DestinationWhat must happen after the application accepts the change?
Browser editorIts connected live root updates the visible order
InvoiceA newly generated PDF replaces the downloadable artifact
ConfirmationA new HTML/text body is rendered; sending is a separate decision
Terminal editorIts connected live root redraws output in the appropriate operating mode

None of these actions automatically triggers the other three. That coordination belongs to the application workflow, not the JSX syntax.

Test the layer that can fail​

Useful checks follow the output contract, not the common JSX syntax:

Layer/outputUseful checks
Shared logicTotals, rounding rules, formatting, invalid quantities
BrowserVisible state changes and interaction behavior
PDFContent, long descriptions, pagination, generated-page appearance
EmailHTML/text content and representative client previews
InkInput/state behavior, constrained widths, non-interactive operation

An element snapshot cannot establish that an invoice's last line stays on the right page. A browser screenshot cannot show how a mail client handles the template. A correct terminal string alone cannot establish that interactive redraw or piped execution behaves properly.

Start with the shared calculation—two units give $50, three give $75—then exercise the host and delivery boundaries independently.

Choose the delivery contract, not just the syntax​

The order total should agree across all four surfaces. Their markup, layout, and update mechanisms need not.

React buys a compositional structure and a familiar declarative model. React DOM and Ink can keep live interfaces running; PDF and email exports produce artifacts. Custom host configurations explain the PDF and terminal branches, while React DOM server explains the email branch.

The useful promise is narrower—and more durable—than “render anywhere.” Share business meaning where consistency matters. Accept destination-specific presentation where layout, input, serialization, and delivery require it. The $75 is shared; how someone receives it is architecture.

Further reading​