---
title: "Attachments — React"
description: "List the files a PDF carries, save them, let people attach and remove files, and go to a file pinned to a page."
framework: "React"
source: "https://www.embedpdf.com/docs/headless/react/documents/attachments"
---

# Attachments

A PDF can carry other files: a spreadsheet with the numbers behind a report, the original of a
scanned contract. They sit in two places: the document's own attachments, and file attachment
annotations, a file pinned to a spot on a page. Register `attachmentPlugin()` to list both as one
list:

**`attachment-list.tsx`**

```tsx
import { displayNameOf, useAttachment, useAttachmentState } from '@embedpdf/react/attachment';
import type { AttachedFile } from '@embedpdf/react/attachment';
import { saveFile } from '@embedpdf/react/runtime';

export function AttachmentList() {
  const attachment = useAttachment();
  const { files, canDownload } = useAttachmentState();

  const save = async (file: AttachedFile) => {
    const { bytes, name, mimeType } = await attachment.download(file);
    saveFile(bytes, name, mimeType ?? undefined);
  };

  return (
    <ul>
      {files.map((file) => (
        <li key={file.id}>
          <bdi>{displayNameOf(file)}</bdi>
          <button disabled={!canDownload} onClick={() => save(file)}>
            Save
          </button>
        </li>
      ))}
    </ul>
  );
}
```

What `useAttachmentState()` gives you updates by itself: after your own changes, and on a shared
document, after someone else's. Nothing is read when a document opens: the attachments load the
first time something asks for them. The files on the pages come from the
[annotation plugin](https://www.embedpdf.com/docs/headless/react/annotations), when you've registered it.

## Show the files

`files` lists the document's attachments first, in the order the document keeps them (by name),
then the files on its pages, in page order. Each file has its `name`, `mimeType`, `description`,
`size`, `checksum`, `createdAt` and `modifiedAt` (`null` when the PDF doesn't say), an `id` for
your list's keys, and its `home`:

- `{ kind: 'document', ref }`: one of the document's attachments, `ref` its name in the document.
- `{ kind: 'page', ref }`: a file attachment annotation; `ref.page` is its page.

Show a name with `displayNameOf(file)`, and put it in a `<bdi>`. A name can hide characters that
make it look like another (`invoice.pdf` that is really `invoice.exe`); `displayNameOf` takes them
out, and the `<bdi>` keeps a name in another script from turning your row around.

`status` says whether the attachments haven't been asked for yet (`'idle'`), are loading, are
there (`'ready'`), or couldn't be read.

## Save a file

`download(file)` returns the file's bytes, its name ready to save under (`displayNameOf`'s) and
its type. Hand them to `saveFile()` to save them as a browser download:

```ts
const { bytes, name, mimeType } = await attachment.download(file);
saveFile(bytes, name, mimeType ?? undefined);
```

Save a file; never open it in a new tab or a frame of your page. A file opened there runs with your
page's origin, so an HTML attachment could act as your page.

Saving takes a file out of the document, so it needs `doc.download`: check `canDownload`. A file
too large to unpack in the browser is refused (see [Large files](#large-files)).

## Attach files

`add(files)` attaches files as one change: one step to undo. A `File` the user picked brings its
name and type; bytes need a `name`:

```ts
await attachment.add([{ data: picked }, { data: bytes, name: 'figures.csv' }]);
```

Names are unique in a document: a name it has already gets a number, `notes (2).txt`, so nothing
is replaced. The files show at once, `isPending(file)` while the engine saves them, and `add()`
resolves with them. Check `canAdd` first.

## Remove a file

`remove(file)` takes a file away, as one step to undo: an undo brings back the same file, with its
bytes and dates. A file on a page goes with its annotation, through the annotation plugin.
`canRemove(file)` says whether this one may go.

**`files.tsx`**

```tsx
import { Viewer, DocumentGate, saveFile } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import {
  attachmentPlugin,
  displayNameOf,
  useAttachment,
  useAttachmentState,
} from '@embedpdf/react/attachment';
import type { AttachedFile } from '@embedpdf/react/attachment';
import { localEngine } from '@embedpdf/engine';

import './files.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), attachmentPlugin()];

const welcome = async (): Promise<OpenInput> => {
  const response = await fetch('/pdfs/welcome.pdf');
  return { kind: 'bytes', id: 'welcome', bytes: new Uint8Array(await response.arrayBuffer()) };
};

/** A file's size, as people read it. */
const sizeOf = (bytes: number | null) =>
  bytes === null ? '' : bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;

// The files the document carries: attach some, save one, remove one.
function Files() {
  const attachment = useAttachment();
  const { files, canAdd, canDownload } = useAttachmentState();

  const save = async (file: AttachedFile) => {
    const { bytes, name, mimeType } = await attachment.download(file);
    saveFile(bytes, name, mimeType ?? undefined);
  };

  return (
    <aside className="files">
      <header className="files-header">
        <h3 className="files-title">Files</h3>
        <label className="button primary" data-disabled={!canAdd || undefined}>
          Attach
          <input
            type="file"
            multiple
            hidden
            disabled={!canAdd}
            onChange={(event) => {
              const picked = Array.from(event.currentTarget.files ?? []);
              event.currentTarget.value = '';
              if (picked.length > 0) void attachment.add(picked.map((data) => ({ data })));
            }}
          />
        </label>
      </header>
      {files.length === 0 ? (
        <p className="empty">Attach a file to carry it inside the document.</p>
      ) : (
        <ul className="list">
          {files.map((file) => (
            <li key={file.id} className="row" data-pending={attachment.isPending(file) || undefined}>
              <button
                type="button"
                className="row-name"
                disabled={!canDownload}
                onClick={() => save(file)}
              >
                <bdi>{displayNameOf(file)}</bdi>
                <span className="row-size">{sizeOf(file.size)}</span>
              </button>
              <button
                type="button"
                className="row-remove"
                aria-label={`Remove ${displayNameOf(file)}`}
                disabled={!attachment.canRemove(file)}
                onClick={() => attachment.remove(file)}
              >
                ×
              </button>
            </li>
          ))}
        </ul>
      )}
    </aside>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: welcome }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <div className="layout">
          <Files />
          <Stage className="stage">{() => <RenderLayer />}</Stage>
        </div>
      </DocumentGate>
    </Viewer>
  );
}
```

**`files.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --line-strong: #dce8fc;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --accent: #3858e9;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The files beside the pages */
.layout {
  display: grid;
  grid-template-columns: 220px 1fr;
  gap: 14px;
}

.files {
  display: flex;
  flex-direction: column;
  height: 420px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--surface);
  box-shadow: 0 1px 2px var(--shadow);
  overflow: hidden;
}

.files-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 10px 10px 14px;
  border-bottom: 1px solid var(--line);
}

.files-title {
  margin: 0;
  font-size: 13px;
  font-weight: 650;
  color: var(--ink);
}

.empty {
  margin: 0;
  padding: 18px 14px;
  font-size: 12.5px;
  color: var(--muted);
}

/* One row per file: its name and size save it; a file still being added is faded */
.list {
  margin: 0;
  padding: 6px;
  list-style: none;
  overflow-y: auto;
}

.row {
  display: flex;
  align-items: center;
  border-radius: 8px;
}

.row:hover {
  background: var(--tint);
}

.row[data-pending] {
  opacity: 0.55;
}

.row-name {
  display: flex;
  flex: 1;
  flex-direction: column;
  min-width: 0;
  font: inherit;
  font-size: 13px;
  text-align: start;
  color: var(--ink);
  background: none;
  border: 0;
  padding: 6px 10px;
  cursor: pointer;
}

.row-name bdi {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.row-size {
  font-size: 11.5px;
  color: var(--muted);
}

.row-remove {
  font: inherit;
  font-size: 16px;
  line-height: 1;
  color: var(--muted);
  background: none;
  border: 0;
  border-radius: 6px;
  padding: 4px 8px;
  cursor: pointer;
  opacity: 0;
}

.row:hover .row-remove,
.row-remove:focus-visible {
  opacity: 1;
}

.row-remove:hover {
  color: var(--ink);
  background: var(--line);
}

.button {
  font: inherit;
  font-size: 12px;
  font-weight: 600;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 6px 12px;
  cursor: pointer;
  box-shadow: 0 1px 2px var(--shadow);
}

.button.primary {
  color: #ffffff;
  background: var(--accent);
  border-color: var(--accent);
}

.button:focus-within,
.row-name:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.button[data-disabled],
.row-name:disabled,
.row-remove:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

/* The pages */
.stage {
  height: 420px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

## Go to a page's file

`reveal(file)` selects a page's file attachment annotation and brings it into view, through the
[stage](https://www.embedpdf.com/docs/headless/react/viewing/stage) when you've registered it. It does nothing for one of the
document's attachments, which isn't on a page.

## Select files

`select(files)` makes files the selection, and `selected` lists them. The selection is the
reader's: show it, and act on it with your own buttons and menus.

```ts
attachment.select([file]);
await attachment.remove(selected[0]);
```

## Methods

Everything `useAttachment()` gives you.

| Method            | What it does                                                                          |
| :---------------- | :------------------------------------------------------------------------------------ |
| `get(id)`         | One file by its `id`, or `null`.                                                      |
| `isPending(file)` | Whether a change to it waits for the engine. [Attach files](#attach-files)            |
| `canRemove(file)` | Whether this file may go. [Remove a file](#remove-a-file)                             |
| `select(files)`   | Makes these files the selection; `[]` clears it. [Select files](#select-files)        |
| `add(files)`      | Attaches files as one change. Resolves with the new files.                            |
| `remove(file)`    | Takes a file away, as one change.                                                     |
| `download(file)`  | Resolves `{ bytes, name, mimeType }`, ready to save. [Save a file](#save-a-file)      |
| `reveal(file)`    | Goes to a page's file and selects it. [Go to a page's file](#go-to-a-pages-file)      |
| `ensureLoaded()`  | Reads the attachments, unless they're loaded or loading. Resolves once they're there. |
| `refresh()`       | Reads the attachments again. Rarely needed: they stay up to date by themselves.       |

Every method that returns a promise also takes a `signal` in its options, to cancel it.

## State

Everything `useAttachmentState()` gives you. Your component re-renders when a value changes. The method in the last column reads the same value once.

| Field         | What it is                                                                                      | Read once       |
| :------------ | :---------------------------------------------------------------------------------------------- | :-------------- |
| `files`       | Every file, the document's then the pages', with your changes shown before the engine answers   | `list()`        |
| `selected`    | The selected files, in the order they were selected                                             | `getSelected()` |
| `status`      | `'idle'` until something asks for them, then `'loading'`, `'ready'`, `'forbidden'` or `'error'` | `getStatus()`   |
| `canAdd`      | Whether attaching and removing files is allowed                                                 | `canAdd()`      |
| `canDownload` | Whether saving files is allowed                                                                 | `canDownload()` |

## Events

**`attachment-changed.tsx`**

```tsx
import { useState } from 'react';
import { useAttachmentEvent } from '@embedpdf/react/attachment';

export function AttachmentNotice() {
  const [notice, setNotice] = useState('');

  useAttachmentEvent(
    (attachment) => attachment.onChanged,
    ({ type, files, origin }) => {
      if (origin.kind === 'remote') setNotice(`Someone ${type} ${files.length} file(s)`);
    },
  );

  return <p role="status">{notice}</p>;
}
```

| Event        | Fires when                                                                                                                           | Carries                   |
| :----------- | :----------------------------------------------------------------------------------------------------------------------------------- | :------------------------ |
| `onChanged`  | The document's attachments were added, removed or brought back by an undo, by you or by someone else                                 | `type`, `files`, `origin` |
| `onResynced` | The attachments were read from the file: the first time they're asked for, after `refresh()`, or to catch up after a lost connection | `files`                   |

`type` is `'added'`, `'removed'` or `'restored'`; for a removal, `files` are the files as they
were. A file on a page is an annotation: its changes are the annotation plugin's events.

## Permissions

| Permission               | What it allows                                  | Check                      |
| :----------------------- | :---------------------------------------------- | :------------------------- |
| `doc.open`               | Listing the files                               | none: it opened            |
| `doc.download`           | Saving a file: it leaves the document           | `attachment.canDownload()` |
| `doc.attachments.modify` | Attaching files and removing the document's own | `attachment.canAdd()`      |

Removing a file on a page is deleting its annotation, so the annotation's own permission decides:
`canRemove(file)` asks it.

## Good to know

### Large files

The browser holds a file it saves in memory, so the local engine refuses a file that unpacks to
more than 256 MB. `download()` rejects with `invalid-input`, and the error's `details.limit` names
the limit. A file's own size can't be trusted for this: a small compressed file can unpack to
gigabytes.

### Removed files and saving

Removing a file takes it out of the document as it is now; an incremental save still contains the
version you opened. When a file must really be gone, save with `mode: 'rewrite'`. See
[Saving](https://www.embedpdf.com/docs/headless/react/documents/saving).

### The ready-made viewer

The viewer's Attachments panel is built on this plugin. See
[Panels](https://www.embedpdf.com/docs/viewer/react/customize/panels).
