Unidirectional Data Flow is a buzz phrase that thrilled development conferences all around the world. There are plenty of frameworks that now use this concept such as State Machines, Redux, Flux, Bloc, Mobx, among others. These are widely used on all frontend platforms (mobile, web, desktop applications, etc.) regardless of programming language or framework.
Key Concepts
First, what does “unidirectional” mean? In simple words, it means that data only has one way to be transferred to other parts of an application (i.e., a single input leads to a single output). Have you ever encountered issues like concurrency modification exceptions or race conditions? These can all be tackled with a unidirectional flow approach since only one function and one thread can make changes at a time.
Events (or “actions“) describe mouse clicks, network call completions, input changes, etc. All are added to the FIFO queue.
A worker thread takes the last state and last event in the queue, and returns a new state.
The transition between states is often called a “reducer”, a “reduce function”, or a “transition”.
The transition should be a blocking function and quick. If it cannot be synchronous, consider running a task in the background and emit a new event when the task starts, finishes, or fails. This type of background task is often called a “side effect” or “middleware”.
An interface (or a “view”) not only adds events to the queue, but also observes the current state and updates itself accordingly. This approach works in a cycle, as shown in the figure below.
Further details will be provided in the following sections.
State
In general, a state is a structure (class or enumeration) that holds the current status of an application or UI (i.e., components visibility, input values, etc.). To avoid ambiguous modifications, the state should always be immutable – no function or object should be able to modify the values of its fields. Below is an example of immutability.
Mutable classes can be modified at any time:
data class MyMutableClass(
var field: String // variable
)
val instance = MyMutableClass("hello")
instance.field = "world"
print(instance.field) // outputs "world"
However, modifications to immutable classes are forbidden by the compiler or result in a runtime exception:
data class MyImmutableClass(
val field: String // constant
)
val instance = MyImmutableClass("hello")
instance.field = "world" // error!
Note: You should also ensure that collection fields are immutable.
So how then do you reflect the change? The answer is simple: create a new object or make a copy of the existing one. Ideally, check if your programming language supports a copy
operator.
A “new state” can be created with a copy of the existing one with some of its fields changed.
data class State(val text: String)
val instance = State("hello")
val newInstance = instance.copy(text = "world")
print(newInstance) // outputs "State(text=world)"
Normally, a state class contains a number of fields:
data class State(
val id: String,
val isLoading: Boolean,
val data: String?
)
Alternatively, you can use an interface:
interface State
class Loading(val id: String) : State
data class Ready(val id: String, val text: String) : State
val instance = Loading(id = "abc")
val newInstance = Ready(id = instance.id, text = "hello world")
print(newInstance) // outputs "Ready(id=abc, text=world)"
Your UI can then observe the current state and update itself to reflect the change:
state.observe { currentState ->
loadingView.isVisible = currentState is Loading
}
Event / Action
An event is a class, which describes a user action or an asynchronous task’s result. Each event is added to an event queue and processed sequentially.
Here is an example of how event definitions in an application could look:
interface Event
class OnResetClicked: Event
data class OnApiResult(val result: String): Event
data class OnApiError(val error: Throwable): Event
Reduce Function / Reducer
A reduce function defines exactly two parameters (previous state and current event) and should immediately return a new state.
Here is an example:
data class State(val isLoading: Boolean, val data: String?)
data class OnApiReady(val data: String)
fun reduce(val previousState: State, val event: OnApiReady): State {
return previousState.copy(isLoading = false, data = event.data)
}
Middleware / Side Effect
Sometimes, there is a need to run an asynchronous task like an API call or writing to a file, etc. Such a task should be processed in a background thread, potentially adding new events to the queue as a result of its execution.
Here is an example:
data class State(val isLoading: Boolean, val data: String?)
interface Event
data class OnStart(val id: String): Event
data class OnCompleted(val data: String): Event
fun middleware(val previousState: State, val event: OnStart) {
Thread().run {
val objectId = previousState.id
// make an api call for object with objectId identifier
// notify about the result
addEvent(OnCompleted("result for $objectId"))
}
}
Types of States
There are two types of states: Global and Scoped.
The global state usually reflects the state of the application, while the scoped one reflects the state of a single view fragment such as a Controller, ViewModel, Presenter, or Bloc.
Tips
- Reducers are supposed to be pure functions,
- Reducers should not perform heavy operations on the same thread; and,
- State classes should be kept as small as possible.
Advantages
- Predictable
- Forget about atomic fields
- Forget about the race conditions
- Forget about concurrent modification exceptions
- Forget about wrong thread execution
- Ease of debugging
- Enhanced testability
Disadvantages
- Boilerplate
- Complexity
- Object copying – memory and CPU overhead
Sample
Check out our Android Game Of Life sample project at GitLab.