Simplify State Management in Kotlin: Refactoring to Sealed Classes
Handling states like loading, success, and error is a common challenge when developing Android apps. Many developers rely on generic wrappers, but these can quickly become cumbersome and less expressive.
This post explores replacing such wrappers with sealed classes, making your code cleaner, more readable, and more idiomatic to Kotlin.
1. The Problem: Using a Generic Wrapper
In a typical Android repository pattern, you may have encountered something like this:
data class DataOrException<T, Boolean, E : Exception>(
var data: T? = null,
var loading: Boolean? = null,
var e: E? = null
)
This structure is functional, but it has drawbacks:
- Ambiguity: The meaning of
loading
,data
, ande
can be unclear at first glance. - Boilerplate: You repeatedly check combinations of these fields to interpret the state.
- Error-Prone: Forgetting to set or check a field can lead to bugs.
Let’s see how to refactor this with Kotlin’s sealed classes.
2. Introducing Sealed Classes
Sealed classes in Kotlin allow you to represent a fixed set of types. They are perfect for modeling finite states in a type-safe and readable way.
Here’s how we define a Result
sealed class:
sealed class Result<out T> {
data class Success<out T>(val data: T) : Result<T>()
data class Error(val exception: Exception) : Result<Nothing>()
object Loading : Result<Nothing>()
}
Benefits of Using Sealed Classes:
- Explicit States: Each state (
Success
,Error
,Loading
) is clear and self-contained. - Type Safety: Forces consumers to handle all possible states, avoiding unintended errors.
- Readability: The intent behind the code is immediately clear to readers.
- Extensibility: Adding new states is straightforward and safe.
3. Refactoring the Repository
Here’s how you can refactor a repository to use the Result
sealed class:
Old Code:
suspend fun getQuestions(): DataOrException<List<QuestionItem>, Boolean, Exception> {
val result = DataOrException<List<QuestionItem>, Boolean, Exception>()
try {
result.loading = true
result.data = questionApi.getAllQuestions().questions
} catch (e: Exception) {
result.e = e
} finally {
result.loading = false
}
return result
}
Refactored Code:
suspend fun getQuestions(): Result<List<QuestionItem>> {
return try {
// Emit success state with data
val questions = questionApi.getAllQuestions().questions
Result.Success(questions)
} catch (e: Exception) {
// Emit error state with the exception
Result.Error(e)
}
}
This is more concise and immediately communicates the function’s possible outcomes.
4. Consuming the Result
The real power of sealed classes shines when consuming the result in your app logic.
Old Code:
val result = repository.getQuestions()
if (result.loading == true) {
showLoading()
} else if (result.data != null) {
displayData(result.data)
} else if (result.e != null) {
handleError(result.e)
}
Refactored Code:
when (val result = repository.getQuestions()) {
is Result.Loading -> showLoading()
is Result.Success -> displayData(result.data)
is Result.Error -> handleError(result.exception)
}
With the sealed class, each state is explicit, reducing ambiguity and improving maintainability.
5. Additional Use Cases
Sealed classes aren’t just for repositories! You can use them in:
- UI State Management: Represent different screen states like
Loading
,Content
, andError
. - Navigation: Define navigation actions like
NavigateToScreenA
orNavigateBack
. - API Response Handling: Handle success, error, and edge cases with descriptive types.
6. Why You Should Adopt This Pattern
By using sealed classes for state management, you:
- Embrace idiomatic Kotlin, aligning your code with best practices.
- Write cleaner, more maintainable code that’s easier to understand and extend.
- Avoid common pitfalls, like forgetting to handle specific states.
7. Key Takeaways
- Replace generic wrappers with sealed classes for explicit state management.
- Use sealed classes to make your code more readable and type-safe.
- Refactor incrementally — start with one area of your codebase and expand.
8. Full Code Example
For reference, here’s the complete refactored example:
Sealed Class:
sealed class Result<out T> {
data class Success<out T>(val data: T) : Result<T>()
data class Error(val exception: Exception) : Result<Nothing>()
object Loading : Result<Nothing>()
}
Repository:
class QuestionRepository @Inject constructor(private val questionApi: QuestionApi) {
suspend fun getQuestions(): Result<List<QuestionItem>> {
return try {
val questions = questionApi.getAllQuestions().questions
Result.Success(questions)
} catch (e: Exception) {
Result.Error(e)
}
}
}
Usage in ViewModel:
viewModelScope.launch {
when (val result = repository.getQuestions()) {
is Result.Loading -> showLoading()
is Result.Success -> displayData(result.data)
is Result.Error -> handleError(result.exception)
}
}
Conclusion
Refactoring to sealed classes is a simple but powerful way to write cleaner, more idiomatic Kotlin code. If you’ve been managing states with generic wrappers, consider giving this approach a try.
Have you used sealed classes in your projects? Share your experiences or questions in the comments below! 🚀
#Kotlin #AndroidDevelopment #SealedClasses #CleanCode #ProgrammingTips