Skip to content

A library for reactive and unidirectional Swift applications

License

Notifications You must be signed in to change notification settings

ReactorKit/ReactorKit

Repository files navigation

ReactorKit

Swift CocoaPods Platform CI Codecov

ReactorKit is a framework for a reactive and unidirectional Swift application architecture. This repository introduces the basic concept of ReactorKit and describes how to build an application using ReactorKit.

You may want to see theExamplessection first if you'd like to see the actual code. For an overview of ReactorKit's features and the reasoning behind its creation, you may also check the slides from this introductory presentation over atSlideShare.

Table of Contents

Basic Concept

ReactorKit is a combination ofFluxandReactive Programming.The user actions and the view states are delivered to each layer via observable streams. These streams are unidirectional: the view can only emit actions and the reactor can only emit states.

flow

Design Goal

  • Testability:The first purpose of ReactorKit is to separate the business logic from a view. This can make the code testable. A reactor doesn't have any dependency to a view. Just test reactors and test view bindings. SeeTestingsection for details.
  • Start Small:ReactorKit doesn't require the whole application to follow a single architecture. ReactorKit can be adopted partially, for one or more specific views. You don't need to rewrite everything to use ReactorKit on your existing project.
  • Less Typing:ReactorKit focuses on avoiding complicated code for a simple thing. ReactorKit requires less code compared to other architectures. Start simple and scale up.

View

AViewdisplays data. A view controller and a cell are treated as a view. The view binds user inputs to the action stream and binds the view states to each UI component. There's no business logic in a view layer. A view just defines how to map the action stream and the state stream.

To define a view, just have an existing class conform a protocol namedView.Then your class will have a property namedreactorautomatically. This property is typically set outside of the view.

classProfileViewController:UIViewController,View{
vardisposeBag=DisposeBag()
}

profileViewController.reactor=UserViewReactor()// inject reactor

When thereactorproperty has changed,bind(reactor:)gets called. Implement this method to define the bindings of an action stream and a state stream.

funcbind(reactor:ProfileViewReactor){
// action (View -> Reactor)
refreshButton.rx.tap.map{Reactor.Action.refresh}
.bind(to:reactor.action)
.disposed(by:self.disposeBag)

// state (Reactor -> View)
reactor.state.map{$0.isFollowing}
.bind(to:followButton.rx.isSelected)
.disposed(by:self.disposeBag)
}

Storyboard Support

UseStoryboardViewprotocol if you're using a storyboard to initialize view controllers. Everything is same but the only difference is that theStoryboardViewperforms a binding after the view is loaded.

letviewController=MyViewController()
viewController.reactor=MyViewReactor()// will not executes `bind(reactor:)` immediately

classMyViewController:UIViewController,StoryboardView{
funcbind(reactor:MyViewReactor){
// this is called after the view is loaded (viewDidLoad)
}
}

Reactor

AReactoris an UI-independent layer which manages the state of a view. The foremost role of a reactor is to separate control flow from a view. Every view has its corresponding reactor and delegates all logic to its reactor. A reactor has no dependency to a view, so it can be easily tested.

Conform to theReactorprotocol to define a reactor. This protocol requires three types to be defined:Action,MutationandState.It also requires a property namedinitialState.

classProfileViewReactor:Reactor{
// represent user actions
enumAction{
caserefreshFollowingStatus(Int)
casefollow(Int)
}

// represent state changes
enumMutation{
casesetFollowing(Bool)
}

// represents the current view state
structState{
varisFollowing:Bool=false
}

letinitialState:State=State()
}

AnActionrepresents a user interaction andStaterepresents a view state.Mutationis a bridge betweenActionandState.A reactor converts the action stream to the state stream in two steps:mutate()andreduce().

flow-reactor

mutate()

mutate()receives anActionand generates anObservable<Mutation>.

funcmutate(action:Action)->Observable<Mutation>

Every side effect, such as an async operation or API call, is performed in this method.

funcmutate(action:Action)->Observable<Mutation>{
switch action{
caselet.refreshFollowingStatus(userID):// receive an action
returnUserAPI.isFollowing(userID)// create an API stream
.map{(isFollowing:Bool)->Mutationin
returnMutation.setFollowing(isFollowing)// convert to Mutation stream
}

caselet.follow(userID):
returnUserAPI.follow()
.map{_->Mutationin
returnMutation.setFollowing(true)
}
}
}

reduce()

reduce()generates a newStatefrom a previousStateand aMutation.

funcreduce(state:State,mutation:Mutation)->State

This method is a pure function. It should just return a newStatesynchronously. Don't perform any side effects in this function.

funcreduce(state:State,mutation:Mutation)->State{
varstate=state// create a copy of the old state
switch mutation{
caselet.setFollowing(isFollowing):
state.isFollowing=isFollowing// manipulate the state, creating a new state
returnstate// return the new state
}
}

transform()

transform()transforms each stream. There are threetransform()functions:

functransform(action:Observable<Action>)->Observable<Action>
functransform(mutation:Observable<Mutation>)->Observable<Mutation>
functransform(state:Observable<State>)->Observable<State>

Implement these methods to transform and combine with other observable streams. For example,transform(mutation:)is the best place for combining a global event stream to a mutation stream. See theGlobal Statessection for details.

These methods can be also used for debugging purposes:

functransform(action:Observable<Action>)->Observable<Action>{
returnaction.debug("action")// Use RxSwift's debug() operator
}

Advanced

Global States

Unlike Redux, ReactorKit doesn't define a global app state. It means that you can use anything to manage a global state. You can use aBehaviorSubject,aPublishSubjector even a reactor. ReactorKit doesn't force to have a global state so you can use ReactorKit in a specific feature in your application.

There is no global state in theAction → Mutation → Stateflow. You should usetransform(mutation:)to transform the global state to a mutation. Let's assume that we have a globalBehaviorSubjectwhich stores the current authenticated user. If you'd like to emit aMutation.setUser(User?)when thecurrentUseris changed, you can do as following:

varcurrentUser:BehaviorSubject<User>// global state

functransform(mutation:Observable<Mutation>)->Observable<Mutation>{
returnObservable.merge(mutation,currentUser.map(Mutation.setUser))
}

Then the mutation will be emitted each time the view sends an action to a reactor and thecurrentUseris changed.

View Communication

You must be familiar with callback closures or delegate patterns for communicating between multiple views. ReactorKit recommends you to usereactive extensionsfor it. The most common example ofControlEventisUIButton.rx.tap.The key concept is to treat your custom views as UIButton or UILabel.

view-view

Let's assume that we have aChatViewControllerwhich displays messages. TheChatViewControllerowns aMessageInputView.When an user taps the send button on theMessageInputView,the text will be sent to theChatViewControllerandChatViewControllerwill bind in to the reactor's action. This is an exampleMessageInputView's reactive extension:

extensionReactivewhereBase:MessageInputView{
varsendButtonTap:ControlEvent<String>{
letsource=base.sendButton.rx.tap.withLatestFrom(...)
returnControlEvent(events:source)
}
}

You can use that extension in theChatViewController.For example:

messageInputView.rx.sendButtonTap
.map(Reactor.Action.send)
.bind(to:reactor.action)

Testing

ReactorKit has a built-in functionality for a testing. You'll be able to easily test both a view and a reactor with a following instruction.

What to test

First of all, you have to decide what to test. There are two things to test: a view and a reactor.

  • View
    • Action: is a proper action sent to a reactor with a given user interaction?
    • State: is a view property set properly with a following state?
  • Reactor
    • State: is a state changed properly with an action?

View testing

A view can be tested with astubreactor. A reactor has a propertystubwhich can log actions and force change states. If a reactor's stub is enabled, bothmutate()andreduce()are not executed. A stub has these properties:

varstate:StateRelay<Reactor.State>{get}
varaction:ActionSubject<Reactor.Action>{get}
varactions:[Reactor.Action]{get}// recorded actions

Here are some example test cases:

functestAction_refresh(){
// 1. prepare a stub reactor
letreactor=MyReactor()
reactor.isStubEnabled=true

// 2. prepare a view with a stub reactor
letview=MyView()
view.reactor=reactor

// 3. send an user interaction programmatically
view.refreshControl.sendActions(for:.valueChanged)

// 4. assert actions
XCTAssertEqual(reactor.stub.actions.last,.refresh)
}

functestState_isLoading(){
// 1. prepare a stub reactor
letreactor=MyReactor()
reactor.isStubEnabled=true

// 2. prepare a view with a stub reactor
letview=MyView()
view.reactor=reactor

// 3. set a stub state
reactor.stub.state.value=MyReactor.State(isLoading:true)

// 4. assert view properties
XCTAssertEqual(view.activityIndicator.isAnimating,true)
}

Reactor testing

A reactor can be tested independently.

functestIsBookmarked(){
letreactor=MyReactor()
reactor.action.onNext(.toggleBookmarked)
XCTAssertEqual(reactor.currentState.isBookmarked,true)
reactor.action.onNext(.toggleBookmarked)
XCTAssertEqual(reactor.currentState.isBookmarked,false)
}

Sometimes a state is changed more than one time for a single action. For example, a.refreshaction setsstate.isLoadingtotrueat first and sets tofalseafter the refreshing. In this case it's difficult to teststate.isLoadingwithcurrentStateso you might need to useRxTestorRxExpect.Here is an example test case using RxSwift:

functestIsLoading(){
// given
letscheduler=TestScheduler(initialClock:0)
letreactor=MyReactor()
letdisposeBag=DisposeBag()

// when
scheduler
.createHotObservable([
.next(100,.refresh)// send.refresh at 100 scheduler time
])
.subscribe(reactor.action)
.disposed(by:disposeBag)

// then
letresponse=scheduler.start(created:0,subscribed:0,disposed:1000){
reactor.state.map(\.isLoading)
}
XCTAssertEqual(response.events.map(\.value.element),[
false,// initial state
true,// just after.refresh
false// after refreshing
])
}

Pulse

Pulsehas diff only when mutated To explain in code, the results are as follows.

varmessagePulse:Pulse<String?>=Pulse(wrappedValue:"Hello tokijh")

letoldMessagePulse:Pulse<String?>=messagePulse
messagePulse.value="Hello tokijh"// add valueUpdatedCount +1

oldMessagePulse.valueUpdatedCount!=messagePulse.valueUpdatedCount// true
oldMessagePulse.value==messagePulse.value// true

Use when you want to receive an event only if the new value is assigned, even if it is the same value. likealertMessage(See follows orPulseTests.swift)

// Reactor
privatefinalclassMyReactor:Reactor{
structState{
@PulsevaralertMessage:String?
}

funcmutate(action:Action)->Observable<Mutation>{
switch action{
caselet.alert(message):
returnObservable.just(Mutation.setAlertMessage(message))
}
}

funcreduce(state:State,mutation:Mutation)->State{
varnewState=state

switch mutation{
caselet.setAlertMessage(alertMessage):
newState.alertMessage=alertMessage
}

returnnewState
}
}

// View
reactor.pulse(\.$alertMessage)
.compactMap{$0}// filter nil
.subscribe(onNext:{[weak self](message:String)in
self?.showAlert(message)
})
.disposed(by:disposeBag)

// Cases
reactor.action.onNext(.alert("Hello"))// showAlert() is called with `Hello`
reactor.action.onNext(.alert("Hello"))// showAlert() is called with `Hello`
reactor.action.onNext(.doSomeAction)// showAlert() is not called
reactor.action.onNext(.alert("Hello"))// showAlert() is called with `Hello`
reactor.action.onNext(.alert("tokijh"))// showAlert() is called with `tokijh`
reactor.action.onNext(.doSomeAction)// showAlert() is not called

Examples

  • Counter:The most simple and basic example of ReactorKit
  • GitHub Search:A simple application which provides a GitHub repository search
  • RxTodo:iOS Todo Application using ReactorKit
  • Cleverbot:iOS Messaging Application using Cleverbot and ReactorKit
  • Drrrible:Dribbble for iOS using ReactorKit (App Store)
  • Passcode:Passcode for iOS RxSwift, ReactorKit and IGListKit example
  • Flickr Search:A simple application which provides a Flickr Photo search with RxSwift and ReactorKit
  • ReactorKitExample
  • reactorkit-keyboard-example:iOS Application example for develop keyboard-extensions using ReactorKit Architecture.
  • TinyHub:Use ReactorKit develop the Github client

Dependencies

Requirements

  • Swift 5
  • iOS 8
  • macOS 10.11
  • tvOS 9.0
  • watchOS 2.0

Installation

Podfile

pod'ReactorKit'

Package.swift

letpackage=Package(
name:"MyPackage",
dependencies:[
.package(url:"https://github /ReactorKit/ReactorKit.git",.upToNextMajor(from:"3.0.0"))
],
targets:[
.target(name:"MyTarget",dependencies:["ReactorKit"])
]
)

ReactorKit does not officially support Carthage.

Cartfile

github"ReactorKit/ReactorKit"

Most Carthage installation issues can be resolved with the following:

carthage update2>/dev/null
(cd Carthage/Checkouts/ReactorKit&&swift package generate-xcodeproj)
carthage build

Contribution

Any discussions and pull requests are welcomed 💖

  • To development:

    TEST=1 swift package generate-xcodeproj
  • To test:

    swift test

Community

Join

Community Projects

Who's using ReactorKit


StyleShare Kakao Wantedly

DocTalk Constant Contact KT

Hyperconnect Toss LINE Pay

LINE Pay Kurly

Are you using ReactorKit? Pleaselet me know!

Changelog

  • 2017-04-18
    • Change the repository name to ReactorKit.
  • 2017-03-17
    • Change the architecture name from RxMVVM to The Reactive Architecture.
    • Every ViewModels are renamed to ViewReactors.

License

ReactorKit is under MIT license. See theLICENSEfor more info.