List attachments keep a file tied directly to a specific item — a screenshot on a bug, a resume on a candidate record — without setting up a separate document library. This covers uploading, reading, and deleting them with PnP.js inside a React-based SPFx component, plus batching when you’re handling more than one file at a time.
In this post: Uploading an attachment · Reading attachments · Deleting an attachment · Rendering the list and keeping it in sync · Batching multiple uploads · None of this handles a failure yet · When would you actually use this? · Related reading
Uploading an attachment
Current PnPjs (v3/v4) uses the spfi() factory rather than the older global sp singleton — if you’ve seen examples importing from @pnp/sp/presets/all, that’s the earlier v2 pattern:
import { spfi, SPFx } from "@pnp/sp";
import "@pnp/sp/webs";
import "@pnp/sp/lists";
import "@pnp/sp/items";
import "@pnp/sp/attachments";
import { useState } from "react";
const sp = spfi().using(SPFx(context)); // context passed in from the SPFx web part
const UploadAttachment = ({ listTitle, itemId }) => {
const [file, setFile] = useState(null);
const handleFileChange = (event) => {
setFile(event.target.files[0]);
};
const uploadFile = async () => {
if (file) {
const item = sp.web.lists.getByTitle(listTitle).items.getById(itemId);
await item.attachmentFiles.add(file.name, file);
alert("File uploaded successfully");
}
};
return (
<div>
<input type="file" onChange={handleFileChange} />
<button onClick={uploadFile}>Upload</button>
</div>
);
};
export default UploadAttachment;
Reading attachments
const getAttachments = async (listTitle, itemId) => {
const item = sp.web.lists.getByTitle(listTitle).items.getById(itemId);
const attachments = await item.attachmentFiles();
console.log(attachments); // array with FileName, ServerRelativeUrl, etc.
};
Deleting an attachment
const deleteAttachment = async (listTitle, itemId, fileName) => {
const item = sp.web.lists.getByTitle(listTitle).items.getById(itemId);
await item.attachmentFiles.getByName(fileName).delete();
alert("Attachment deleted");
};
Rendering the list and keeping it in sync
The read/delete examples above log to the console and alert — useful for confirming the calls work, not what an actual component needs. A real attachment list has to hold the results in state and refresh it after every upload or delete, or the UI silently drifts out of sync with what SharePoint actually has:
import { useState, useEffect, useCallback } from "react";
const AttachmentList = ({ listTitle, itemId }) => {
const [attachments, setAttachments] = useState([]);
const loadAttachments = useCallback(async () => {
const item = sp.web.lists.getByTitle(listTitle).items.getById(itemId);
const results = await item.attachmentFiles();
setAttachments(results);
}, [listTitle, itemId]);
useEffect(() => { loadAttachments(); }, [loadAttachments]);
const handleDelete = async (fileName) => {
const item = sp.web.lists.getByTitle(listTitle).items.getById(itemId);
await item.attachmentFiles.getByName(fileName).delete();
await loadAttachments(); // refetch rather than assuming the delete succeeded silently
};
return (
<ul>
{attachments.map((a) => (
<li key={a.FileName}>
<a href={a.ServerRelativeUrl} target="_blank" rel="noopener noreferrer">{a.FileName}</a>
<button onClick={() => handleDelete(a.FileName)}>Remove</button>
</li>
))}
</ul>
);
};
Re-fetching after the delete (rather than just filtering the deleted item out of local state) is the more defensive choice — it confirms what’s actually stored in SharePoint rather than trusting the delete call succeeded and assuming local state matches. The upload component earlier in this post can call the same loadAttachments function after a successful upload for the same reason.
Batching multiple uploads
Uploading several files one call at a time means one HTTP round trip per file. Batching combines them into fewer requests. The batching API also changed between PnPjs versions — current syntax uses sp.batched(), not the older createBatch()/.inBatch() pattern:
const batchUploadAttachments = async (listTitle, itemId, files) => {
const [batchedSP, execute] = sp.batched();
const item = batchedSP.web.lists.getByTitle(listTitle).items.getById(itemId);
files.forEach(file => {
item.attachmentFiles.add(file.name, file);
});
await execute();
alert("Batch upload completed");
};
Worth knowing before you build a UI around this: all of the above needs a live connection — there’s no local caching layer for attachment binary data the way there is for list item metadata, so this isn’t something you can make work offline.
None of this handles a failure yet
Worth being direct about a gap running through every component in this post: none of the await calls above are wrapped in a try/catch. A throttled request, an expired session, or a file that’s too large all reject the promise, and with nothing catching it, that’s an unhandled promise rejection — the user sees nothing happen, or a cryptic console error they’ll never see, instead of an actual message telling them what went wrong. alert() on success doesn’t help here either; it’s fine for a quick demo, but it blocks the UI thread and isn’t how a real component should surface state.
Wiring real error state into the upload component from earlier in this post:
const UploadAttachment = ({ listTitle, itemId, onUploaded }) => {
const [file, setFile] = useState(null);
const [status, setStatus] = useState("idle"); // idle | uploading | error
const [errorMessage, setErrorMessage] = useState("");
const handleFileChange = (event) => {
setFile(event.target.files[0]);
setStatus("idle");
};
const uploadFile = async () => {
if (!file) return;
setStatus("uploading");
try {
const item = sp.web.lists.getByTitle(listTitle).items.getById(itemId);
await item.attachmentFiles.add(file.name, file);
setStatus("idle");
setFile(null);
onUploaded?.(); // let the parent refresh its attachment list
} catch (error) {
setStatus("error");
setErrorMessage(error?.message ?? "Upload failed. Please try again.");
}
};
return (
<div>
<input type="file" onChange={handleFileChange} />
<button onClick={uploadFile} disabled={!file || status === "uploading"}>
{status === "uploading" ? "Uploading..." : "Upload"}
</button>
{status === "error" && <p role="alert">{errorMessage}</p>}
</div>
);
};
The same pattern — try/catch around the PnPjs call, a status/error state instead of a bare alert() — applies equally to the delete handler and the batch upload function earlier in this post. An onUploaded callback (or the same pattern for delete) is what actually connects this component to the loadAttachments refresh shown in the “Rendering the list” section above, so a successful upload updates the visible list without a manual page refresh.
The batching API changed shape between PnPjs versions along with everything else — sp.batched() replaced createBatch()/.inBatch(), not just the import style.
When would you actually use this?
- A custom SPFx form needs a file-upload control that isn’t SharePoint’s default attachment UI — a ticketing system, an HR intake form.
- Multiple files need to attach to one item at once — the batched upload avoids one round trip per file.
- You need to display or manage attachments inline in a React component rather than sending users to the list item’s default view.
Related reading
- Methods on How to Add Attachments to SharePoint List — the same attachment operations without the React/SPFx wrapper, including a PowerShell path for bulk work.
- Best Practices in Handling Email Attachments — what to do with a file before it ever reaches this upload code.
Hope this helps. Let me know in the comments if you run into issues.
App Catalog Authentication Automation Backup Compliance Content Type CSS Flows Google Javascript Limitations List Metadata MFA Microsoft Node NodeJs O365 OneDrive Permissions PnP PnPJS Policy PowerApps Power Automate PowerAutomate PowerPlatform PowerShell React ReactJs Rest API Rest Endpoint Security Send an HTTP Request to SharePoint SharePoint SharePoint List SharePoint Modern SharePoint Online SPFX SPO Sync Tags Teams Termstore Versioning


