Handling Temporary / Private state with Reduce Method

I am struggling to understand how to handle very temporary state. For a very simple example, lets say you have a view that displays a list of favorite Numbers. Your appState would look something like this

struct AppState {
  var favorateNumbers: [Int]
}

Now lets say there is a button on this view that displays a list of all numbers (or a finite list) and the app allows you to pick as many as you want to add to your favorites. But to finalize your selections, there is a save button. This model would look something like this

  var allNumbers: [Int]
  var selectedNumbers: [Int]
}

enum NumbersAction {
  case select(Int)
  case save
}

let reducer: Reducer<NumbersState, NumbersAction, Void> = Reducer { state, action, _ in 
  switch action {
  case .select(let number):
    state.selectedNumbers.append(number)
   return .none

   case .save:
   ////Here is the problem
}

The only way currently to implement the save case is to have AppState hold on to the temporary selected numbers.

struct AppState {
  var favorateNumbers: [Int]
  var temporarySelectedNumbers: [Int]
}

This may be fine for very small apps, but I work on a large app, where this scenario is produced multiple times. This 'temporary' state will litter my State structs.

Does anyone have an idea on how to remove this type of temporary state? My thought is to add a reduce function higher-order Reducer.

extension Reducer {
    
    public static func reduce<PrivateState>(
        _ privateState: PrivateState,
        reducer: @escaping (inout PrivateState, inout State, Action, Environment) -> (PrivateState, Effect<Action, Never>)
    ) -> Reducer {
        var privateState = privateState
        return Reducer { state, action, environment in
            let (newState, effect) = reducer(&privateState, &state, action, environment)
            defer { privateState = newState }
            return effect
        }
    }
}

I am not a big fan of the name, I think it could be confusing, but I believe this reducer would allow one to remove very temporary state from their architecture

struct AppState {
  var favorateNumbers: [Int]
}

enum Action {
  case add(Int)
  case save
}

let reducer = Reducer.reduce([Int](), reducer: { selected, state, action, _ in 
  var temp = selected
  switch action {
                        
  case .add(let number):
     temp.append(number)
  case .save:
     state.numbers = temp
  }
  return (temp, .none)
})
1 Like

We feel that temporary state should be handled much like how navigation is handled. For example, if your application has 4 screens in a sequence of navigations (Screen A -> Screen B -> Screen C -> Screen D), then it is not necessarily true that you have to hold all of the state for all 4 screens in your AppState at once. In fact, it probably makes more sense to have a kind of linked-list of optional state:

struct ScreenAState {
  ...
  var screenB: ScreenBState?
}

struct ScreenBState {
  ...
  var screenC: ScreenCState?
}

struct ScreenCState {
  ...
  var screenD: ScreenDState?
}

struct ScreenDState {
  ...
}

And then when a particular piece of navigation flips from nil to non-nil it triggers a navigation to that screen.

We would apply this same idea to your situation. There is a bit of subtle state in your application that is not properly represented. The state of whether or not the user is in the selection state could be modeled with the following enum:

struct SelectionState {
  case noneSelected
  case selected([Int])
}

(this is equivalent to simply an optional [Int]?, but you may like the more explicitly named enum and cases better)

Then you can think of the UI changing from the unselected mode to the selected mode when you first tap on a number, and as you tap on more numbers it appends to the selected array. And once you "commit" the changes you will just copy the .selected array over to your favoriteNumbers and reset the selection state to .noneSelected. Further, if you have a "Clear" button you could simply reset the selection state to .noneSelected.

And modeling this state properly, and giving your view access to it, you only set yourself up better to properly render the UI, it may make future features even easier to develop, and it means you get to test all of that logic.

In general we think the idea of "private local" state is appealing, but it's not really necessary, and it may be better to just explicitly model the state. And just as the 4 screen navigation doesn't really cause us to hold a bunch of state in memory at once, the same is true for all of these little "temporary" pieces of state, since they are essentially just optionals that are nil when not in use.

With that said, I will also say that Stephen and I have also explored higher-order reducers to support this "private local" state idea, and although we haven't gotten too far with it, if you are getting use out of it I'd say use it! It definitely looks interesting :grinning:

2 Likes

You could give each number a property isSelected. On save you just iterate through all numbers and add the selected ones to the favorites.

But this is probably not the solution you want.

I think we discussed this on Twitter some time ago :smiley:

The "problem" with @mbrandonw solution comes when you need to mix that local state with some global state. Meaning screen A needs to pass some state to Screen B and back and so on. And I use quotes on problem because this is probably only about ergonomics.

Although I have a working solution I'm not really happy with it and I still struggle with this. I wish there was a way in the architecture to handle these cases.

struct AppState: Equatable {
    var favoriteNumbers: [Int]

    var _numbersState: NumbersState?
    var numbersState: NumbersState? {
        get {
            if _numbersState == nil {
                return nil
            }

            var copy = _numbersState!
            copy.favoriteNumbers = favoriteNumbers
            return copy
        }
        set {
            _numbersState = newValue
            if newValue != nil {
                favoriteNumbers = newValue!.favoriteNumbers
            }
        }
    }
}

struct NumbersState: Equatable {
    var favoriteNumbers: [Int]
    internal var allNumbers: [Int] = []
    internal var selectedNumbers: [Int] = []
}

One approach is to model your app's state as you might for a state machine. For example

struct AppState: Equatable {
	var screen: Screen
}

enum Screen {
	case screenA(ScreenA)
	case screenB(ScreenB)
}

struct ScreenA {

}

struct ScreenB {

}

With this model you only store values that are required for the current screen. You can extend this approach to an arbitrarily deep tree, with structs and enums introduced to model components and widgets within the screens. This allows you to scale your app to any size without ever being overwhelmed by the information.

To return to your number selection example you might look something like

struct ScreenA {
	selectionState: SelectionState

	enum SelectionState {
	case selecting(allNumbers: [Int], selected: [Int])
	case selected(favouriteNumbers: [Int])
	}
}

Regarding the mixing of global local state the approach I'd take with Elm (where most of my experience is with this architecture) is to model the reducer functions like:

struct ScreenA {
	public var reduce: (_ sharedState: inout GlobalState, _ state: inout ScreenAState, _ action: Action, _ environment: Environment) ->  Effect<Action, Never> {

	}
}

You could use this today without using a pullback by doing something like

public let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, _ in
	switch state.screen {
		.screenA(var screenA): 
			effect = ScreenA.reduce(&state.sharedState, &screenA)
			state.screen = .screenA(screenA)
			return effect
	}
}

I haven't given much thought to ergonomics but the information flow should hopefully be clear (in particular it's unfortunate that inplace mutation doesn't work with enums).

The thing about mixing local with global state looks nice and clean, but it only solves half of the problem: You can access the global state from the local reducer now, but not from the local view.

What happens if ScreenA needs some of the shared state to represent itself? Do you then pass it some other way outside of the architecture?

I think ideally the shared state would be part of the local state so it can be used transparently.

EDIT: Never mind, I just realised you can still have a local property that can be set in that new reduce method... Let me try that.