Architecting Success: A Comprehensive Guide to TCA Architecture in Android Development

Table of Contents

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.

Kotlin
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.

Kotlin
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.

Kotlin
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.

Kotlin
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.

Kotlin
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.

Kotlin
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.

Kotlin
@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.

Kotlin
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 disposeon 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:

Kotlin
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:

Kotlin
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:

Kotlin
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:

Kotlin
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:

Kotlin
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.

Kotlin
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:

Kotlin
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

Kotlin
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:

  1. 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.
  2. 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.
  3. 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.
  4. 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Author

Skill Up: Software & AI Updates!

Receive our latest insights and updates directly to your inbox

Related Posts

error: Content is protected !!