All Articles
CategorySwiftUI
Reading Time
16 min read
Published
2026-06-19
Word Count
3,709words

Grab a coffee — this one is a deep dive!

SwiftUI Document API: ReadableDocument and WritableDocument

Summary

A sourced walk through iOS 27's SwiftUI Document API migration: FileDocument to ReadableDocument/WritableDocument, incremental Subprogress writes, DocumentCreationSource.

  • ReadableDocument and WritableDocument are the expanded Document API that replaces FileDocument and ReferenceFileDocument for iOS/macOS/visionOS 27
  • The new document model is reference-typed and @Observable; it isn't recreated on every change
  • DocumentWriter now supports incremental, asynchronous writing via a previous parameter and reports progress through Subprogress
  • DocumentCreationSource + NewDocumentButton let you define multiple new-document sources inside a single DocumentGroup
SwiftUI Document API: ReadableDocument and WritableDocument

SwiftUI's Document API got a fundamental update with iOS 27: Apple introduced two new protocols for document-based apps, ReadableDocument and WritableDocument. In this post I walk through the difference between these protocols and FileDocument/ReferenceFileDocument, the incremental/asynchronous write mechanism, and the multi-document creation flow with DocumentCreationSource, step by step, based on Apple's WWDC26 resources and an independent write-up that corroborates them.

💡 Pro Tip: If you're starting a project whose new deployment target is 27+, start directly with ReadableDocument/WritableDocument instead of FileDocument — migrating back later is far more expensive than starting there.

Table of Contents

Why the new protocols arrived

Apple's official WWDC26 guide describes the expanded SwiftUI Document API this way: "The expanded SwiftUI Document API gives you direct control over the structure of your saved documents. For reading and writing, conform to WritableDocument and ReadableDocument…" This sentence sums up the origin point of the new protocols: the old FileDocument/ReferenceFileDocument pair didn't give you fine-grained control over the document's structure on disk.

Apple's WWDC26 session 269 walkthrough directly backs up the same motivation: "First, the write method is nonisolated and asynchronous. This lets me perform expensive disk writing operations in the background, so the app stays responsive. I write only the parts of the package that actually need updating, by comparing the current and the previous snapshots." So the reason isn't one thing: disk-structure control, incremental writes, and non-blocking background I/O.

On top of that, the new Document type is now a reference type marked @Observable. In onmyway133's words: "The document is a reference type marked @Observable, so SwiftUI does not recreate it on every change."

The WWDC26 guide page doesn't quote FileDocument directly, but Apple's session 269 summary explicitly mentions this protocol: "Expanded document APIs for SwiftUI apps, including the new DocumentCreationSource API for custom new-document flows, performance improvements for reading and writing large documents, and first-class support for direct document URL access via the FileDocument and ReferenceFileDocument protocols." onmyway133's write-up summarizes this more sharply: "two new protocols replace FileDocument and ReferenceFileDocument for new code." So the new protocols are a pair that replaces the old ones for newly written code — a distinction that matters later (specifically, in the iOS 26 compatibility section).

Migrating FileDocument → ReadableDocument (a side-by-side example)

The StickerDocument definition below, based on the WWDC26 session's example, shows what conforming to WritableDocument looks like.

ReadableDocument is defined in the WWDC26 session like this: "ReadableDocument is a twin to WritableDocument. Here's how they compare. Each protocol requires a list of supported content types. WritableDocument provides a snapshot and ReadableDocument knows how to apply it." The same session clarifies disk access this way: "ReadableDocument's friend is DocumentReader, which does all the disk-related heavy lifting." So it works on a twin-protocol model, and applying snapshots read from disk back onto the document is handled through a DocumentReader that owns that job.

swift
1@Observable
2final class StickerDocument: WritableDocument {
3 static let writableContentTypes: [UTType] = [.stickerDocument]
4 
5 @MainActor
6 func snapshot(contentType: UTType) async throws -> sending PageSnapshot {
7 // encode current state into a PageSnapshot
8 }
9 
10 func writer(configuration: sending WriteConfiguration) -> sending Writer {
11 // return a Writer that knows how to persist a PageSnapshot
12 }
13}

The @MainActor marking on the definition above shows that taking a snapshot runs on the main thread; the nonisolated marking you'll see on the write side further down shows the opposite — that operation can run without being tied to any actor.

What the old FileDocument side used to look like

To complete this "side-by-side example" comparison, the same StickerDocument type's conformance to the old FileDocument protocol used to look like this — a struct that declares readableContentTypes, does reading in a single pass inside init(configuration:), and writing in a single pass inside fileWrapper(configuration:):

swift
1struct StickerDocument: FileDocument {
2 static var readableContentTypes: [UTType] { [.stickerDocument] }
3 
4 init(configuration: ReadConfiguration) throws {
5 // decode the data inside configuration.file (a FileWrapper) into the struct in one pass
6 }
7 
8 func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
9 // encode the struct's current state into a new FileWrapper in one pass
10 }
11}

Side by side with the WritableDocument example above, the difference is clear: the old side runs init(configuration:) and fileWrapper(configuration:) synchronously in one piece, while the new side prepares snapshot(contentType:) on @MainActor and pushes the actual disk write through a separate, nonisolated writer (shown further down).

The role of PageSnapshot and DocumentReader

In the WWDC26 session 269 example, the type returned by snapshot(contentType:) above is concretely defined as: struct PageSnapshot { var background: Image; var metadata: StickerPlacements; var stickers: [Image] }. So the snapshot freezes the document's state — background image, metadata with sticker positions, and the sticker images — into one value type; ReadableDocument's apply(snapshot:previous:) applies this PageSnapshot, read back from disk, onto the document object.

In the same session, StickerDocument's conformance to ReadableDocument is given as a separate extension: extension StickerDocument: ReadableDocument {}. This shows that WritableDocument and ReadableDocument can be defined as two separate conformance blocks on the same class — meaning reading and writing responsibilities can be separated in the code itself as well.

The real difference in a practical migration: FileDocument was a value type (struct), so every read produced a new copy; on ReadableDocument, you're applying a snapshot to a reference-type, @Observable document object instead. Migration isn't "change the protocol signature" — it's moving the document model from value to reference.

The most concrete consequence of this shift shows up in the SwiftUI view layer. onmyway133's statement — "so SwiftUI does not recreate it on every change, and a TextEditor bound to a document property does not lose its model on each keystroke" — shows this directly: once ReadableDocument moves to a reference type, the document object's identity is preserved even when the view redraws, so a TextEditor bound to the document property doesn't lose its model on every keystroke.

Dimension
FileDocument (old)
ReadableDocument (new)
Type nature
Value (struct)
Reference, @Observable
Read responsibility
In one pass, inside init(configuration:)
Applying a snapshot via DocumentReader
Recreation on change
New struct copy on every edit
Object identity preserved, not recreated
Content type declaration
static var readableContentTypes
readableContentTypes, reader(configuration:), apply(snapshot:previous:)

ReferenceFileDocument → WritableDocument

ReferenceFileDocument was already a reference type, so moving to WritableDocument is conceptually less of a leap than the ReadableDocument migration.

onmyway133's point brings clarity here: "You convert between your data and disk through a snapshot, using either the FileWrapperDocumentReader and FileWrapperDocumentWriter convenience types or a fully custom reader and writer for streaming or direct URL access." So on the WritableDocument side, the data conversion now happens through a snapshot, either via a concrete convenience type like FileWrapperDocumentWriter or through a fully custom reader/writer — conceptually corresponding to ReferenceFileDocument's snapshot(contentType:) + fileWrapper(snapshot:configuration:) pair, but with disk access now extended with streaming/direct URL support.

Feature
ReferenceFileDocument (old)
WritableDocument (new)
Type nature
Reference (class), ObservableObject
Reference, @Observable
Write helper type
fileWrapper(snapshot:configuration:)
FileWrapperDocumentWriter / DocumentWriter
Disk access
Single-shot FileWrapper production
Streaming or direct URL support
Progress reporting
Not specified
Subprogress parameter

A concrete Writer type

WWDC26 session 269 gives a concrete writer type that conforms to DocumentWriter like this: struct Writer<Snapshot>: DocumentWriter { typealias Snapshot = PageSnapshot; let contentType: UTType }. So Writer is a generic helper type that knows which PageSnapshot to write for which contentType; the WritableDocument.writer(configuration:) method returns an instance of this type every time it's called.

Exporting the same document as PNG too

In the same session, the content types Writer supports aren't limited to a single format. Around 14:35, .png is added to the writableContentTypes list; at 14:48, the writer branches on it like this: if contentType.conforms(to: .stickerDocument) { /* write the .stickerDocument package */ } else if contentType.conforms(to: .png) { /* write the flattened image for .png */ }. So the same Writer, by checking the contentType parameter, can produce either the document's own package format (.stickerDocument) or a flattened PNG output — this is the branch that kicks in for exporting the page visually.

Incremental and asynchronous writing

Computing the diff with the previous parameter

The signature in the WWDC26 session's code example shows directly how incremental writing works: DocumentWriter.write(snapshot:to:previous:progress:). The previous parameter here carries the prior state into the writer; this lets the writer write only the part that changed, not the whole document.

swift
1nonisolated func write(
2 snapshot: sending PageSnapshot,
3 to destination: URL,
4 previous: sending PageSnapshot?,
5 progress: consuming Subprogress
6) async throws {
7 // write .stickerDocument
8 // previous == nil → first save, full write
9 // previous != nil → only the diff is written (incremental write)
10}

Apple's session 269 walkthrough directly confirms this: the write compares current and previous snapshots and applies only to the package parts that actually need updating, running in the background without blocking the main thread — so incremental writing isn't just "write less data," it's also "don't keep the UI thread busy."

In practice: in a large document (say, a drawing file with dozens of pages), every Cmd+S now writes to disk only the parts that changed since the last operation, not the entire document.

Progress reporting with Subprogress

The progress: consuming Subprogress parameter you saw in the signature above is Foundation's progress-reporting API integrated directly into the SwiftUI Document layer. The WWDC26 guide summarizes it this way: "offer asynchronous, incremental disk operations and progress reporting via the Foundation Subprogress API." onmyway133 confirms the same point: "Packages can be read and written incrementally, and progress flows through Subprogress." — two independent sources agree here.

The consuming keyword shows that the Subprogress value is handed over (ownership transfer) to the write call — meaning the writer reports the progress of its own sub-operations back up to the caller through this single parameter. This means that when you want to show the user something like "42% saved" while saving a large document, you don't need to manually synchronize your own progress counters; the framework carries that for you.

What the consuming keyword means

swift
1// The result from the signature (verified in WWDC26): consuming Subprogress
2// "consuming" → the value is handed over to the write() call, ownership never returns.
3// Practical effect: you don't need to update progress by hand,
4// DocumentWriter reports its own sub-operations' progress through this parameter.

DocumentCreationSource and NewDocumentButton

The WWDC26 guide defines this feature in one clear sentence: "The DocumentCreationSource API lets you declare multiple creation sources with a NewDocumentButton for each." So instead of one "New Document" button, an app can now define multiple buttons, each for a different starting state (blank page, template, import from photo, and so on).

The literal call from the WWDC26 session's code example is this:

swift
1extension DocumentCreationSource {
2 static let blank = Self(id: "blank")
3 static let photo = Self(id: "photo")
4}
5 
6DocumentGroupLaunchScene("Create a Sticker Page") {
7 NewDocumentButton("New Sticker Page", source: .blank)
8 NewDocumentButton("Sticker Page from Photo…", source: .photo)
9}

Adding multiple sources to the scene

The pattern is clear: each source is a static DocumentCreationSource value initialized with an id string, and NewDocumentButton takes a title string plus this value. The buttons' layout in the scene is just as clear: multiple NewDocumentButtons are defined one after another inside DocumentGroupLaunchScene, each carrying its own source. In practice: a drawing app with two flows like "Blank Page" and "Start from Photo" means two separate NewDocumentButton lines, each with a different source value.

iCloud/Files integration pitfalls

There's a broadly applicable pitfall to keep in mind when thinking about iCloud or Files syncing, confirmed in onmyway133's write-up: "One thing to remember is that SwiftUI tracks unsaved changes through undo actions, so without registered undo actions it will not autosave." In other words, SwiftUI tracks unsaved changes through undo actions. If a change hasn't been registered on the undo stack, autosave isn't triggered.

Why autosave doesn't trigger without a registered undo action

The practical consequence of this is: if you mutate your document by directly assigning to an @Observable property (without registering an undo action), SwiftUI doesn't mark that change as "unsaved" and won't autosave it — the file stays in its old state on disk (and therefore in iCloud sync too). That's why every mutation needs to go through a registered action via UndoManager; otherwise the change appears to the user as if it's "lost," when in fact it was never written at all.

iOS 26 backward compatibility

Apple's canonical documentation data is clear: the new protocols ship only with iOS 27.0, iPadOS 27.0, Mac Catalyst 27.0, macOS 27.0, and visionOS 27.0 (per the platform records in readabledocument.json and writabledocument.json) — not backported to iOS 26. The old FileDocument and ReferenceFileDocument have existed since iOS 14.0 / macOS 11.0; as of publish date, a mixed deployment target (one also covering below 27) has to stay on these protocols, since the new ones don't run there.

The consequence is this: if your deployment target is 27+, onmyway133's write-up gives clear advice — "If you are starting a document app today and your deployment target is 27 or later, reach for these rather than the older protocols." With a mixed deployment target (iOS 26+27), staying on the old API isn't a preference, it's a requirement — the new protocols don't exist on iOS 26.

What to do with a mixed deployment target

This advice needs to be read together with the onmyway133-sourced "replace ... for new code" statement from the "Why the new protocols arrived" section above: the new protocols replace the old ones for newly written code, but since the old ones are still usable as of publish date, an app with a mixed deployment target can host both protocol families at once — one for the 27+ flow, the other for backward compatibility.

GOLDEN TIP

The most valuable insight in this article

This tip holds the article's most important takeaway.

Easter Egg

You found a hidden gem!

There's a hidden detail in this section. Want to uncover it?

Reader Reward

If you've read this far, you've earned an ordered checklist you can follow when moving to the new Document API. The items below gather the sourced pitfalls and steps from this post into a single sequence — check each one off before you start the migration.

FAQ

What replaces FileDocument now?

With iOS, macOS, and visionOS 27, Apple introduced the ReadableDocument and WritableDocument protocols for document-based SwiftUI apps. In onmyway133's words, these two protocols replace the old FileDocument/ReferenceFileDocument pair ("two new protocols replace FileDocument and ReferenceFileDocument for new code.") and are positioned as the recommended approach for new code.

What's the difference between ReadableDocument and WritableDocument?

ReadableDocument holds the list of supported content types and knows how to apply snapshots read from disk onto the document; this work is carried out via a DocumentReader. WritableDocument provides the list of writable formats and a snapshot mechanism that returns the current content, through a writer type that conforms to DocumentWriter — twin protocols, one carrying the reading responsibility, the other the saving responsibility.

How do I incrementally save a large file in SwiftUI?

WritableDocument's writer takes the prior state via previous in write(snapshot:to:previous:progress:) and writes only the changed part. The same call reports progress via progress: consuming Subprogress — so instead of rewriting large documents from scratch, you save them piece by piece, asynchronously.

How do you define multiple new-document sources in a DocumentGroup?

You define a DocumentCreationSource value for each different starting state and pass it to a NewDocumentButton("Title", source: source) call. The verified call from the WWDC26 code example looks like NewDocumentButton("New Sticker Page", source: .blank); you can reuse the same button type with different source values to offer multiple creation options inside a DocumentGroupLaunchScene.

What happens if I don't register an undo action?

Because SwiftUI tracks unsaved changes through the undo stack, autosave isn't triggered unless a mutation was made as an action registered with UndoManager. This can leave the document looking changed in the UI while staying in its old state on disk — which is why every edit needs to happen inside an undo-aware action.

Do I need to change my old FileDocument code right away?

Not in the short term. FileDocument and ReferenceFileDocument have been usable since iOS 14.0 / macOS 11.0. The new pair ships only with iOS/macOS/visionOS 27.0+, not backported to iOS 26. If your target also covers below 27, staying on the old API is required — the new protocols don't run there. See '## Update (September 2026)' below for a post-publish development on these protocols' status.

Update (September 2026)

After this post's publish date (June 19, 2026), a real change landed in Apple's canonical documentation data: FileDocument and ReferenceFileDocument were marked deprecated as of 27.2. The warning in FileDocument's records reads: "Conform your type to Document instead." ReferenceFileDocument's reads: "Use Document protocol instead." Both protocols had existed since iOS/iPadOS/Mac Catalyst 14.0, macOS 11.0; the deprecation only arrived with 27.2, and isn't a retroactive behavior change.

The Document protocol Apple points to inherits both ReadableDocument and WritableDocument: "Inherits From: ReadableDocument, WritableDocument", with the abstract "A document that supports both reading and writing." (iOS/macOS/visionOS 27.0). So the twin-protocol model this post describes hasn't changed; Document was added on top as a third protocol unifying the two under one name. Read this together with the earlier '## iOS 26 backward compatibility' section: if your deployment target also covers below 27, staying on the old protocols is still required — but it's now "deprecated, yet currently the only option," not "not deprecated."

Conclusion

ReadableDocument and WritableDocument are among the most concrete infrastructure updates SwiftUI has shipped for document-based apps: a reference-type, @Observable document model, incremental/asynchronous writing that reports progress via Subprogress, and multiple document-creation sources via DocumentCreationSource. But this doesn't have to be a "big bang" migration: the new protocols replace the old ones for newly written code, but FileDocument/ReferenceFileDocument are still required for deployment targets that cover below iOS 27; migrating only becomes a choice once your deployment target is 27+ (see the Update section above).

To refresh SwiftUI's state management before moving your document model to a reference type, see my deep dive into SwiftUI property wrappers. Planning a document-based macOS app? The guide to desktop development with SwiftUI on macOS is a natural next step. Curious about other iOS 27 breaking changes? See the post on what broke in the State macro and ContentBuilder. If you're splitting your document model into components, the custom component library guide and, for large-document performance, the performance optimization post are useful references too.

Sources

Tags

#SwiftUI#iOS 27#Document API#FileDocument#WWDC26#Subprogress
Muhittin Çamdalı

Muhittin Çamdalı

Lead Mobile Engineer

Lead Mobile Engineer with 12+ years of experience. Expert in iOS, Android and cross-platform architectures with Swift, SwiftUI, Kotlin and Flutter. I build performant, user-friendly mobile apps.

iOS Development News

Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.

We respect your privacy. You can unsubscribe at any time.

Share