androidengineers.Book a session

State Management

Single Source of Truth & Hoisting

article25 minMedium

What Is Single Source of Truth?

Single Source of Truth (SSOT) is an architectural principle that states each piece of data in your application should have exactly one authoritative owner. Every other layer that needs that data derives it from that single owner — no copies, no local caches that diverge, no parallel representations.

Violating SSOT is one of the most common sources of sync bugs in Android apps: you update the database but forget to update the in-memory list, or you update a ViewModel field but the Fragment has its own copy. The data drifts, and the UI shows stale or contradictory information.

The principle applies at every level:

  • Persistent data — the Room database (or backend) is the source of truth, not an in-memory list
  • UI state — the ViewModel is the source of truth for what the screen should display
  • Derived values — computed from the source of truth, never stored separately

State Hoisting in Jetpack Compose

State hoisting is the Compose pattern that enforces SSOT at the UI layer. Instead of a composable owning its own state internally, you move (hoist) that state up to the caller. The composable becomes stateless — it receives values and emits events.

Stateful (before hoisting) — avoid this pattern

@Composable
fun SearchBar() {
    // state lives inside the composable — hard to test, hard to share
    var query by remember { mutableStateOf("") }

    TextField(
        value = query,
        onValueChange = { query = it },
        placeholder = { Text("Search...") }
    )
}

The problem: if the parent needs to know the current query (to trigger a search, clear it, or restore it after navigation), it cannot — the state is trapped inside SearchBar.

Stateless (after hoisting) — preferred

@Composable
fun SearchBar(
    query: String,
    onQueryChange: (String) -> Unit,
    modifier: Modifier = Modifier
) {
    TextField(
        value = query,
        onValueChange = onQueryChange,
        placeholder = { Text("Search...") },
        modifier = modifier
    )
}

// The caller owns the state
@Composable
fun SearchScreen(viewModel: SearchViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    SearchBar(
        query = uiState.query,
        onQueryChange = viewModel::onQueryChange
    )
}

Now the state lives in one place (the ViewModel), and SearchBar is a pure function of its inputs — trivial to test and preview.

ViewModel as SSOT for UI State

The ViewModel is the canonical SSOT for everything the current screen needs to display. It aggregates data from repositories and exposes a single uiState stream.

The encapsulation pattern: private MutableStateFlow, public StateFlow

@HiltViewModel
class ProductListViewModel @Inject constructor(
    private val productRepository: ProductRepository
) : ViewModel() {

    // Private mutable — only this ViewModel can write
    private val _uiState = MutableStateFlow(ProductListUiState())

    // Public immutable — UI can only read
    val uiState: StateFlow<ProductListUiState> = _uiState.asStateFlow()

    init {
        loadProducts()
    }

    private fun loadProducts() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }

            productRepository.getProducts()
                .catch { throwable ->
                    _uiState.update { it.copy(
                        isLoading = false,
                        errorMessage = throwable.message
                    )}
                }
                .collect { products ->
                    _uiState.update { it.copy(
                        isLoading = false,
                        products = products,
                        errorMessage = null
                    )}
                }
        }
    }

    fun onSearchQueryChanged(query: String) {
        _uiState.update { it.copy(searchQuery = query) }
    }
}

data class ProductListUiState(
    val isLoading: Boolean = false,
    val products: List<Product> = emptyList(),
    val searchQuery: String = "",
    val errorMessage: String? = null
)

Why asStateFlow() matters: without it, the UI could cast uiState back to MutableStateFlow and write to it directly, bypassing the ViewModel. The asStateFlow() call returns a read-only wrapper that prevents this.

Derived state — do not duplicate

If a value can be computed from existing state, never store it separately:

// BAD — filteredProducts is a copy that can drift
data class ProductListUiState(
    val products: List<Product> = emptyList(),
    val searchQuery: String = "",
    val filteredProducts: List<Product> = emptyList() // duplicate!
)

// GOOD — derive at the point of use
data class ProductListUiState(
    val products: List<Product> = emptyList(),
    val searchQuery: String = ""
) {
    val filteredProducts: List<Product>
        get() = if (searchQuery.isBlank()) products
                else products.filter { it.name.contains(searchQuery, ignoreCase = true) }
}

Database as SSOT for Persistent Data

For data that must persist, the Room database (not the in-memory list) is the source of truth. The correct pattern: write to database, observe the database stream, let the UI react automatically.

@Dao
interface TaskDao {
    @Query("SELECT * FROM tasks ORDER BY createdAt DESC")
    fun observeAll(): Flow<List<Task>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(task: Task)

    @Update
    suspend fun update(task: Task)
}

class TaskRepository @Inject constructor(private val dao: TaskDao) {
    // Repository exposes the DB stream directly — DB is the SSOT
    val tasks: Flow<List<Task>> = dao.observeAll()

    suspend fun addTask(task: Task) {
        dao.insert(task) // write to DB
        // DO NOT also update an in-memory list here — the Flow handles it
    }

    suspend fun toggleComplete(task: Task) {
        dao.update(task.copy(isComplete = !task.isComplete))
        // The Flow emits the new list automatically
    }
}

Any write goes to the database; any read comes from the database. There is no separate in-memory list to keep in sync.

What Happens When State Is Duplicated

Here is a real-world example of sync bugs caused by SSOT violations:

// ANTI-PATTERN: state in two places
class CartViewModel : ViewModel() {
    private val _cartItems = MutableStateFlow<List<CartItem>>(emptyList())
    val cartItems: StateFlow<List<CartItem>> = _cartItems.asStateFlow()

    // BAD: a separate variable for item count
    private var _itemCount = MutableStateFlow(0)
    val itemCount: StateFlow<Int> = _itemCount.asStateFlow()

    fun addToCart(item: CartItem) {
        val updated = _cartItems.value + item
        _cartItems.value = updated
        // Oops — forgot to update _itemCount in some code paths
        // Now cartItems.size != itemCount
    }

    fun removeFromCart(item: CartItem) {
        _cartItems.value = _cartItems.value - item
        // Forgot _itemCount update entirely — bug!
    }
}

// CORRECT: derive itemCount from the single list
class CartViewModel : ViewModel() {
    private val _cartItems = MutableStateFlow<List<CartItem>>(emptyList())
    val cartItems: StateFlow<List<CartItem>> = _cartItems.asStateFlow()

    // Derived — always consistent, zero maintenance
    val itemCount: StateFlow<Int> = _cartItems
        .map { it.size }
        .stateIn(viewModelScope, SharingStarted.Eagerly, 0)

    fun addToCart(item: CartItem) {
        _cartItems.update { it + item }
        // itemCount updates automatically
    }

    fun removeFromCart(item: CartItem) {
        _cartItems.update { it - item }
        // itemCount updates automatically
    }
}

Sharing State Across Multiple ViewModels

When two screens need the same data, the source of truth should be a shared repository, not one ViewModel copying data to another:

// Shared source of truth at the repository layer
class UserRepository @Inject constructor(
    private val userDao: UserDao,
    private val api: UserApi
) {
    // All ViewModels that need user data observe this single stream
    val currentUser: Flow<User?> = userDao.observeCurrentUser()

    suspend fun refreshUser() {
        val user = api.getMe()
        userDao.insert(user)
        // Both screens react automatically via their collected flows
    }
}

@HiltViewModel
class ProfileViewModel @Inject constructor(userRepository: UserRepository) : ViewModel() {
    val uiState = userRepository.currentUser
        .map { user -> ProfileUiState(user) }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ProfileUiState())
}

@HiltViewModel
class HeaderViewModel @Inject constructor(userRepository: UserRepository) : ViewModel() {
    val avatarUrl = userRepository.currentUser
        .map { it?.avatarUrl }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
}

StateFlow vs SharedFlow for SSOT

FeatureStateFlowSharedFlow
Initial value requiredYesNo
Replays latest value to new collectorsAlways (1)Configurable
Suitable for UI stateYesRarely
Suitable for one-off eventsNoYes
.value propertyYesNo

StateFlow is almost always the right choice for UI state because new collectors (e.g., after screen rotation) immediately receive the current state without waiting for the next emission.

Collecting State Safely in the UI

// Fragment
class ProductListFragment : Fragment() {
    private val viewModel: ProductListViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // collectAsStateWithLifecycle stops collection when UI is not visible
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { state ->
                    renderState(state)
                }
            }
        }
    }
}

// Compose — equivalent approach
@Composable
fun ProductListScreen(viewModel: ProductListViewModel = hiltViewModel()) {
    // collectAsStateWithLifecycle is the Compose equivalent of repeatOnLifecycle(STARTED)
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    ProductListContent(uiState)
}

Key Takeaways

ConceptRule
SSOTEach piece of data has exactly one authoritative owner
State hoistingMove state up so composables are stateless functions of inputs
MutableStateFlowKeep private; expose as StateFlow via asStateFlow()
Derived valuesCompute from the source; never store a duplicate
DatabaseWrite-through pattern: write to DB, read from DB Flow
Shared dataPut SSOT in repository, not in one ViewModel talking to another
StateFlowPreferred for UI state — replays latest value to new collectors

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Single Source of Truth & Hoisting | Android System Design | Android Engineers