Last updated 2 min read

State-Driven Side Effects

Related: Software Engineering, iOS App Development


/content/images/2024/09/4.png
4.png

State-driven side effects implementation allows for the elimination of both middleware and sync actions.

Let's start with a SwiftUI example of a simple state with a single boolean value isLoading and a simple spinner indicator view for it with a button.

struct ContentView: View { 
    @State private var isLoading = 0.5
     
    var body: some View {   
        VStack { 
            ProgressView // visible when `isLoading` is true.  
                .opacity(isLoading ? 1.0 : 0.0)
		
            Button("Toggle") {
                isLoading.toggle()
            } 
        }
}

In SwiftUI the View is a function of state meaning that it's completely defined by the state.

We can imagine that the spinner is actually doing some heavy work, like spinning really hard. The work that the spinner is performing is controlled by the state: isLoading flag which may turn the spinner on/off or restart it.

From that perspective, UI can be considered a side effect as an interface to the outer world.

The idea of State-Driven side effects suggests that all side effects that the app produces can be implemented as a function of state, just like declarative UI. In that case at any point in time, the amount of async work that our app produces is completely defined by its state. It can be restated, canceled, or delayed.

There is only need to be a diffing mechanism that will compare the work which is expected to be performed with the actual work and will start/restart/cancel it when needed

Here is an example from Puredux documentation:

// Add effect state to the state
struct AppState {
   private(set) var theJob: Effect.State = .idle()
}

// Add related actions
enum Action { 
	case jobSuccess(SomeResult) 
	case startJob 
	case cancelJob 
	case jobFailure(Error) 
}

// Handle actions in the reducer
extension AppState {
    mutating func reduce(_ action: Action) {
        switch action {
        case .jobSuccess:
            theJob.succeed()
        case .startJob:
            theJob.run()
        case .cancelJob:
            theJob.cancel()
        case .jobFailure(let error):
            theJob.retryOrFailWith(error)
        }
    }
}

// Add SideEffect to the store in a declarative way
let store = StateStore<AppState, Action>(AppState()) { state, action in 
    state.reduce(action) 
}
.effect(\.theJob, on: .main) { appState, dispatch in
    Effect {
        do {
            let result = try await apiService.fetch()
            dispatch(.jobSuccess(result))
        } catch {
            dispatch(.jobFailure(error))
        }
    }
}


store.dispatch(.startJob)