I had the opportunity to work with the TCA (The Composable Architecture) in the past and would like to share my knowledge about it with our community. This architecture has gained popularity as a reliable way to create robust and scalable applications. TCA is a composable, unidirectional, and predictable architecture that helps developers to build applications that are easy to test, maintain and extend. In this blog, we’ll explore TCA in Android and how it can be implemented using Kotlin code.
What is TCA?
The Composable Architecture is a pattern that is inspired by Redux and Elm. It aims to simplify state management and provide a clear separation of concerns. TCA achieves this by having a strict unidirectional data flow and by breaking down the application into smaller, reusable components.
The basic components of TCA are:
- State: The single source of truth for the application’s data.
- Action: A description of an intent to change the state.
- Reducer: A pure function that takes the current state and an action as input and returns a new state.
- Effect: A description of a side-effect, such as fetching data from an API or showing a dialog box.
- Environment: An object that contains dependencies that are needed to perform side-effects.
TCA uses a unidirectional data flow, meaning that the flow of data in the application goes in one direction. Actions are dispatched to the reducer, which updates the state, and effects are executed based on the updated state. This unidirectional flow makes the architecture predictable and easy to reason about.
Implementing TCA in Android using Kotlin
To implement TCA in Android using Kotlin, we will use the following libraries:
- Kotlin Coroutines: For handling asynchronous tasks.
- Kotlin Flow: For creating reactive streams of data.
- Compose: For building the UI.
Let’s start by creating a basic TCA structure for our application.
1. State
The State is the single source of truth for the application’s data. In this example, we will create a simple counter app, where the state will contain an integer value representing the current count.
data class CounterState(val count: Int = 0)
2. Action
Actions are descriptions of intents to change the state. In this example, we will define two actions, one to increment the count and another to decrement it.
sealed class CounterAction {
object Increment : CounterAction()
object Decrement : CounterAction()
}
3. Reducer
Reducers are pure functions that take the current state and an action as input and return a new state. In this example, we will create a reducer that updates the count based on the action.
fun counterReducer(state: CounterState, action: CounterAction): CounterState {
return when (action) {
is CounterAction.Increment -> state.copy(count = state.count + 1)
is CounterAction.Decrement -> state.copy(count = state.count - 1)
}
}
4. Effect
Effects are descriptions of side-effects, such as fetching data from an API or showing a dialog box. In this example, we don’t need any effects.
sealed class CounterEffect
5. Environment
The Environment is an object that contains dependencies that are needed to perform side-effects. In this example, we don’t need any dependencies.
class CounterEnvironment
6. Store
The Store is the central component of TCA. It contains the state, the reducer, the effect handler, and the environment. It also provides a way to dispatch actions and subscribe to state changes.
class CounterStore : CoroutineScope {
private val job = Job()
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job
private val _state = MutableStateFlow(CounterState())
val state: StateFlow<CounterState> = _state.asStateFlow()
fun dispatch(action: CounterAction) {
val newState = counterReducer(_state.value, action)
_state.value = newState
}
fun dispose() {
job.cancel()
}
}
We create a MutableStateFlow
to hold the current state of the application. We also define a StateFlow
to provide read-only access to the state. The dispatch
function takes an action, passes it to the reducer, and updates the state accordingly. Finally, the dispose
function cancels the job
to clean up any ongoing coroutines when the store is no longer needed.
7. Compose UI
Now that we have our TCA components in place, we can create a simple UI to interact with the counter store. We will use Compose to create the UI, which allows us to define the layout and behavior of the UI using declarative code.
@Composable
fun CounterScreen(store: CounterStore) {
val state = store.state.collectAsState()
Column {
Text(text = "Counter: ${state.value.count}")
Row {
Button(onClick = { store.dispatch(CounterAction.Increment) }) {
Text(text = "+")
}
Button(onClick = { store.dispatch(CounterAction.Decrement) }) {
Text(text = "-")
}
}
}
}
We define a CounterScreen
composable function that takes a CounterStore
as a parameter. We use the collectAsState
function to create a state holder for the current state of the store. Inside the Column
, we display the current count and two buttons to increment and decrement the count. When a button is clicked, we dispatch the corresponding action to the store.
8. Putting it all together
To put everything together, we can create a simple MainActivity
that creates a CounterStore
and displays the CounterScreen
.
class MainActivity : ComponentActivity() {
private val store = CounterStore()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
CounterScreen(store)
}
}
override fun onDestroy() {
store.dispose()
super.onDestroy()
}
}
We create a CounterStore
instance and pass it to the CounterScreen
composable function in the setContent
block. We also call dispose
on the store when the activity is destroyed to clean up any ongoing coroutines.
Let’s take a look at a real-world example to gain a clearer understanding of this concept in action and see how it can be applied to solve practical problems.
Here’s another example of TCA in action using a Weather App an example.
1. State
Let’s start by defining the state of our weather app:
data class WeatherState(
val location: String,
val temperature: Double,
val isFetching: Boolean,
val error: String?
)
Our state consists of the current location, the temperature at that location, a flag indicating whether the app is currently fetching data, and an error message if an error occurs.
2. Action
Next, we define the actions that can be performed in our weather app:
sealed class WeatherAction {
data class UpdateLocation(val location: String) : WeatherAction()
object FetchData : WeatherAction()
data class DataFetched(val temperature: Double) : WeatherAction()
data class Error(val message: String) : WeatherAction()
}
We define four actions: UpdateLocation
to update the current location, FetchData
to fetch the weather data for the current location, DataFetched
to update the temperature after the data has been fetched, and Error
to handle errors that occur during the fetch.
3. Reducer
Our reducer takes the current state and an action and returns a new state based on the action:
fun weatherReducer(state: WeatherState, action: WeatherAction): WeatherState {
return when (action) {
is WeatherAction.UpdateLocation -> state.copy(location = action.location)
WeatherAction.FetchData -> state.copy(isFetching = true, error = null)
is WeatherAction.DataFetched -> state.copy(
temperature = action.temperature,
isFetching = false,
error = null
)
is WeatherAction.Error -> state.copy(isFetching = false, error = action.message)
}
}
Our reducer updates the state based on the action that is dispatched. We use the copy
function to create a new state object with updated values.
4. Effects
Our effect is responsible for fetching the weather data for the current location:
fun fetchWeatherData(location: String): Flow<WeatherAction> = flow {
try {
val temperature = getTemperatureForLocation(location)
emit(WeatherAction.DataFetched(temperature))
} catch (e: Exception) {
emit(WeatherAction.Error(e.message ?: "Unknown error"))
}
}
Our effect uses a suspend function getTemperatureForLocation
to fetch the weather data for the current location. We emit a DataFetched
action if the data is fetched successfully and an Error
action if an exception occurs.
5. Environment
Our environment provides dependencies required by our effect:
interface WeatherEnvironment {
suspend fun getTemperatureForLocation(location: String): Double
}
class WeatherEnvironmentImpl : WeatherEnvironment {
override suspend fun getTemperatureForLocation(location: String): Double {
// implementation omitted
}
}
Our environment defines a single function getTemperatureForLocation
which is implemented by WeatherEnvironmentImpl
.
import okhttp3.OkHttpClient
import okhttp3.Request
class WeatherEnvironmentImpl : WeatherEnvironment {
private val client = OkHttpClient()
override suspend fun getTemperatureForLocation(location: String): Double {
val url = "https://api.openweathermap.org/data/2.5/weather?q=$location&appid=API_KEY&units=metric"
val request = Request.Builder().url(url).build()
val response = client.newCall(request).execute()
val jsonResponse = response.body()?.string()
val json = JSONObject(jsonResponse)
val main = json.getJSONObject("main")
return main.getDouble("temp")
}
}
In this implementation, we’re using the OkHttpClient
library to make an HTTP request to the OpenWeatherMap API. The API returns a JSON response, which we parse using the JSONObject
class from the org.json
package. We then extract the temperature from the JSON response and return it as a Double
.
Note that the API_KEY
placeholder in the URL should be replaced with a valid API key obtained from OpenWeatherMap.
6. Store
Our store holds the current state and provides functions to dispatch actions and read the current state:
class WeatherStore(environment: WeatherEnvironment) {
private val _state = MutableStateFlow(WeatherState("", 0.0, false, null))
val state: StateFlow<WeatherState> = _state.asStateFlow()
private val job = Job()
private val scope = CoroutineScope(job + Dispatchers.IO)
fun dispatch(action: WeatherAction) {
val newState = weatherReducer(_state.value, action)
_state.value = newState
when (action) {
is WeatherAction.UpdateLocation -> {
scope.launch {
val weatherAction = fetchWeatherData(newState.location).first()
dispatch(weatherAction)
}
}
WeatherAction.FetchData -> {
scope.launch {
val weatherAction = fetchWeatherData(newState.location).first()
dispatch(weatherAction)
}
}
else -> Unit
}
}
init {
scope.launch {
val weatherAction = fetchWeatherData(_state.value.location).first()
dispatch(weatherAction)
}
}
}
Our store initializes the state with default values and provides a dispatch
function to update the state based on actions. We use a CoroutineScope
to run our effects and dispatch new actions as required.
In the init
block, we fetch the weather data for the current location and dispatch a DataFetched
action with the temperature.
In the dispatch
function, we update the state based on the action and run our effect to fetch the weather data. If an UpdateLocation
or FetchData
action is dispatched, we launch a new coroutine to run our effect and dispatch a new action based on the result.
That’s a simple example of how TCA can be used in a real-world application. By using TCA, we can easily manage the state of our application and handle complex interactions between different components.
Testing in TCA
In TCA, the reducer is the most important component as it is responsible for managing the state of the application. Hence, unit testing the reducer is essential. However, there are other components in TCA such as actions, environment, and effects that can also be tested.
Actions can be tested to ensure that they are constructed correctly and have the intended behavior when dispatched to the reducer. Environment can be tested to ensure that it provides the necessary dependencies to the reducer and effects. Effects can also be tested to ensure that they produce the expected results when executed.
Unit Testing For Reducer
import kotlinx.coroutines.test.TestCoroutineDispatcher
import org.junit.Assert.assertEquals
import org.junit.Test
class WeatherReducerTest {
private val testDispatcher = TestCoroutineDispatcher()
@Test
fun `update location action should update location in state`() {
val initialState = WeatherState(location = "Satara")
val expectedState = initialState.copy(location = "Pune")
val actualState = weatherReducer(
initialState,
WeatherAction.UpdateLocation("Pune")
)
assertEquals(expectedState, actualState)
}
@Test
fun `fetch data action should update fetching state and clear error`() {
val initialState = WeatherState(isFetching = false, error = "Some error")
val expectedState = initialState.copy(isFetching = true, error = null)
val actualState = weatherReducer(initialState, WeatherAction.FetchData)
assertEquals(expectedState, actualState)
}
@Test
fun `data fetched action should update temperature and reset fetching state`() {
val initialState = WeatherState(isFetching = true, temperature = 0.0)
val expectedState = initialState.copy(isFetching = false, temperature = 20.0)
val actualState = weatherReducer(
initialState,
WeatherAction.DataFetched(20.0)
)
assertEquals(expectedState, actualState)
}
@Test
fun `error action should update error and reset fetching state`() {
val initialState = WeatherState(isFetching = true, error = null)
val expectedState = initialState.copy(isFetching = false, error = "Some error")
val actualState = weatherReducer(
initialState,
WeatherAction.Error("Some error")
)
assertEquals(expectedState, actualState)
}
@Test
fun `fetch data effect should emit data fetched action with temperature`() {
val initialState = WeatherState(isFetching = false, temperature = 0.0)
val expectedState = initialState.copy(isFetching = false, temperature = 20.0)
val fetchTemperature = { 20.0 }
val actualState = performActionWithEffect(
initialState,
WeatherAction.FetchData,
fetchTemperature,
testDispatcher
)
assertEquals(expectedState, actualState)
}
@Test
fun `fetch data effect should emit error action with message`() {
val initialState = WeatherState(isFetching = false, error = null)
val expectedState = initialState.copy(isFetching = false, error = "Failed to fetch temperature")
val fetchTemperature = { throw Exception("Failed to fetch temperature") }
val actualState = performActionWithEffect(
initialState,
WeatherAction.FetchData,
fetchTemperature,
testDispatcher
)
assertEquals(expectedState, actualState)
}
}
In this example, we test each case of the when
expression in the weatherReducer
function using different test cases. We also test the effect that is dispatched when the FetchData
action is dispatched. We create an initial state and define an expected state after each action is dispatched or effect is performed. We then call the weatherReducer
function with the initial state and action to obtain the updated state. Finally, we use assertEquals
to compare the expected and actual states.
To test the effect, we define a function that returns a value or throws an exception, depending on the test case. We then call the performActionWithEffect
function, passing in the initial state, action, and effect function, to obtain the updated state after the effect is performed. We then use assertEquals
to compare the expected and actual states.
Furthermore, integration testing can also be performed to test the interactions between the components of the TCA architecture. For example, integration testing can be used to test the flow of data between the reducer, effects, and the environment.
Overall, while the reducer is the most important component in TCA, it is important to test all components to ensure the correctness and robustness of the application.
Advantages:
- Predictable state management: TCA provides a strict, unidirectional data flow, which makes it easy to reason about the state of your application. This helps reduce the possibility of unexpected bugs and makes it easier to maintain and refactor your codebase.
- Testability: The unidirectional data flow in TCA makes it easier to write tests for your application. You can test your reducers and effects independently, which can help you catch bugs earlier in the development process.
- Modularity: With TCA, your application is broken down into small, composable pieces that can be easily reused across your codebase. This makes it easier to maintain and refactor your codebase as your application grows.
- Error handling: TCA provides a clear path for error handling, which makes it easier to handle exceptions and recover from errors in your application.
Disadvantages:
- Learning curve: TCA has a steep learning curve, especially for developers who are new to functional programming. You may need to invest some time to learn the concepts and get comfortable with the syntax.
- Overhead: TCA can introduce some overhead, especially if you have a small application. The additional boilerplate code required to implement TCA can be a barrier to entry for some developers.
- More verbose code: The strict, unidirectional data flow of TCA can lead to more verbose code, especially for more complex applications. This can make it harder to read and maintain your codebase.
- Limited tooling: TCA is a relatively new architecture, so there is limited tooling and support available compared to more established architectures like MVP or MVVM. This can make it harder to find solutions to common problems or get help when you’re stuck.
Summary
In summary, each architecture has its own strengths and weaknesses, and the best architecture for your project depends on your specific needs and requirements. TCA can be a good choice for projects that require predictable state management, testability, and modularity, but it may not be the best fit for every project.