Skip to content

Redux ‐ How to Implement

FilippoZazzeroni edited this page Nov 8, 2024 · 16 revisions

How to add a new Redux component

Introduction

So you want to add a Redux action and are unsure how to go aboot it. Well, you've come to the right place, if the right place is filled with terrible jokes. For a more comprehensive discussion of Redux in our application, feast your eyes on this bad boy of a writeup. This page will only be concerned with the nitty gritty details of how to become the greatest Redux-master the world has ever known... for your particular feature.

Process Overview

To add a new Redux component, you'll need to do the following things:

  1. Create an action
  2. Create a state
  3. Create a middleware (this is optional, depending on what you're implementing)
  4. Add the required connections to turn your component into a fully operational battleship

Below, you'll find templates for the work that needs doing.

A note on mental models re: states

Generally, you can think of features as things that come on screen at some point that the user sees and interacts with. But, lore-wise, a Redux component doesn't need to be a thing that's visually presented to a user. Redux is kinda like the Force, invisible, but surrounding and binding everything together. You can see the Force when Jedi does a Force Jump, for example; but you might not see it when they're Force meditating. Features are kind of like Force powers. Some of them visible, some of them operating behind the scenes. But, at some point, you need to register your feature with the Redux system for it to work. For the rest of these examples, we're gonna assume that your feature is a screen that is presented to the user to make things easier to understand.

Actions

The action is basically the thing that you will dispatch to tell whomever's listening for that action that it's been... well, ya know, actioned. Actions can have properties if you need to pass things along. This one will be a simple action, with an associated action type. We create separate action classes and action types simply to keep things neatly organized. They could be part of a single giant class and enum, but that would be unwieldy, like some of the ridiculously oversized swords you find in video-games.

final class ExampleAction: Action {
    override init(windowUUID: WindowUUID, actionType: any ActionType) {
        super.init(windowUUID: windowUUID, actionType: actionType)
    }
}

enum ExampleActionType: ActionType {
    case anExampleActionForExample
}

Please note that in this simple example, we only have one type of action and action type. Depending on your feature and its requirements, it may make sense to break down all the actions needed into more specific categories for thinking through the Redux flow, and have a user action, a middleware action, and their associated action types.

States

You'll need to create a state which is meant to be updatable as things are happening in your app. To do this, you'll have to conform to the ScreenState protocol and implement the correct initializers, and the corresponding reducer for your state. The reducer is basically the thing that's like, "Hay girl hay!!! I hear what you're saying, and I know exactly what to do." And then it does that thing and returns the new state.

By conforming to ScreenState, the defaultState(from state:) method must be implemented. This method provides a copy of the current state, resetting any transient data to their default values. Transient data includes any state properties that have default values in the initializer and should be restored when a default action is performed.

Ensure this method is used in any case where a specific action is not present, such as in else cases or default cases in switch statements.

import Redux

struct ExampleState: ScreenState, Equatable {
    var windowUUID: WindowUUID
    var exampleProperty: Bool
    var exampleTransientProperty: Bool

    init(appState: AppState, uuid: WindowUUID) {
        guard let exampleState = store.state.screenState(
            ExampleState.self,
            for: .exampleFeature,
            window: uuid
        ) else {
            self.init(windowUUID: uuid)
            return
        }

        self.init(
            windowUUID: exampleState.windowUUID,
            exampleProperty: exampleState.exampleProperty
        )
    }

    init(windowUUID: WindowUUID,
         exampleProperty: Bool,
         exampleTransientProperty: Bool = false) {
        self.windowUUID = windowUUID
        self.exampleProperty = exampleProperty
        self.exampleTransientProperty = exampleTransientProperty
    }

    static let reducer: Reducer<Self> = { state, action in
        guard action.windowUUID == .unavailable || action.windowUUID == state.windowUUID else { return defaultState(from: state) }

        switch action.actionType {
        case ExampleActionType.anExmapleActionForExample:
            return ExampleState(
                windowUUID: state.windowUUID,
                exampleProperty: true,
                exampleTransientProperty: true
            )
        default:
            return defaultState(from: state)
        }
    }

   static func defaultState(from state: ExampleState) -> ExampleState {
        // exampleTransientProperty is not passed here since it will be restored to default value
        return ExampleState(
                windowUUID: state.windowUUID,
                exampleProperty: state.exampleProperty
        )
   }
}

Connections

So you've create a state. Heck yes! But the app doesn't know what to do with it. So, like a cute little mushroom in the forest, you must reach out with your code tendrils and make the connections that will make everything work.

The View Controller

As mentioned before, your feature need not be a screen. But you have to register to redux somewhere. As a screen, you'll conform to StoreSubscriber

import Redux

class ExampleViewController: UIViewController, StoreSubscriber {
    typealias SubscriberStateType = ExampleState

    private var exampleState: ExampleState

    init() {
        subscribeToRedux()
    }

    deinit {
        unsubscribeFromRedux()
    }

    // MARK: - Redux
    func subscribeToRedux() {
        store.dispatch(
            ScreenAction(
                windowUUID: windowUUID,
                actionType: ScreenActionType.showScreen,
                screen: .exampleFeature
            )
        )

        let uuid = windowUUID
        store.subscribe(self, transform: {
            return $0.select({ appState in
                return ExampleState(appState: appState, uuid: uuid)
            })
        })
    }

    func unsubscribeFromRedux() {
        store.dispatch(
            ScreenAction(
                windowUUID: windowUUID,
                actionType: ScreenActionType.closeScreen,
                screen: .exampleFeature
            )
        )
    }

    func newState(state: ExampleState) {
        exampleState = state
        // do stuff with your new state here
    }

    private func exampleFunctionThatWillDispatchTheExampleActionForThisExample() {
        store.dispatch(
            ExampleAction(
                windowUUID: windowUUID,
                actionType: ExampleActionType.anExampleActionForExample
            )
        )
    }
}

AppScreen

Add your feature to the AppScreen enum so that Redux can identify your feature. Basically, this is your license plate, and you don't wanna drive without one or... ok, so this metaphor broke, because your car will totally work without a licence plate. But Redux won't. So just add your feature here.

enum AppScreen {
    ....
    case exampleFeature
    ....
}

ActiveScreenState

You'll have to add your feature to the AppScreenState enums.

enum AppScreenState: Equatable {
    ...
    case exampleFeature(ExampleState)
    ...

    static let reducer: Reducer<Self> = { state, action in
        switch state {
        ...
        case .exampleFeature(let state):
            return .exampleFeature(ExampleState.reducer(state, action))
        ...
        
        }
    }

    var associatedAppScreen: AppScreen {
        switch self {
        ...
        case .exampleFeature: return .exampleFeature
        ...
        }
    }

    var windowUUID: WindowUUID? {
        switch self {
        ...
        case .exampleFeature(let state): return state.windowUUID
        ...
        }
    }
}

ActiveScreenStates

You'll also have to add your feature to ActiveScreenStatess updateActiveScreens function so it knows what to return.

    private static func updateActiveScreens(action: Action, screens: [AppScreenState]) -> [AppScreenState] {
      
        switch action.actionType {
        ...
        case ScreenActionType.showScreen:
            let uuid = action.windowUUID
            switch action.screen {
            ...
            case .exampleFeature:
                screens.append(.exampleFeature(ExampleState(windowUUID: uuid)))
            ...
        default:
            return screens
        }

        return screens
    }
}

AppState

You'll also need to add your feature in the screenState's return in the AppState file.

    func screenState<S: ScreenState>(_ s: S.Type,
                                     for screen: AppScreen,
                                     window: WindowUUID?) -> S? {
        return activeScreens.screens
            .compactMap {
                switch ($0, screen) {
                ...
                case (.exampleFeature(let state), .exampleFeature): return state as? S
                ...
                }

Middlewares (Optional)

Ah the middleware. Not like Middle Earth, but, equally likeable because it can do fancy things like interacting with the outside world to have side-effects. To implement a middleware, you'll declare it in the following way.

You'll also want to give the middleware a provider. This is basically like a reducer. But the middleware thinks it's fancy, so it calls it another name. Programming!

import Redux

final class ExampleMiddleware {
    private let logger: Logger
    private let telemetry = ExampleTelemetry() // you might think of passing this in as a dependency for more programming brownie points

    init(logger: Logger = DefaultLogger.shared) {
        self.logger = logger
    }

    lazy var exampleProvider: Middleware<AppState> = { state, action in
        switch action.actionType {
        case ExampleActionType.anExampleActionForExample:
            self.telemetry.exampleActionActioned()
        default:
            break
        }
    }
}

Middleware Connections

Once more in the AppState file, you'll need to come down and add your middleware to the middlewares portion of the store.

let store = Store(state: AppState(),
                  reducer: AppState.reducer,
                  middlewares: [
                    ...,
                    ExampleMiddleware().exampleProvider,
                    ...
                  ]

And now, your journey is complete. If you enjoyed the ride, please tap the like button with grace and humility and only consider the subscribe button if you're willing to continue your journey of self reflection and growth as a developer... and maybe, even, as a human being.

Clone this wiki locally