Introducing Processed - Automated Loading States in SwitUI

Hey,

I've built a small, lightweight Swift package that helps you manage loading states in SwiftUI. It's called Processed.

It's a wrapper around two simple enums that represent loading states for either a loadable resource or a generic process:

// When you need to represent the loading state of some data
public enum LoadableState<Value> {
  case absent
  case loading
  case error(Error)
  case loaded(Value)
}

// When you need to represent the state of some generic process, i.e. saving, deleting, ...
public enum ProcessState<ProcessID> {
  case idle
  case running(ProcessID)
  case failed(process: ProcessID, error: Swift.Error)
  case finished(ProcessID)
}

First, these enums come with a lot of convenience methods and setters to make working with them easy and increase code clarity and readability.

However, the main thing that this package adds is two property wrappers around those enums that automate the state handling and Task management, because loading some data for a view almost always requires the same, identical steps:

  • Cancel any previous loading tasks
  • Create a new task and store it somewhere for cancellation
  • Set loading state to .loading
  • Load the data
  • Set the loading state to either .loaded(value) or .error(error)

This is a lot of boilerplate that clutters up the code, in my opinion. That's why I built Processed:

@Loadable

The @Loadable property wrapper exposes the above mentioned LoadableState<Value> via its wrappedValue property, so that you can use it (and switch over it) just like any other enum property.

Additionally, via its projectedValue, you can access some methods to start a new resource loading process, for example $numbers.load { ... } (see below for a code example).

The body of this closure is asynchronous and throwing. @Loadable will internally cancel any previous tasks, create and store a new Task, set the state to .loading (unless you want to load the data silently) and wait for the closure to finish. If a value is returned, the state will then be set to .loaded(value) and if an error was thrown, the state will be set to .error(error). This all happens automatically, because it's almost always the same behavior.

struct DemoView: View {
  @Loadable<[Int]> var numbers

  @MainActor func loadNumbers() {
    $numbers.load {
      try await Task.sleep(for: .seconds(2))
      return [0, 1, 2, 42, 73]
    }
  }
  
  var body: some View {
    List {
      Button("Reload") { loadNumbers() }
      .disabled(numbers.isLoading)
      switch numbers {
      case .absent: 
        EmptyView()
      case .loading: 
        ProgressView()
      case .error(let error): 
        Text("\(error.localizedDescription)")
      case .loaded(let numbers):
        ForEach(numbers, id: \.self) { number in
          Text(String(number))
        }
      }
    }
  }
}

@Loadable supports:

  • Automatic state and task handling
  • Option to control the states fully manually, if needed
  • Proper Task cancellation support (state will not change anymore, when Task was cancelled, to prevent data races)
  • Cancellation from within the load { ... } closure through throw CancelLoadable()
  • $numbers.cancel() and $numbers.reset() methods to cancel and reset that Task/state
  • All load { ... } methods have an async overload, so when you call them from an asynchronous context, it will not create a new internally managed Task, but instead simply use the current asynchronous context, so you can await the results of the load call and manage the parent Task by yourself.
  • There is a load { ... } overload that, instead of waiting for a single return value, lets you yield multiple values over time, which is really useful for long running observations through an AsyncStream, for example.

@Process

The @Process property wrapper is similar to @Loadable, but it's semantically optimized for generic processes that don't return a value, but simply perform some work that needs to be tracked, like a save or delete action. It wraps around the ProcessState<ProcessID> enum.

Instead of a load method, its projectedValue exposes a run method (and more):

struct DemoView: View {
  @Process var saving

  @MainActor func save() {
    $saving.run {
      try await save()
    }
  }
  
  var body: some View {
    List {
      Button("Save") { save() }
      .disabled(numbers.isRunning)
      switch saving {
      case .idle: 
        Text("Idle")
      case .running: 
        Text("Saving")
      case .failed(_, let error): 
        Text("\(error.localizedDescription)")
      case .finished:
        Text("Finished Saving")
      }
    }
  }
}

@Process supports:

  • Automatic state and task handling
  • Option to control multiple kinds of processes via a ProcessID type: @Loadable<ProcessKind> and then, for example, myProcess.run(.save) { ... } or myProcess.run(.delete) { ... }
  • Option to control the states fully manually, if needed
  • Proper Task cancellation support (state will not change anymore, when Task was cancelled, to prevent data races)
  • Cancellation from within the run { ... } closure through throw CancelProcess()
  • $saving.cancel() and $saving.reset() methods to cancel and reset that Task/state
  • All run { ... } methods have an async overload, so when you call them from an asynchronous context, it will not create a new internally managed Task, but instead simply use the current asynchronous context, so you can await the results of the run { ... } call and manage the parent Task by yourself.

LoadableState and ProcessState in classes

If you prefer to keep your state in a view model, or if you would like to use Processed completely outside of SwiftUI, you can also do all the things from above inside a class. However, it works slightly differently because of the nature of SwiftUI property wrappers (they hold @State properties inside, which don't work outside the SwiftUI environment).

You simply have to conform your class to the LoadableSupport and/or the ProcessSupport protocol that implements the same load, run, cancel and reset methods as the @Loadable and @Process property wrappers, but this time defined on self.

For example, for LoadableState, you can do something like this:

@MainActor final class ViewModel: ObservableObject, LoadableSupport {
  // Define the LoadableState enum as a normal @Published property
  @Published var numbers: LoadableState<[Int]> = .absent

  func loadNumbers() {
    // Call the load method from the LoadableSupport protocol
    load(\.numbers) {
      try await Task.sleep(for: .seconds(2))
      return [0, 1, 2, 42, 73]
    }
  }
  
  // Alternatively, use the yielding overload to stream values over time
  func loadStreamedNumbers() {
    load(\.numbers) { yield in
      var numbers: [Int] = []
      for await number in [0, 1, 2, 42, 73].publisher.values {
        try await Task.sleep(for: .seconds(1))
        numbers.append(number)
        yield(.loaded(numbers))
      }
    }
  }
}

For more information on this, see the README.

What do you think about this? It has helped me quite a bit already and reduced a lot of repetitive boilerplate in my projects.

10 Likes

I have just released version 2.0.0 of Processed, which adds (among other things) "interrupts".

Interrupts are optional tasks that run in parallel to a loading process and trigger at specified times for you to set intermediate states or perform additional work.

This makes it also really easy to incorporate timeouts into loading processes.

For example:

@Loadable<[Int]> var numbers
@State var showLoadingDelayed = false

// ...

@MainActor func loadWithTimeout() {
 $numbers.load(interrupts: [.seconds(2), .seconds(3)]) { // Interrupt after 2 seconds and then again after 3 more seconds
  try await Task.sleep(for: .seconds(10)) // Simulate long running loading process
  return [42]
 } onInterrupt: { accumulatedDelay in
  switch accumulatedDelay {
  case .seconds(5):
   throw TimeoutError() // Time out the loading process
  default:
   showLoadingDelayed = true // Show a message like "it's taking longer than expected"
  }
 }
}

There are also new examples in the overhauled demo project that showcase these interrupts.

To see the full list of changes and new features:

1 Like