All writing

Taming a Complex Document Feed with ReSwift

How unidirectional data flow made filtering, metadata updates, caching, and networking predictable

Preface: The Familiar Data-Flow Failure

Many list screens begin with an innocent sequence: fetch JSON, decode it, filter the result, and hand an array to the view. The design becomes fragile when the payload grows and several features begin to treat it as their own source of truth.

A typical failure pattern looks like this:

  • A networking layer returns one large, deeply nested payload.
  • A repository caches one version while a view model keeps another.
  • Filtering and sorting create partially transformed copies.
  • Pagination appends to whichever copy is currently available.
  • A metadata update searches through nested arrays and mutates an item in place.
  • Cache and network callbacks arrive in an order the screen did not expect.

Each operation is locally reasonable. Together, they create a system in which nobody can answer a basic question: which value is authoritative right now?

I encountered this while developing a document feed. The payload contained folders, documents, owners, metadata, paging information, and presentation hints. It was passed through multiple layers and repeatedly transformed with filter, map, and ad hoc mutation. The resulting bugs appeared unrelated: a metadata field disappeared after refresh, an item could be opened from one list state but not another, and an older response sometimes overwrote a newer one.

The root cause was not JSON decoding. It was uncontrolled state ownership.

This article explains how I introduced ReSwift to make that data flow explicit, then redesigned feed loading, caching, filtering, and metadata updates around a single state transition pipeline.

Why Local Fixes Were Not Enough

The original flow effectively allowed every layer to both read and write the feed:

API response
  -> repository copy
  -> view-model copy
  -> filtered array
  -> sectioned array
  -> cell model

Cache restore ---------------------> any point above
Metadata callback ----------------> any point above
Pagination response --------------> any point above

Adding locks would have protected individual mutations, but not their meaning. Copying more aggressively would have reduced accidental aliasing, but increased the number of competing snapshots. Moving all code into one view model would have produced one very large object with the same ordering problems.

The missing property was direction. A state change needed one entry point, one deterministic transformation, and one observable result.

Why ReSwift Fit the Problem

ReSwift brings the core Redux model to Swift:

  • The Store owns the current application state.
  • An Action describes an event or completed operation.
  • A Reducer transforms the previous state and an action into the next state.
  • Middleware handles side effects around dispatch, including networking, caching, logging, and analytics.
  • Subscribers observe only the state they need to render.

The important constraint is unidirectional flow:

User or system event -> Action -> Middleware -> Reducer -> New State -> Subscriber

Views no longer mutate feed objects. Network callbacks no longer reach into view models. Cache restoration does not silently replace an array. Every change becomes an action that travels through the same pipeline.

ReSwift did not remove complexity. It gave the complexity an address.

Do Not Put the Raw JSON in the Store

Introducing a store without changing the data model would only centralize the original problem. A deeply nested JSON-shaped state is still difficult to update safely.

I decoded the payload once at the boundary and normalized it into two parts:

  1. Canonical entities indexed by stable identifiers.
  2. Ordered identifiers describing the current feed and its visible projection.
struct Document: Equatable {
    let id: String
    var title: String
    var ownerID: String
    var metadata: Metadata
}

struct FeedState: StateType, Equatable {
    var documentsByID: [String: Document] = [:]
    var orderedIDs: [String] = []
    var query = FeedQuery()
    var pagination = PaginationState()
    var request = RequestState.idle
    var activeRequestID: UUID?
    var cacheRevision: Int = 0
}

Updating metadata now targets one dictionary entry instead of searching through every section and transformed array. Filtering does not delete data from the canonical collection; it produces a list of visible IDs.

func visibleDocumentIDs(in state: FeedState) -> [String] {
    state.orderedIDs.filter { id in
        guard let document = state.documentsByID[id] else { return false }
        return state.query.matches(document)
    }
}

This distinction became the foundation of the architecture:

  • State stores facts.
  • Selectors derive views of those facts.
  • Reducers are the only place that changes the facts.

Designing Actions Around Events

Actions should describe what happened, not instruct the store to perform arbitrary mutations.

struct FeedRequested: Action {
    let requestID: UUID
    let reason: LoadReason
}

struct CachedFeedLoaded: Action {
    let snapshot: FeedSnapshot
}

struct RemoteFeedLoaded: Action {
    let requestID: UUID
    let page: FeedPage
}

struct FeedRequestFailed: Action {
    let requestID: UUID
    let error: FeedError
}

struct QueryChanged: Action {
    let query: FeedQuery
}

struct DocumentMetadataChanged: Action {
    let documentID: String
    let metadata: Metadata
}

The request identifier is important. It allows the reducer to reject stale responses without relying on callback timing.

Reducers as the Consistency Boundary

The reducer contains the rules for accepting and merging data. It is deliberately free of networking, disk access, clocks, and callbacks.

func feedReducer(action: Action, state: FeedState?) -> FeedState {
    var state = state ?? FeedState()

    switch action {
    case let action as FeedRequested:
        state.activeRequestID = action.requestID
        state.request = .loading

    case let action as CachedFeedLoaded:
        guard state.documentsByID.isEmpty else { break }
        state.merge(action.snapshot)
        state.cacheRevision = action.snapshot.revision

    case let action as RemoteFeedLoaded:
        guard action.requestID == state.activeRequestID else { break }
        state.merge(action.page)
        state.request = .loaded

    case let action as FeedRequestFailed:
        guard action.requestID == state.activeRequestID else { break }
        state.request = .failed(action.error)

    case let action as QueryChanged:
        state.query = action.query

    case let action as DocumentMetadataChanged:
        state.documentsByID[action.documentID]?.metadata = action.metadata

    default:
        break
    }

    return state
}

Several previously implicit decisions are now visible:

  • Cached data can hydrate an empty feed but cannot overwrite fresher state.
  • Only the active request may update loading status or merge a response.
  • Filtering changes the query, not the underlying document collection.
  • Metadata updates use a stable identifier and preserve every unrelated field.

These rules are easy to review because they live together and execute synchronously.

Middleware Owns Networking and Cache Effects

Reducers answer, “given this event, what is the next state?” Middleware answers, “which external work should this event start?”

The feed middleware received its dependencies rather than reaching global singletons:

func makeFeedMiddleware(
    api: FeedAPI,
    cache: FeedCache,
    makeRequestID: @escaping () -> UUID
) -> Middleware<FeedState> {
    { dispatch, getState in
        { next in
            { action in
                next(action)

                switch action {
                case is FeedScreenAppeared:
                    if let snapshot = cache.read() {
                        dispatch(CachedFeedLoaded(snapshot: snapshot))
                    }

                    let requestID = makeRequestID()
                    dispatch(FeedRequested(
                        requestID: requestID,
                        reason: .initial
                    ))

                    api.fetchFeed { result in
                        switch result {
                        case let .success(page):
                            dispatch(RemoteFeedLoaded(
                                requestID: requestID,
                                page: page
                            ))
                        case let .failure(error):
                            dispatch(FeedRequestFailed(
                                requestID: requestID,
                                error: error
                            ))
                        }
                    }

                case is RemoteFeedLoaded,
                     is DocumentMetadataChanged:
                    if let state = getState() {
                        cache.write(FeedSnapshot(state: state))
                    }

                default:
                    break
                }
            }
        }
    }
}

This middleware performs cache hydration and remote loading, then translates their results back into actions. It never mutates FeedState directly.

The architecture can be summarized as follows:

There is still asynchronous work, but asynchronous code cannot bypass the state machine.

Rebuilding the Feed Loading Sequence

The resulting load behavior was explicit:

  1. The screen dispatches FeedScreenAppeared.
  2. Middleware reads a cached snapshot and dispatches CachedFeedLoaded when available.
  3. Middleware generates a request ID and dispatches FeedRequested.
  4. The API result becomes either RemoteFeedLoaded or FeedRequestFailed.
  5. The reducer accepts the result only when its request ID is still active.
  6. Subscribers receive a new state and recompute the visible IDs.
  7. Middleware persists accepted state back to the cache.

Cache-first rendering remained fast, while the network still refreshed the source of truth. Most importantly, cache and network could no longer write to the UI through independent paths.

Metadata Updates Without Losing the Document

Metadata edits had been one of the most error-prone operations because a partial response was sometimes treated as a complete document. ReSwift made the distinction between replacement and patching explicit.

struct MetadataUpdateRequested: Action {
    let documentID: String
    let patch: MetadataPatch
}

struct MetadataUpdateSucceeded: Action {
    let documentID: String
    let metadata: Metadata
}

struct MetadataUpdateFailed: Action {
    let documentID: String
    let previousMetadata: Metadata
}

Middleware performs the request. The reducer may optimistically apply the patch and later replace it with the authoritative metadata, or roll back on failure. In every case, the document identity and unrelated fields remain intact.

This removed an entire class of bugs caused by rebuilding a document from incomplete metadata JSON.

Subscriptions Should Be Narrow

A single store does not mean every screen should observe the entire state. The document list subscribed to a derived view state containing only what it rendered.

struct FeedViewState: Equatable {
    let documents: [Document]
    let isLoading: Bool
    let canLoadMore: Bool
}

store.subscribe(self) { subscription in
    subscription.select { state in
        let ids = visibleDocumentIDs(in: state)
        return FeedViewState(
            documents: ids.compactMap { state.documentsByID[$0] },
            isLoading: state.request == .loading,
            canLoadMore: state.pagination.hasNextPage
        )
    }
}

With an Equatable view state, unchanged projections can be skipped. A metadata update for an off-screen item does not need to rebuild the visible list, and a query change does not alter canonical entities.

Testability Was a Design Outcome

The new structure separated deterministic policy from effects.

Reducer tests became small input-output assertions:

func testStaleResponseCannotReplaceNewerFeed() {
    let activeID = UUID()
    let staleID = UUID()
    var state = FeedState()
    state.activeRequestID = activeID

    let next = feedReducer(
        action: RemoteFeedLoaded(
            requestID: staleID,
            page: .fixture(title: "Stale")
        ),
        state: state
    )

    XCTAssertEqual(next, state)
}

Middleware tests injected a fake API, an in-memory cache, and a deterministic request-ID generator. An action recorder verified the sequence without constructing a view controller:

FeedScreenAppeared
CachedFeedLoaded
FeedRequested(id: 42)
RemoteFeedLoaded(id: 42)

The same setup covered failures, pagination, cache misses, and out-of-order responses. Testability did not come from adding mocks after the design; it came from moving effects behind injected boundaries and representing their results as values.

Tradeoffs and Scope

ReSwift introduces ceremony. Actions and reducers add types, and a poorly scoped root state can become another monolith. I avoided treating the framework as a reason to place every UI detail in one global store.

The store owned durable feed state and transitions shared across networking, cache, and presentation. Ephemeral concerns such as a cell highlight or an in-progress gesture remained local to the view. Reducers were split by state domain, while selectors kept the storage layout private from subscribers.

That boundary matters. Unidirectional data flow is valuable when several producers and consumers must agree on state. It is unnecessary overhead for state that is truly local and short-lived.

Takeaway

The most important result was not adopting a Redux-style framework. It was replacing implicit mutation with an explicit protocol:

Events are Actions.
Effects live in Middleware.
Rules live in Reducers.
Facts live in State.
Views consume Selectors.

Once the document feed had one canonical representation and one path for change, the strange bugs stopped being strange. Filtering no longer destroyed source data, metadata updates no longer replaced complete documents with partial payloads, stale requests could be rejected deterministically, and caching became a testable part of the data flow.

ReSwift supplied the structure, but the deeper fix was architectural: make ownership singular, make transitions explicit, and make every side effect return through the same door.