Why MVI?

Introducing MVI

Introducing MVI

State

UI

Action

(ViewModel/Presenter)

(Fragment/ViewController)

User

Intent

Model

View

Reducer

State

Data source

Intent

Transformer

View

Model

Reducer

State

Data source

Intent

Transformer

View

Model

Side effect

Key concepts

  • Unidirectional cycle of data
  • Non-blocking
  • Immutable state

MVI - benefits

MVI - benefits

  • Simplicity
  • Well defined contract
  • Easy unit testing

MVI - benefits

  • Screenshot testing
  • Interaction testing
  • Time-travel debugging

Testing apps

Use cases & repos

Backend

MVI

UI

Testing apps

Use cases & repos

Backend

MVI

UI

Testing apps

Use cases & repos

Backend

MVI

UI

(c) Martin Fowler

Testing apps

Use cases & repos

Backend

MVI

UI

Screenshot

Unit

Interaction

Contract / Integration

Testing apps

Screenshot E2E UI
Airbnb ~ 30 000  none
Uber thousands handful
Spotify ~ 1 600 ~ 500
Shopify ~ 2 300 ~ 20

"Building Mobile Apps at scale - 39 Engineering Challenges"

State

Screenshot testing

State

Screenshot testing

Screenshot testing

Screenshot testing

Screenshot testing

State

Interaction testing

Interaction testing

Interaction testing

Interaction testing

"button_close": {
  "view": "ImageView",
  "click": [
    {
      "onExitRequested": {
        "params": [
            createCircleRequestDomain: "{circleName="Test",coverPhoto="file://something.jpg"}"
        ]
      }
    }
  ]
}

Interaction testing

"button_close": {
  "view": "ImageView",
  "click": [
    "onExitRequested": {
      "params": [
          createCircleRequestDomain: "{circleName="Test",coverPhoto="file://something.jpg"}"
      ]
    }
  ]
}

Time travel

Reducer

State

Data source

Intent

Transformer

View

Model

Time travel

State

Intent

Time travel

t

MVP to MVI

MVP to MVI

  • Unidirectional cycle of data
  • Non-blocking
  • Immutable state

MVP to MVI

  1. Create an MVI framework
  2. Translate MVP contract to MVI      
  1. Create an MVI framework

MVP to MVI

  1. Create an MVI framework
  2. Translate MVP contract to MVI
  3. Move processing to background
  4. Translate view interactions to MVI
  5. Refactor view
  1. Create an MVI framework

MVI framework

Reducer

State

Data source

Intent

Transformer

View

Model

Side effect

Reducer

Transformer

Side effect

State

public class MviContainer<STATE, SIDE_EFFECT> {

    STATE getState();

    void intent(Intent<STATE, SIDE_EFFECT> intent);

    void reduce(Reducer<STATE> reducer);

    void postSideEffect(SIDE_EFFECT sideEffect);

    Subscription subscribeToState(
        Output<STATE> stateListener
    );

    Subscription subscribeToSideEffects(
        Output<SIDE_EFFECT> sideEffectListener
    );
}

Reducer

Transformer

Side effect

State

public class MviContainer<STATE, SIDE_EFFECT> {

    STATE getState();

    void intent(Intent<STATE, SIDE_EFFECT> intent);

    void reduce(Reducer<STATE> reducer);

    void postSideEffect(SIDE_EFFECT sideEffect);

    Subscription subscribeToState(
        Output<STATE> stateListener
    );

    Subscription subscribeToSideEffects(
        Output<SIDE_EFFECT> sideEffectListener
    );
}

Reducer

Transformer

Side effect

State

public class MviContainer<STATE, SIDE_EFFECT> {

    STATE getState();

    void intent(Intent<STATE, SIDE_EFFECT> intent);

    void reduce(Reducer<STATE> reducer);

    void postSideEffect(SIDE_EFFECT sideEffect);

    Subscription subscribeToState(
        Output<STATE> stateListener
    );

    Subscription subscribeToSideEffects(
        Output<SIDE_EFFECT> sideEffectListener
    );
}

Reducer

Transformer

Side effect

State

public class MviContainer<STATE, SIDE_EFFECT> {

    STATE getState();

    void intent(Intent<STATE, SIDE_EFFECT> intent);

    void reduce(Reducer<STATE> reducer);

    void postSideEffect(SIDE_EFFECT sideEffect);

    Subscription subscribeToState(
        Output<STATE> stateListener
    );

    Subscription subscribeToSideEffects(
        Output<SIDE_EFFECT> sideEffectListener
    );
}

Reducer

Transformer

Side effect

State

public class MviContainer<STATE, SIDE_EFFECT> {

    STATE getState();

    void intent(Intent<STATE, SIDE_EFFECT> intent);

    void reduce(Reducer<STATE> reducer);

    void postSideEffect(SIDE_EFFECT sideEffect);

    Subscription subscribeToState(
        Output<STATE> stateListener
    );

    Subscription subscribeToSideEffects(
        Output<SIDE_EFFECT> sideEffectListener
    );
}

Reducer

Transformer

Side effect

State

public class MviContainer<STATE, SIDE_EFFECT> {

    STATE getState();

    void intent(Intent<STATE, SIDE_EFFECT> intent);

    void reduce(Reducer<STATE> reducer);

    void postSideEffect(SIDE_EFFECT sideEffect);

    Subscription subscribeToState(
        Output<STATE> stateListener
    );

    Subscription subscribeToSideEffects(
        Output<SIDE_EFFECT> sideEffectListener
    );
}

Reducer

Transformer

Side effect

State

public abstract class MviContainerHost<STATE, SIDE_EFFECT> {

    private final MviContainer<STATE, SIDE_EFFECT> container;

    public MviContainerHost(STATE initialState) {
        this.container = new MviContainer<>(initialState);
    }

    protected void intent(Intent<STATE, SIDE_EFFECT> intent) {
        container.intent(intent);
    }

    public MviContainer<STATE, SIDE_EFFECT> getContainer() {
        return container;
    }
}
public void onDeleteCircleConfirmed() {
    intent(mvi -> {
        mvi.reduce(currentState -> currentState.withCommittingChanges(true));
        
        final String circleId = mvi.getState().editedCircle().get().id();

        circlesRepository.deleteCircle(circleId)
                .subscribe(
                        result -> mvi.postSideEffect(CreateOrEditSideEffect.Simple.OPEN_AGGREGATED_FEED),
                        exception -> {
                            mvi.reduce(currentState -> currentState.withCommittingChanges(false));
                            mvi.postSideEffect(CreateOrEditSideEffect.Simple.SHOW_ERROR);
                        }
                );
    });
}

Reducer

Transformer

Side effect

State

public void onDeleteCircleConfirmed() {
    intent(mvi -> {
        mvi.reduce(currentState -> currentState.withCommittingChanges(true));
        
        final String circleId = mvi.getState().editedCircle().get().id();

        circlesRepository.deleteCircle(circleId)
                .subscribe(
                        result -> mvi.postSideEffect(CreateOrEditSideEffect.Simple.OPEN_AGGREGATED_FEED),
                        exception -> {
                            mvi.reduce(currentState -> currentState.withCommittingChanges(false));
                            mvi.postSideEffect(CreateOrEditSideEffect.Simple.SHOW_ERROR);
                        }
                );
    });
}

Reducer

Transformer

Side effect

State

public void onDeleteCircleConfirmed() {
    intent(mvi -> {
        mvi.reduce(currentState -> currentState.withCommittingChanges(true));
        
        final String circleId = mvi.getState().editedCircle().get().id();

        circlesRepository.deleteCircle(circleId)
                .subscribe(
                        result -> mvi.postSideEffect(CreateOrEditSideEffect.Simple.OPEN_AGGREGATED_FEED),
                        exception -> {
                            mvi.reduce(currentState -> currentState.withCommittingChanges(false));
                            mvi.postSideEffect(CreateOrEditSideEffect.Simple.SHOW_ERROR);
                        }
                );
    });
}

Reducer

Transformer

Side effect

State

public void onDeleteCircleConfirmed() {
    intent(mvi -> {
        mvi.reduce(currentState -> currentState.withCommittingChanges(true));
        
        final String circleId = mvi.getState().editedCircle().get().id();

        circlesRepository.deleteCircle(circleId)
                .subscribe(
                        result -> mvi.postSideEffect(CreateOrEditSideEffect.Simple.OPEN_AGGREGATED_FEED),
                        exception -> {
                            mvi.reduce(currentState -> currentState.withCommittingChanges(false));
                            mvi.postSideEffect(CreateOrEditSideEffect.Simple.SHOW_ERROR);
                        }
                );
    });
}

Reducer

Transformer

Side effect

State

public void onDeleteCircleConfirmed() {
    intent(mvi -> {
        mvi.reduce(currentState -> currentState.withCommittingChanges(true));
        
        final String circleId = mvi.getState().editedCircle().get().id();

        circlesRepository.deleteCircle(circleId)
                .subscribe(
                        result -> mvi.postSideEffect(CreateOrEditSideEffect.Simple.OPEN_AGGREGATED_FEED),
                        exception -> {
                            mvi.reduce(currentState -> currentState.withCommittingChanges(false));
                            mvi.postSideEffect(CreateOrEditSideEffect.Simple.SHOW_ERROR);
                        }
                );
    });
}

Reducer

Transformer

Side effect

State

public void onDeleteCircleConfirmed() {
    intent(mvi -> {
        mvi.reduce(currentState -> currentState.withCommittingChanges(true));
        
        final String circleId = mvi.getState().editedCircle().get().id();

        circlesRepository.deleteCircle(circleId)
                .subscribe(
                        result -> mvi.postSideEffect(CreateOrEditSideEffect.Simple.OPEN_AGGREGATED_FEED),
                        exception -> {
                            mvi.reduce(currentState -> currentState.withCommittingChanges(false));
                            mvi.postSideEffect(CreateOrEditSideEffect.Simple.SHOW_ERROR);
                        }
                );
    });
}

Reducer

Transformer

Side effect

State

public void onDeleteCircleConfirmed() {
    intent(mvi -> {
        mvi.reduce(currentState -> currentState.withCommittingChanges(true));
        
        final String circleId = mvi.getState().editedCircle().get().id();

        circlesRepository.deleteCircle(circleId)
                .subscribe(
                        result -> mvi.postSideEffect(CreateOrEditSideEffect.Simple.OPEN_AGGREGATED_FEED),
                        exception -> {
                            mvi.reduce(currentState -> currentState.withCommittingChanges(false));
                            mvi.postSideEffect(CreateOrEditSideEffect.Simple.SHOW_ERROR);
                        }
                );
    });
}

Reducer

Transformer

Side effect

State

Step1 - translate contract

State

Side effect

public interface CreateOrEditCircleContract {

    interface View extends BaseView {

        void showCreateSpinner();
        void showEditSpinner();
        void redirectToCircleFeedScreen(final String circleId);
        void redirectToCircleInviteScreen(final String circleId);
        void showCreateCircleErrorDialog();
        void showEditCircleErrorDialog();
        void showInitEditErrorDialog();
        void enableCTA();
        void disableCTA();
        void exitWithCreateConfirmationDialog();
        void exitWithEditConfirmationDialog();
        void exitWithoutConfirmationDialog();
        void exitToAggregatedFeed();
        void showDeleteConfirmationDialog();
        void setEditableCircleData(CircleConfigModel result);
    }

    abstract class Presenter extends SimplePresenter<View> {
        ...
    }

}

OR

public interface CreateOrEditCircleContract {

    interface View extends BaseView {

        void showCreateSpinner();
        void showEditSpinner();
        void redirectToCircleFeedScreen(final String circleId);
        void redirectToCircleInviteScreen(final String circleId);
        void showCreateCircleErrorDialog();
        void showEditCircleErrorDialog();
        void showInitEditErrorDialog();
        void enableCTA();
        void disableCTA();
        void exitWithCreateConfirmationDialog();
        void exitWithEditConfirmationDialog();
        void exitWithoutConfirmationDialog();
        void exitToAggregatedFeed();
        void showDeleteConfirmationDialog();
        void setEditableCircleData(CircleConfigModel result);
    }

    abstract class Presenter extends SimplePresenter<View> {
        ...
    }

}
@AutoValue
public abstract class CreateOrEditState {

    public abstract Mode mode();

    public enum Mode {
        CREATE,
        EDIT;
    }
}
public interface CreateOrEditCircleContract {

    interface View extends BaseView {

        void showCreateSpinner();
        void showEditSpinner();
        void redirectToCircleFeedScreen(final String circleId);
        void redirectToCircleInviteScreen(final String circleId);
        void showCreateCircleErrorDialog();
        void showEditCircleErrorDialog();
        void showInitEditErrorDialog();
        void enableCTA();
        void disableCTA();
        void exitWithCreateConfirmationDialog();
        void exitWithEditConfirmationDialog();
        void exitWithoutConfirmationDialog();
        void exitToAggregatedFeed();
        void showDeleteConfirmationDialog();
        void setEditableCircleData(CircleConfigModel result);
    }

    abstract class Presenter extends SimplePresenter<View> {
        ...
    }

}
@AutoValue
public abstract class CreateOrEditState {

    public abstract Mode mode();
    
    public abstract boolean committingChanges();

    public enum Mode {
        CREATE,
        EDIT;
    }
}
public interface CreateOrEditCircleContract {

    interface View extends BaseView {

        void redirectToCircleFeedScreen(final String circleId);
        void redirectToCircleInviteScreen(final String circleId);
        void showCreateCircleErrorDialog();
        void showEditCircleErrorDialog();
        void showInitEditErrorDialog();
        void enableCTA();
        void disableCTA();
        void exitWithCreateConfirmationDialog();
        void exitWithEditConfirmationDialog();
        void exitWithoutConfirmationDialog();
        void exitToAggregatedFeed();
        void showDeleteConfirmationDialog();
        void setEditableCircleData(CircleConfigModel result);
    }

    abstract class Presenter extends SimplePresenter<View> {
        ...
    }

}
public interface CreateOrEditSideEffect {


    enum Simple implements CreateOrEditSideEffect {
        SHOW_ERROR
    }

}
public interface CreateOrEditCircleContract {

    interface View extends BaseView {

        void redirectToCircleFeedScreen(final String circleId);
        void redirectToCircleInviteScreen(final String circleId);
        void showInitEditErrorDialog();
        void enableCTA();
        void disableCTA();
        void exitWithCreateConfirmationDialog();
        void exitWithEditConfirmationDialog();
        void exitWithoutConfirmationDialog();
        void exitToAggregatedFeed();
        void showDeleteConfirmationDialog();
        void setEditableCircleData(CircleConfigModel result);
    }

    abstract class Presenter extends SimplePresenter<View> {
        ...
    }

}
public interface CreateOrEditSideEffect {


    enum Simple implements CreateOrEditSideEffect {
        SHOW_ERROR,
        SHOW_EXIT_CONFIRMATION
    }

}
public interface CreateOrEditCircleContract {

    interface View extends BaseView {

        void redirectToCircleFeedScreen(final String circleId);
        void redirectToCircleInviteScreen(final String circleId);
        void showInitEditErrorDialog();
        void enableCTA();
        void disableCTA();
        void exitWithoutConfirmationDialog();
        void exitToAggregatedFeed();
        void showDeleteConfirmationDialog();
        void setEditableCircleData(CircleConfigModel result);
    }

    abstract class Presenter extends SimplePresenter<View> {
        ...
    }

}
public interface CreateOrEditSideEffect {


    enum Simple implements CreateOrEditSideEffect {
        CLOSE,
        OPEN_AGGREGATED_FEED,
        SHOW_DELETE_CONFIRMATION,
        SHOW_EXIT_CONFIRMATION,
        SHOW_ERROR,
        SHOW_INIT_EDIT_ERROR
    }

}
public interface CreateOrEditCircleContract {

    interface View extends BaseView {

        void redirectToCircleFeedScreen(final String circleId);
        void redirectToCircleInviteScreen(final String circleId);
        void enableCTA();
        void disableCTA();
        void setEditableCircleData(CircleConfigModel result);
    }

    abstract class Presenter extends SimplePresenter<View> {
        ...
    }

}
public interface CreateOrEditSideEffect {

    @AutoValue
    abstract class OpenCircleInvite implements CreateOrEditSideEffect {

        public abstract String circleId();

        ...
    }

    @AutoValue
    abstract class OpenCircleFeed implements CreateOrEditSideEffect {

        public abstract String circleId();

        ...
    }

    enum Simple implements CreateOrEditSideEffect {
        ...
    }
}

public interface CreateOrEditCircleContract {

    interface View extends BaseView {

        void enableCTA();
        void disableCTA();
        void setEditableCircleData(CircleConfigModel result);
    }

    abstract class Presenter extends SimplePresenter<View> {
        ...
    }

}
@AutoValue
public abstract class CreateOrEditState {

    public abstract Mode mode();
    
    public abstract boolean committingChanges();
    
    public abstract Optional<CircleConfigModel> editedCircle();

    public abstract boolean ctaEnabled();

    public enum Mode {
        CREATE,
        EDIT;
    }
}
@AutoValue
public abstract class CreateOrEditState {

    public abstract Mode mode();
    
    public abstract boolean committingChanges();
    
    public abstract Optional<CircleConfigModel> editedCircle();

    public abstract boolean ctaEnabled();
    

    public CreateOrEditState withCommittingChanges(final boolean committingChanges) {
        return toBuilder()
                .committingChanges(committingChanges)
                .build();
    }
    
    public abstract Builder toBuilder();

    public static Builder builder() {
        return new AutoValue_CreateOrEditState.Builder()
                .mode(Mode.CREATE)
                .ctaEnabled(false)
                .committingChanges(false);
    }

    @AutoValue.Builder
    public abstract static class Builder {

        public abstract Builder mode(final Mode mode);

        public abstract Builder editedCircle(final CircleConfigModel editedCircle);

        public abstract Builder ctaEnabled(final boolean ctaEnabled);

        public abstract Builder committingChanges(final boolean committingChanges);

        public abstract CreateOrEditState build();
    }

    public enum Mode {
        CREATE,
        EDIT;
    }
}
@Value.Immutable
public abstract class CreateOrEditState {

    @Value.Default
    public Mode mode() {
        return Mode.CREATE;
    }

    public abstract Optional<CircleConfigModel> editedCircle();

    @Value.Default
    public boolean ctaEnabled() {
        return false;
    }

    @Value.Default
    public boolean committingChanges() {
        return false;
    }

    public enum Mode {
        CREATE,
        EDIT;
    }
}
public interface CreateOrEditCircleContract {

    interface View extends BaseView {

        // yay
    }

    abstract class Presenter extends SimplePresenter<View> {
    
        public abstract void onCheckFormRequested(final CreateOrEditCircleRequestDomain createCircleRequestDomain);

        public abstract void onFormSubmitted(final CreateOrEditCircleRequestDomain createCircleRequestDomain);

        public abstract void onExitFromCreateCircleFlowRequested(final CreateOrEditCircleRequestDomain createCircleRequestDomain);

        public abstract void onEditCircleRequested(final String circleIdToEdit);

        public abstract void onDeleteCircleRequested();

        public abstract void onDeleteCircleConfirmed();
    }

}

Step 2 - processing in background

public class CreateOrEditCirclePresenter extends CreateOrEditCircleContract.Presenter {

    public static final String TAG = "CreateCirclePresenter";
    private final HasCreateCircleRequestChangedUseCase hasCreateCircleRequestChangedUseCase;
    private final CirclesRepository circlesRepository;
    private final PrepareEditCircleRequestUseCase prepareEditCircleRequestUseCase;
    private final ShouldAllowFormSubmissionUseCase shouldAllowFormSubmissionUseCase;
    private CreateOrEditCircleContract.Mode mode = CreateOrEditCircleContract.Mode.CREATE;
    private CircleConfigModel editedCircle;
    
    public CreateOrEditCirclePresenter(
            final ShouldAllowFormSubmissionUseCase shouldAllowFormSubmissionUseCase,
            final HasCreateCircleRequestChangedUseCase hasCreateCircleRequestChangedUseCase,
            final CirclesRepository circlesRepository,
            final PrepareEditCircleRequestUseCase prepareEditCircleRequestUseCase
    ) {
        this.shouldAllowFormSubmissionUseCase = shouldAllowFormSubmissionUseCase;
        this.hasCreateCircleRequestChangedUseCase = hasCreateCircleRequestChangedUseCase;
        this.circlesRepository = circlesRepository;
        this.prepareEditCircleRequestUseCase = prepareEditCircleRequestUseCase;
    }
    
    ...
    
}
public class CreateOrEditCirclePresenter extends 
        MviContainerHost<ImmutableCreateOrEditState, CreateOrEditSideEffect> {

    public static final String TAG = "CreateCirclePresenter";
    private final HasCreateCircleRequestChangedUseCase hasCreateCircleRequestChangedUseCase;
    private final CirclesRepository circlesRepository;
    private final PrepareEditCircleRequestUseCase prepareEditCircleRequestUseCase;
    private final ShouldAllowFormSubmissionUseCase shouldAllowFormSubmissionUseCase;
    
    public CreateOrEditCirclePresenter(
            final ShouldAllowFormSubmissionUseCase shouldAllowFormSubmissionUseCase,
            final HasCreateCircleRequestChangedUseCase hasCreateCircleRequestChangedUseCase,
            final CirclesRepository circlesRepository,
            final PrepareEditCircleRequestUseCase prepareEditCircleRequestUseCase
    ) {
        super(ImmutableCreateOrEditState.builder().build());
        this.shouldAllowFormSubmissionUseCase = shouldAllowFormSubmissionUseCase;
        this.hasCreateCircleRequestChangedUseCase = hasCreateCircleRequestChangedUseCase;
        this.circlesRepository = circlesRepository;
        this.prepareEditCircleRequestUseCase = prepareEditCircleRequestUseCase;
    }
    
    ...
    
}
public class CreateOrEditCirclePresenter extends 
        MviContainerHost<ImmutableCreateOrEditState, CreateOrEditSideEffect> {

    ...
    
    public void onDeleteCircleConfirmed() {
    
        ifAttached(view -> view.showEditSpinner());

        circlesRepository.deleteCircle(editedCircle.id())
                .subscribeOnUiThread(
                        result -> ifAttached(view -> view.exitToAggregatedFeed()),
                        exception -> ifAttached(view -> view.showEditCircleErrorDialog())
                );
    }
    
    ...
    
}
public class CreateOrEditCirclePresenter extends 
        MviContainerHost<ImmutableCreateOrEditState, CreateOrEditSideEffect> {

    ...
    
    public void onDeleteCircleConfirmed() {
    
        intent(mvi -> {
            ifAttached(view -> view.showEditSpinner());

            circlesRepository.deleteCircle(editedCircle.id())
                    .subscribeOnUiThread(
                            result -> ifAttached(view -> view.exitToAggregatedFeed()),
                            exception -> ifAttached(view -> view.showEditCircleErrorDialog())
                    );
        }
    }
    
    ...
    
}

Step 3 - translate view interactions

public class CreateOrEditCirclePresenter extends 
        MviContainerHost<ImmutableCreateOrEditState, CreateOrEditSideEffect> {

    ...
    
    public void onDeleteCircleConfirmed() {
    
        intent(mvi -> {
            ifAttached(view -> view.showEditSpinner());

            circlesRepository.deleteCircle(editedCircle.id())
                    .subscribeOnUiThread(
                            result -> ifAttached(view -> view.exitToAggregatedFeed()),
                            exception -> ifAttached(view -> view.showEditCircleErrorDialog())
                    );
        }
    }
    
    ...
    
}
public class CreateOrEditCirclePresenter extends 
        MviContainerHost<ImmutableCreateOrEditState, CreateOrEditSideEffect> {

    ...
    
    public void onDeleteCircleConfirmed() {
    
        intent(mvi -> {
            mvi.reduce(currentState -> currentState.withCommittingChanges(true));

            circlesRepository.deleteCircle(mvi.getState().editedCircle().get().id())
                    .subscribe(
                            result -> 
                                mvi.postSideEffect(CreateOrEditSideEffect.Simple.OPEN_AGGREGATED_FEED),
                            exception -> {
                                mvi.reduce(currentState -> currentState.withCommittingChanges(false));
                                mvi.postSideEffect(CreateOrEditSideEffect.Simple.SHOW_ERROR);
                            }
                    );
        });
    }
    
    ...
    
}

Step 4 - refactor view

override fun onCreate(savedInstanceState: Bundle?) {
        
    ...

    presenter.subscribe(
            lifecycleScope,
            lifecycle,
            ::handleState,
            ::handleSideEffect
        )
}
private fun handleState(createOrEditState: ImmutableCreateOrEditState) {

        with(createOrEditState) {
        
            updateCtaState(ctaEnabled())

            if (committingChanges()) {
                if (mode() == CreateOrEditState.Mode.CREATE) {
                    showCreateSpinner()
                } else {
                    showEditSpinner()
                }
            } else {
                hideSpinner()
            }

            if (mode == CreateOrEditState.Mode.EDIT && 
                     !editInitialized && 
                     editedCircle() != null) {
                editInitialized = true
                setEditableCircleData(editedCircle().get())
            }
        }
    }
private fun handleSideEffect(sideEffect: CreateOrEditSideEffect) {

        when (sideEffect) {
            is CreateOrEditSideEffect.OpenCircleInvite -> redirectToCircleInviteScreen(sideEffect.circleId())
            is CreateOrEditSideEffect.OpenCircleFeed -> redirectToCircleFeedScreen(sideEffect.circleId())
            CreateOrEditSideEffect.Simple.CLOSE -> exitWithoutConfirmationDialog()
            CreateOrEditSideEffect.Simple.OPEN_AGGREGATED_FEED -> exitToAggregatedFeed()
            CreateOrEditSideEffect.Simple.SHOW_DELETE_CONFIRMATION -> showDeleteConfirmationDialog()
            CreateOrEditSideEffect.Simple.SHOW_EXIT_CONFIRMATION -> if (mode == CreateOrEditState.Mode.CREATE) {
                exitWithCreateConfirmationDialog()
            } else {
                exitWithEditConfirmationDialog()
            }
            CreateOrEditSideEffect.Simple.SHOW_ERROR -> if (mode == CreateOrEditState.Mode.CREATE) {
                showCreateCircleErrorDialog()
            } else {
                showEditCircleErrorDialog()
            }
            CreateOrEditSideEffect.Simple.SHOW_INIT_EDIT_ERROR -> showInitEditErrorDialog()
        }
        
    }

How does it look on iOS?

class CreateEditCircleViewController: FabulousViewController {

    ...

    @Inject({ $0.presenter.createCreateOrEditCirclePresenter() })
    private var presenter: CreateOrEditCirclePresenter

    private let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()

        presenter
            .subscribe(
                stateHandler: { [weak self] state in
                    self?.handleState(state: state)
                },
                sideEffectHandler: { [weak self] sideEffect in
                    self?.handleSideEffect(sideEffect: sideEffect)
                }
            )
            .disposed(by: disposeBag)
    }
    func handleState(state: CreateOrEditState) {
        nameTextField.text = state.circleName()
        if state.coverImage().isPresent(),
           let imagePath = state.coverImage().get() as? String,
           let imageUrl = URL(string: imagePath) {
            imageView.image = try? UIImage(url: imageUrl)
        }
        saveButton.isEnabled = state.ctaEnabled()
        spinner.isHidden = state.committingChanges().isFalse
    }

    @IBAction private func nameChanged(_ sender: Any) {
        presenter.onNameChanged(with: nameTextField.text ?? "")
    }

    @IBAction private func saveButtonTapped(_ sender: Any) {
        presenter.onFormSubmitted()
    }
    func handleSideEffect(sideEffect: CreateOrEditSideEffect) {
        switch sideEffect {

        case let openCircleInvite as CreateOrEditSideEffect_OpenCircleInvite:
            openCircleInviteScreen(circleId: openCircleInvite.circleId())

        case let openCircleFeed as CreateOrEditSideEffect_OpenCircleFeed:
            openCircleFeedScreen(circleId: openCircleFeed.circleId())

        case let simpleEffect as CreateOrEditSideEffect_Simple:
            switch simpleEffect.toNSEnum() {
            case CreateOrEditSideEffect_Simple_Enum.CLOSE:
                dismissScreen()
            case CreateOrEditSideEffect_Simple_Enum.OPEN_AGGREGATED_FEED:
                dismissToAggregatedFeed()
            case CreateOrEditSideEffect_Simple_Enum.SHOW_DELETE_CONFIRMATION:
                showDeleteConfirmation()
            case CreateOrEditSideEffect_Simple_Enum.SHOW_EXIT_CONFIRMATION:
                showExitConfiramtion()
            case CreateOrEditSideEffect_Simple_Enum.SHOW_ERROR:
                showErrorDialog()
            case CreateOrEditSideEffect_Simple_Enum.SHOW_INIT_EDIT_ERROR:
                showInitEditErrorDialog()
            default:
                break
            }

        default:
            break
        }
    }

Unit testing

@Test
fun `should exit when delete is confirmed and completes successfully`() {
    val fakeCircle = fixture<CircleConfigModel>()
    val presenter = createPresenter(
        circlesRepository = mock {
            on { deleteCircle(fakeCircle.id()) } doReturn Task.empty()
        }
    )
    val initialState = ImmutableCreateOrEditState.builder()
        .mode(CreateOrEditState.Mode.EDIT)
        .editedCircle(fakeCircle)
        .build()
    val testPresenter = presenter.test(initialState)

    testPresenter.testIntent {
        onDeleteCircleConfirmed()
    }

    testPresenter.assert(initialState) {
        states(
            { withCommittingChanges(true) }
        )
        postedSideEffects(CreateOrEditSideEffect.Simple.OPEN_AGGREGATED_FEED)
    }
}
testPresenter.assert(defaultInitialState) {
    states(
        { withCommittingChanges(true).withMode(CreateOrEditState.Mode.EDIT) },
        { withCommittingChanges(false) }
    )
}

Conclusions

To do

  • Improve lifecycle management (e.g. ViewModel)
  • Side effect caching
  • State saving

Next steps

  • Do we want this in our app?
  • Draw up implementation plan
  • Implement missing/extra features

Thanks!

Why MVI?

By Mikołaj Leszczyński