Getting a Redux Vibe Into SwiftUI | raywenderlich.com

Learn how to implement Redux concepts to manage the state of your SwiftUI app in a more predictable way by implementing a matching-pairs card game.


This is a companion discussion topic for the original entry at https://www.raywenderlich.com/22096649-getting-a-redux-vibe-into-swiftui

Thanks Andrew and team for the fun tutorial.

I’m interested in hearing how you would use this pattern with views that require bindings, for example Picker's init(selection: Binding<SelectionValue>, content: () -> Content, label: () -> Label)

I am trying to use Redux as you’ve shown in a small app, but I haven’t found a workable way to pass part of my store’s state as a binding and capture the selection change made by the Picker.

Edit:
Thought a little harder after posting:

struct PickerWrapperView: View {
	@EnvironmentObject var store: AppStore
	
	var body: some View {
		let selectionBinding = Binding(
			get: {self.store.state.selectedEntry},
			set: {
				self.store.dispatch(.selectionChanged($0.id))
			}
		)
		Picker("pick", selection: selectionBinding) {
			ForEach(store.state.possibleEntries, id: \.self) { entry in
				Text(entry.name)
			}
		}
	}
}

Using a custom binding seems to be working, but I would be happy to hear if there was some improvement to be made.

Firstly be careful of bending your code to fit the pattern, sometimes it’s not ideal. The Redux also describes a similar philosophy:

This was why I described it more as a vibe than a hard rule :smiley:

I have found different approaches for this elsewhere:

Hope that helps?

First of all, thanks for the awesome tutorial! I found a small bug in your final project and I can’t figure out how to (properly) fix it. Here’s how to reproduce it:

  1. Flip a card
  2. Flip a non-matching card
  3. Start spamming taps on a 3rd card while the other two are still flipped and keep going until they are unflipped.

Observed: as soon as the two cards are unflipped, the third card will flip briefly then unflip.

It seems that every attempt to flip a 3rd card before the other 2 are unflipped triggers a ThreeDucksAction.unFlipSelectedCards that piles up on the sync queue of the Store. Some of these unFlipSelectedCards are then executed AFTER the third card has been flipped.

Here’s a log:

REDUCER: Trying to flip 3rd card
MIDDLEWARE: 2 flipped cards → Unflip
STORE: dispatching: unflipCards
REDUCER: Unflipped cards
STORE: dispatching: flip
REDUCER: Flipped cards
MIDDLEWARE: 1 flipped cards → Do nothing
STORE: dispatching: flip
MIDDLEWARE: 1 flipped cards → Do nothing
:warning: STORE: dispatching: unflipCards
:warning: REDUCER: Unflipped cards
:warning: STORE: dispatching: unflipCards
:warning: REDUCER: Unflipped cards
:warning: STORE: dispatching: unflipCards
:warning: REDUCER: Unflipped cards

I’ve managed to prevent this by only unflipping cards if there are exactly 2 flipped cards, but this seems like a hacky fix rather than one that addresses the concurrency issue. Any ideas for a better solution?

Hi @pduemlein,

Firstly, I don’t think your solution is hacky at all! In fact the reducer makes a similar check, that there are less than 2 selected cards, before flipping the selected card.

Other than using the reducer logic to handle this, the other end of the transaction are the taps.

In the example the reducer tries to be as rigid as possible handling actions serially and each one operating on the state as produced by the previous action.

As you’ve noticed touches will always queue up an action:

.onTapGesture {
  store.dispatch(.flipCard(card.id))
}

What you’ve uncovered could be thought of as a denial of service attack!

I’d probably start there, experimenting with how to throttle the taps. There’s a throttle operator in Combine that could potentially come in handy.

I’ll have a look too and I’ll let you know if I find a good solution for SwiftUI

Hi again @pduemlein ! I have played around a bit and this is what I came up with.

Made a new class:

class TapDebounce: ObservableObject {
  var signal: AnyPublisher<UUID, Never>
  private var uuidSubject = PassthroughSubject<UUID, Never>()

  init() {
    signal = uuidSubject
      .removeDuplicates()
      .eraseToAnyPublisher()
  }

  func send(_ uuid: UUID) {
    uuidSubject.send(uuid)
  }
}

Added a new property to the CardGridView:

@StateObject var debouncer = TapDebounce()

Changed the tap handler to:

.onTapGesture {
  debouncer.send(card.id)
}

Added this modifier to the LazyVGrid:

.onReceive(debouncer.signal, perform: {
  store.dispatch(.flipCard($0))
})

So the tap just sends the card id to the debouncer, and the debouncer removes duplicates from the stream of IDs it receives. The view watches the debouncer’s signal and sends the action.

What do you think?

Thanks for looking into this! Very interesting solution. I just have one question: wouldn’t the .removeDuplicates() operator prevent consecutive taps on the same card even when they are intended to work? For example:

debouncer.send(1) Flip 1st card
debouncer.send(2) Flip 2nd card

  • wait for cards to be unflipped *

debouncer.send(2) Flip 2nd card again

I think in this scenario the debouncer would not emit because the UUID is the same as the previous value emitted, but it should.

@pduemlein Ah yes you’re right. OK we can introduce some more data to our debouncer. The selected card count varies between 0 and 2 so we could use that in our debouncer, and that would guard against it.

class TapDebounce: ObservableObject {
  private struct Model: Equatable {
    let uuid: UUID
    let count: Int
  }

  var signal: AnyPublisher<UUID, Never>
  private var uuidSubject = PassthroughSubject<Model, Never>()

  init() {
    signal = uuidSubject
      .removeDuplicates()
      .map { $0.uuid }
      .eraseToAnyPublisher()
  }

  func send(_ uuid: UUID, selectedCount: Int) {
    uuidSubject.send(Model(uuid: uuid, count: selectedCount))
  }
}

This only changes the calling site to:

.onTapGesture {
  debouncer.send(card.id, selectedCount: store.state.selectedCards.count)
}

This is really cool! Is there any open source redux library built up on Combine and SwiftUI?

There’s a few on GitHub that I found, although I haven’t used any of them:

No doubt there are even more I haven’t found :smiley:

Thanks for this great tutorial!!! My appreciation that there were no external libraries. It was easy for understanding.
I have found another bug. When there are already selected matched cards on the next step you can select the flipped card. The route cause is in Reducer:

case .flipCard(let id):
  // 1
  guard mutatingState.selectedCards.count < 2 else {
    break
  }
  // 2
  guard !mutatingState.selectedCards.contains(where: { $0.id == id }) else {
    break
  }

It can be fixed by validating cards not selectedCards on selection.

case .flipCard(let id):
      // 1
      guard mutatingState.selectedCards.count < 2 else {
        break
      }
      // 2
      guard !(mutatingState.cards.first(where: { $0.id == id })?.isFlipped ?? false) else {
        break
      }

Thanks For the great tutorial. It’s a very simple and clean way to implement Redux in the iOS projects!

However, I have one concern about putting the middleware logic within queue.sync.
Middleware handles side effects asynchronously. Execute it inside a synchronous closure seems may cause deadlock sometimes.

Do you think it would be better to put the middlewares execution just under queue.sync?

func dispatch(_ action: Action) {
  queue.sync {
    self.dispatch(self.state, action)
  }

  middlewares.forEach { middleware in
    let publisher = middleware(self.state, action)
      publisher
        .receive(on: DispatchQueue.main)
        .sink(receiveValue: dispatch)
        .store(in: &subscriptions)
  }
}

Nice one @sinnet3 , thanks!

It’s been fine for me in other projects. When you use the Combine operator .receive(on: DispatchQueue.main) it’s performing a DispatchQueue.main.async { } call for you.

Nice tutorial! Everything worked well, except the GameScreenView did not show when I tapped on New Game the first time. I had to Give Up and tap it again to see it, but it was all okay when I tapped Go Again when I won. What’s causing this?

But how exactly run async operation (for example, network request) using this approach? In .sync context it actually freezes whole interface, in .async approach I cannot got error in private func dispatch(_ currentState: State, _ action: Action) at last row of function that state can be only updated from main thread. I’m dead stuck and got no idea how to fix this