Sitemap

Part 3 — How Can Alerts/Toasts Be Used in Composable Architecture?

12 min readApr 21, 2025

This post introduces how to utilize Alerts (Dialogs)/Toasts (Snackbars) within Composable Architecture.

The basic structure applies the Action-based system introduced in Parts 1 and 2.

This post covers how to centralize Dialogs/Toasts when a Design System is present.

In this post:

  • Content covering the centralization of Alerts/Toasts (Snackbars).
  • Does not explain the previously discussed Actions in detail.
  • Does not cover basic content, so referring to previous posts is recommended.

Previous Posts

한글 포스팅

Centralization using Action

What was the reason for introducing Actions?

  • Case A: Centralization was needed because Alerts/Toasts using the Design System were repeatedly created as SideEffects.
  • Case B: Common SideEffect handling was needed when network errors, etc., occurred.

Ultimately, both lead to questions like “How can we approach this?”, “Is there an easier way?”, and “Is it testable?”.

Example Code Including Both Cases

Scenario: A user presses the send button. Pressing the button displays a “Send this message?” Yes/No Dialog. Any selection other than ‘Yes’ (clicking ‘No’, clicking outside, pressing back) is considered a cancellation. Pressing ‘Yes’ initiates a network API call.

Additional Scenario: What if it fails during the API call and a Snackbar needs to be displayed?

Code in the previous approach: (Example for understanding the overall data flow)

Kotlin

// ViewModel Part
class SendViewModel(/* private val apiRepository: ApiRepository */) : ViewModel() {
// Assume _uiState and its related logic exist as in previous examples
private val _uiState = MutableStateFlow(SendUiState())
val uiState: StateFlow<SendUiState> = _uiState.asStateFlow()

private val _sideEffect = Channel<SendSideEffect>(Channel.BUFFERED)
val sideEffect: Flow<SendSideEffect> = _sideEffect.receiveAsFlow()

// --- Function called when send button is clicked ---
fun onSendClicked() {
// Request dialog immediately, assuming message validation is handled in the confirmation step
sendSideEffect(SendSideEffect.ShowConfirmationDialog)
}

// --- Function called when 'Yes' is pressed in the confirmation dialog ---
fun onConfirmationAccepted() {
val message = _uiState.value.messageToSend
// Check if message is blank (can be done here or before API call)
if (message.isBlank()) {
sendSideEffect(SendSideEffect.ShowSendMessageError("Message cannot be empty"))
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) } // Start loading state
kotlinx.coroutines.delay(1500) // Simulate network communication
val success = false // Simulate API call result (failure case)
_uiState.update { it.copy(isLoading = false) } // End loading state
if (success) {
sendSideEffect(SendSideEffect.ShowSendMessageSuccess("Message sent successfully!"))
_uiState.update { it.copy(messageToSend = "") } // Clear message on success
} else {
// Failure scenario
sendSideEffect(SendSideEffect.ShowSendMessageError("Failed to send message"))
}
}
}

// --- Function called when 'No' or outside click cancels the dialog ---
fun onConfirmationCancelled() {
println("User cancelled message sending.")
}

// --- Internal function to send SideEffect to the channel ---
private fun sendSideEffect(effect: SendSideEffect) {
viewModelScope.launch {
_sideEffect.send(effect)
}
}
}

// SideEffect Definition
sealed interface SendSideEffect {
data object ShowConfirmationDialog : SendSideEffect // Request to show confirmation dialog
data class ShowSendMessageSuccess(val successMessage: String) : SendSideEffect // Request to show success message
data class ShowSendMessageError(val errorMessage: String) : SendSideEffect // Request to show error message
}

// UiState Definition (Needed for ViewModel example)
data class SendUiState(
val isLoading: Boolean = false,
val messageToSend: String = ""
)

Below is the View part of the code.

Kotlin

// Composable for Handling SideEffects
@Composable
fun HandleSideEffects(
sideEffectFlow: Flow<SendSideEffect>, // Receive SideEffect Flow from ViewModel
snackbarHostState: SnackbarHostState, // State object for displaying Snackbars
onShowConfirmationDialog: () -> Unit // Lambda function to display the confirmation dialog
) {
// LaunchedEffect safely executes suspend functions within the Composable's lifecycle.
// key1 = true ensures it runs only once when the Composable is first created.
LaunchedEffect(true) {
// collectLatest: If a new SideEffect arrives, it cancels the previous processing logic (if any) and starts the new one.
sideEffectFlow.collectLatest { effect ->
when (effect) {
// Handle request to show confirmation dialog
is SendSideEffect.ShowConfirmationDialog -> {
onShowConfirmationDialog() // Execute the passed lambda to change dialog display state
}
// Handle request to show success message
is SendSideEffect.ShowSendMessageSuccess -> {
snackbarHostState.showSnackbar(
message = effect.successMessage, // Success message from ViewModel
duration = SnackbarDuration.Short // Display briefly
)
}
// Handle request to show error message
is SendSideEffect.ShowSendMessageError -> {
snackbarHostState.showSnackbar(
message = effect.errorMessage, // Error message from ViewModel
duration = SnackbarDuration.Long // Display longer
)
}
}
}
}
}

// Main Screen Composable
@Composable
fun SendScreen(
modifier: Modifier = Modifier,
viewModel: SendViewModel = viewModel() // Get ViewModel instance
) {
// State variable to manage the visibility of the confirmation dialog
var showDialog by remember { mutableStateOf(false) }
// State object for displaying Snackbar messages
val snackbarHostState = remember { SnackbarHostState() }
// Execute SideEffect handling logic (using a separate Composable)
HandleSideEffects(
sideEffectFlow = viewModel.sideEffect,
snackbarHostState = snackbarHostState,
onShowConfirmationDialog = { showDialog = true } // Change showDialog state to true upon SideEffect
)
// Scaffold: Provides basic Material Design layout structure
Scaffold(
modifier = modifier,
snackbarHost = { SnackbarHost(snackbarHostState) } // Specify where Snackbars will be displayed
) { paddingValues -> // Padding values for the content area within the Scaffold
// UI Skipped (Assume Button, TextField etc. are here using paddingValues)
}
// Display the confirmation dialog only when showDialog state is true
if (showDialog) {
AlertDialog(
onDismissRequest = {
showDialog = false // Close dialog
viewModel.onConfirmationCancelled() // Notify ViewModel of cancellation
},
title = { Text("Confirm Send") }, // Dialog title
text = { Text("Are you sure you want to send this message?") }, // Dialog body
confirmButton = {
Button(
onClick = {
showDialog = false // Close dialog
viewModel.onConfirmationAccepted() // Notify ViewModel of confirmation
}
) { Text("Yes") }
},
dismissButton = {
Button(
onClick = {
showDialog = false // Close dialog
viewModel.onConfirmationCancelled() // Notify ViewModel of cancellation
}
) { Text("No") }
}
)
}
}

The code above is a typical implementation handling the two scenarios described earlier (failure after confirmation, or failure during confirmation). It receives user confirmation, starts the network API call, and processes the result.

Finding Common Parts

Looking at this flow, one Dialog can be displayed, and two types of Snackbars (success/failure) can operate.

Based on the code and flow chart above, let’s identify parts that occur commonly.

  • N Views (@Composable) need to display a Dialog with the same UI/behavior.
  • N Views (@Composable) require Snackbar(Toast) with the same behavior (like simple message display).

I considered the repetition across N screens important here and wanted to centralize and reduce this.

UI Centralization

The Alert/Toast(Snackbar) parts were centralized through a separate module.

composable-architecture-alert-system — link

It was created as a library, leaving the Alert UI customizable. If you want to use a design other than the default Dialog UI, you can use the onDialogScreen parameter.

Kotlin

// Common Composable for handling Alerts/Snackbars
@Composable
fun CaAlertScreen(
snackbarHostState: SnackbarHostState, // Manages Snackbar state
// Lambda provided when you want to customize the Dialog UI
onDialogScreen: (@Composable (caAlertUiStateDialogUiState: CaAlertUiStateDialogUiState, onAction: (nextAction: CaAction) -> Unit) -> Unit)? = null,
) {
// Call internal implementation
InternalCaAlertScreen(
snackbarHostState = snackbarHostState,
onDialogScreen = onDialogScreen,
)
}

If customization is needed, you can create a Composable that draws the Dialog directly and pass it via onDialogScreen.

Reference Custom Dialog Code — link

Kotlin

// Example: Custom Dialog Composable
@Composable
internal fun CustomDialogScreen(
caAlertUiStateDialogUiState: CaAlertUiStateDialogUiState, // Dialog state information
onAction: (nextAction: CaAction) -> Unit, // Function to dispatch Action on button clicks within the Dialog
) {
AlertDialog(
icon = { /* Icon logic */ },
title = { /* Title logic */ },
onDismissRequest = {
// Dispatch the Action defined for dismiss request (outside click or back press)
onAction(caAlertUiStateDialogUiState.onDismissRequest)
},
confirmButton = { /* Confirm button logic, calls onAction(confirmAction) on click */ },
dismissButton = { /* Dismiss button logic, calls onAction(dismissAction) on click */ }
// Omitted
)
}

How does this common UI (CaAlertScreen) operate? It utilizes the UiState and SideEffect approach we generally use. A separate ViewModel (CaAlertViewModel) was created specifically for this common system.

What’s important here is that despite centralization, this CaAlertViewModel doesn't know the logic of specific screens. It simply receives a specific Action called CaAlertAction, creates the corresponding Dialog state (UiState), and sends a SideEffect to display it. It acts merely as a forwarder for events.

The Action system introduced in Parts 1/2 is crucial here. This Action Stream is accessible application-wide (singleton or app-scoped), so any ViewModel or Composable can send a CaAlertAction, and CaAlertViewModel can receive and process it. There's no issue with it operating within the same scope in the View hierarchy.

Kotlin

// ViewModel dedicated to handling Alerts/Snackbars
@HiltViewModel
class CaAlertViewModel @Inject constructor(
private val flowCaActionStream: FlowCaActionStream, // Inject Action stream
) : CaViewModel<CaAlertAction>( // Only receives Actions of type CaAlertAction
flowCaActionStream = flowCaActionStream,
actionClass = CaAlertAction::class,
) {
// UiState holding the content of the Dialog
private val _alertUiStateDialogUiState = MutableStateFlow(CaAlertUiStateDialogUiState.Default)
val alertUiStateDialogUiState = _alertUiStateDialogUiState.asStateFlow()

// SideEffect channel for commands to show/hide Dialogs/Snackbars
private val _sideEffect = Channel<CaAlertSideEffect>(Channel.BUFFERED)
internal val sideEffect = _sideEffect.receiveAsFlow()

// Logic to handle received CaAlertActions (Reducer)
override suspend fun reducer(action: CaAlertAction) {
when (action) {
is CaAlertAction.ShowDialog -> {
// Create Dialog UiState from Action's info
val dialogItem = CaAlertUiStateDialogUiState( /* ... use data from action ... */ )
_alertUiStateDialogUiState.value = dialogItem
// Send SideEffect to show the Dialog
_sideEffect.send(CaAlertSideEffect.ShowDialog)
}
is CaAlertAction.HideDialog -> {
// Revert to default state and send SideEffect to hide Dialog
_alertUiStateDialogUiState.value = CaAlertUiStateDialogUiState.Default
_sideEffect.send(CaAlertSideEffect.HideDialog)
}
is CaAlertAction.ShowSnack -> {
// Create and send Snackbar SideEffect from Action info
val snackItem = CaAlertSideEffect.ShowSnack( /* ... use data from action ... */ )
_sideEffect.send(snackItem)
}
is CaAlertAction.None -> { /* Ignore */ }
}
}

// Function to convert internal Duration type to Material's SnackbarDuration
private fun CaAlertAction.ShowSnack.Duration.convert(): SnackbarDuration = /* ... conversion logic ... */
}

// SideEffect definitions used by CaAlertViewModel
internal sealed interface CaAlertSideEffect {
data object ShowDialog : CaAlertSideEffect
data object HideDialog : CaAlertSideEffect
data class ShowSnack(
val message: String,
val actionLabel: String,
val onAction: CaAction, // Action to dispatch if Snackbar action is performed
val onDismiss: CaAction, // Action to dispatch if Snackbar is dismissed
val duration: SnackbarDuration,
) : CaAlertSideEffect
}

// UiState definition for CaAlertViewModel
internal data class CaAlertUiStateDialogUiState( /* ... Dialog properties ... */ ) {
companion object {
val Default = CaAlertUiStateDialogUiState( /* ... default values ... */ )
}
}

// Action definition received by CaAlertViewModel
sealed interface CaAlertAction : CaAction {
// Action to show Dialog
data class ShowDialog( /* ... Dialog properties and Actions to dispatch on clicks ... */ ) : CaAlertAction
// Action to hide Dialog
data object HideDialog : CaAlertAction
// Action to show Snackbar
data class ShowSnack( /* ... Snackbar properties and Actions to dispatch on action/dismiss ... */ ) : CaAlertAction
// Empty Action
data object None : CaAlertAction
}

The part that receives events from CaAlertViewModel and exposes the actual UI (Dialog, Snackbar) is the InternalCaAlertScreen Composable.

Kotlin

// Internal implementation Composable for CaAlertScreen
@Composable
private fun InternalCaAlertScreen(
snackbarHostState: SnackbarHostState,
onDialogScreen: (@Composable (caAlertUiStateDialogUiState: CaAlertUiStateDialogUiState, onAction: (nextAction: CaAction) -> Unit) -> Unit)? = null,
caAlertViewModel: CaAlertViewModel = viewModel(), // CaAlertViewModel instance
) {
// State for Dialog visibility
var showDialog by remember { mutableStateOf(false) }
// Object for sending Actions (using CompositionLocal)
val actionSender = LocalCaActionOwner.current

// Subscribe to and handle SideEffects from CaAlertViewModel
caAlertViewModel.sideEffect.collectLifecycleEvent { event -> // Assuming collectLifecycleEvent is a Lifecycle-aware extension
when (event) {
is CaAlertSideEffect.ShowDialog -> showDialog = true // Change dialog display state
is CaAlertSideEffect.HideDialog -> showDialog = false // Change dialog hide state
is CaAlertSideEffect.ShowSnack -> {
// Logic to show Snackbar
val result = snackbarHostState.showSnackbar( /* ... use event data ... */ )
// Dispatch subsequent Action based on Snackbar result
when (result) {
SnackbarResult.ActionPerformed -> actionSender.send(event.onAction) // On 'Action' button click
SnackbarResult.Dismissed -> actionSender.send(event.onDismiss) // When dismissed
}
}
}
}

// Subscribe to CaAlertViewModel's Dialog UiState
val caAlertUiStateDialogUiState by caAlertViewModel.alertUiStateDialogUiState.collectAsStateWithLifecycle()

// Display Dialog if showDialog state is true
if (showDialog) {
// Common logic executed on button clicks within the Dialog (Hide dialog, then dispatch the specified next Action)
val onAction: (nextAction: CaAction) -> Unit = { nextAction ->
actionSender.send(CaAlertAction.HideDialog) // First, send Action to hide the dialog
actionSender.send(nextAction) // Then, send the Action specified for the button
}
// Use the custom Dialog lambda if provided, otherwise use the default CaDialogScreen
onDialogScreen?.invoke(caAlertUiStateDialogUiState, onAction)
?: CaDialogScreen(caAlertUiStateDialogUiState = caAlertUiStateDialogUiState, onAction = onAction)
}

// Composable for managing CaAlertViewModel's lifecycle (internal implementation omitted)
LaunchedLifecycleViewModel(viewModel = caAlertViewModel)
}

Now, the side using this common Alert/Snackbar system (e.g., a specific screen’s ViewModel or Composable) simply needs to send a CaAlertAction to the Action stream.

Kotlin

// Example of sending Action from a Composable
val actionSender = LocalCaActionOwner.current
actionSender.send(
CaAlertAction.ShowDialog(
title = "Confirm Send",
message = "Are you sure you want to send this message?",
confirmButtonText = "Yes",
// Define Action to execute on 'Yes' button click (here, an Action to show a Snackbar)
onConfirmButtonAction = CaAlertAction.ShowSnack(message = "Confirmed"),
dismissButtonText = "No",
// Define Action to execute on 'No' button click
onDismissButtonAction = CaAlertAction.ShowSnack(message = "Cancelled"),
// Define Action for dismiss request (clicking outside)
onDismissRequest = CaAlertAction.None // Do nothing
)
)

// Example of sending Action from a ViewModel (assuming a nextAction function exists)
nextAction( // Action dispatch function implemented in a BaseViewModel, etc.
CaAlertAction.ShowDialog( /* ... same as above ... */ )
)

Let’s look at this code’s data flow

Based on the centralized code above, we can consider the data flow for the following scenario:

Scenario:

  • User Action: User clicks the ‘Send’ button (assuming message validation passed).
  • Screen ViewModel: SendViewModel, within its onSendClicked function, dispatches CaAlertAction.ShowDialog to the Action stream.
  • Common Alert ViewModel: CaAlertViewModel receives CaAlertAction.ShowDialog from the Action stream.
  • Dialog State Update: CaAlertViewModel updates its UiState containing the Dialog content and sends a ShowDialog SideEffect.
  • Dialog Display: The CaAlertScreen Composable receives the SideEffect from CaAlertViewModel, changes its showDialog state to true, and renders the Dialog content using the UiState.
  • User Dialog Action: User clicks the ‘Yes’ (Confirm) button in the Dialog.
  • Dialog Internal Handling: The onAction lambda within CaAlertScreen executes.
  • It dispatches CaAlertAction.HideDialog, causing CaAlertViewModel to close the Dialog.
  • It dispatches the CaAlertAction.ShowSnack(message = "Confirmed") which was specified as onConfirmButtonAction when defining the ShowDialog Action.
  • Snackbar Display: CaAlertViewModel receives the ShowSnack Action, sends a ShowSnack SideEffect, and CaAlertScreen receives this to display the Snackbar. (If a different Action, like SendMessageConfirmAction, was specified for the Confirm button, that Action would be dispatched instead).
  • (If network call needed on Confirm): SendViewModel could receive SendMessageConfirmAction, execute its onConfirmationAccepted logic (network call, etc.), and based on the result, potentially dispatch another CaAlertAction.ShowSnack (for success/failure message).

The diagram below illustrates this. Unlike the previous flow, the User Action goes through the ViewModel to dispatch an Action. This Action is picked up by CaAlertViewModel, which handles the UI display via CaAlertScreen. User actions within the Dialog dispatch further Actions, which can be handled by CaAlertViewModel or other ViewModels.

The unique point here is that SendViewModel doesn't send a SideEffect to directly display the Dialog. Instead, it sends a CaAlertAction. This Action is then subscribed to and processed by a separate Dialog handling ViewModel (CaAlertViewModel).

Pros/Cons

Being able to send Actions from anywhere is certainly convenient. However, the drawback is that it can be difficult to trace where the event originated. It’s reminiscent of the old Event Bus pattern. Ultimately, even with filters, similar problems can arise. Additional considerations, such as clearly identifying the origin of Actions during debugging or including origin information within the Action itself, might be necessary.

Next

This post explored the code for centralizing Alert/Toast(Snackbar) handling using Actions. It started from the question, “How could we approach centralization?”

An Action system was created to facilitate this kind of task, which bears similarities to The Composable Architecture (TCA)’s approach. TCA also handles alert-related state changes directly within the Reducer.

Most code we write flows back and forth between View and ViewModel. Dialogs, in particular, often break this direct flow once an event is sent to the View, making it hard for the ViewModel to continue the interaction directly. However, using an Action system allows the result of a Dialog (e.g., confirmation/cancellation) to be converted back into an Action, which another ViewModel can receive to continue subsequent tasks.

On another note, such an Action-based system can also offer testing advantages. If mechanisms like await() (hypothetically implemented) were used to wait for Action processing results, it would enable scenario-based unit testing for a complete cycle. This allows verifying logic sufficiently through tests without needing to validate the View, leading to more stable testing. Since directly applying such an await mechanism could complicate the design, the initial approach shown here focuses on enabling different ViewModels to subscribe and handle events via Actions, creating a testable structure.

The next post will cover: ‘How can we test this structure?’

Following Posts

작성 글 이어보기

--

--

No responses yet