Bỏ qua để đến nội dung

Side Effects

Side effects là các thao tác ảnh hưởng đến bên ngoài scope của composable function. Compose cung cấp các APIs đặc biệt để xử lý side effects một cách an toàn.

Side effect là bất kỳ thay đổi nào visible bên ngoài composable function:

  • Gọi API hoặc database
  • Cập nhật shared state
  • Ghi log, analytics
  • Đăng ký/hủy đăng ký listeners
  • Navigate

Tại sao Composable không nên có Side Effects?

Phần tiêu đề “Tại sao Composable không nên có Side Effects?”

Composables có thể:

  • Chạy nhiều lần (recomposition)
  • Chạy song song trên nhiều threads
  • Bị hủy giữa chừng
  • Chạy theo bất kỳ thứ tự nào
// ❌ SAI: Side effect trực tiếp trong composable
@Composable
fun BadExample(viewModel: MyViewModel) {
viewModel.loadData() // Gọi mỗi recomposition!
Text("Loading...")
}
// ✅ ĐÚNG: Dùng Effect API
@Composable
fun GoodExample(viewModel: MyViewModel) {
LaunchedEffect(Unit) {
viewModel.loadData() // Chỉ gọi 1 lần
}
Text("Loading...")
}

LaunchedEffect tạo coroutine scope và chạy suspend functions:

@Composable
fun SnackbarDemo(message: String?) {
val snackbarHostState = remember { SnackbarHostState() }
// Chạy khi message thay đổi (và không null)
LaunchedEffect(message) {
message?.let {
snackbarHostState.showSnackbar(it)
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }
) { }
}
LaunchedEffect enters composition
├── Coroutine bắt đầu
├── Key thay đổi?
│ ├── Yes: Cancel coroutine cũ → Start mới
│ └── No: Tiếp tục chạy
└── Leave composition → Cancel coroutine
// Key là Unit: Chỉ chạy 1 lần khi enter composition
LaunchedEffect(Unit) {
loadInitialData()
}
// Key là state: Restart khi state thay đổi
LaunchedEffect(userId) {
loadUserData(userId)
}
// Nhiều keys: Restart khi bất kỳ key nào thay đổi
LaunchedEffect(userId, filter) {
loadFilteredData(userId, filter)
}
// Key là true: "Bật/tắt" effect
LaunchedEffect(isPolling) {
if (isPolling) {
while (true) {
pollData()
delay(5000)
}
}
}
@Composable
fun PulsingDot(pulseRateMs: Long) {
val alpha = remember { Animatable(1f) }
LaunchedEffect(pulseRateMs) {
while (isActive) {
delay(pulseRateMs)
alpha.animateTo(0.3f)
alpha.animateTo(1f)
}
}
Box(
modifier = Modifier
.size(50.dp)
.alpha(alpha.value)
.background(Color.Red, CircleShape)
)
}

3. rememberCoroutineScope - Coroutine từ callbacks

Phần tiêu đề “3. rememberCoroutineScope - Coroutine từ callbacks”

Khi bạn cần launch coroutine từ event callback (onClick, etc.):

@Composable
fun ScrollToTopButton(listState: LazyListState) {
// Tạo scope gắn với lifecycle của composable này
val scope = rememberCoroutineScope()
Button(onClick = {
// Launch từ callback, không phải từ composition
scope.launch {
listState.animateScrollToItem(0)
}
}) {
Text("Scroll to Top")
}
}
LaunchedEffect rememberCoroutineScope
Chạy trong composition Chạy từ callbacks
Tự động trigger bởi key Manual trigger
Không dùng được trong onClick Dùng trong onClick

Khi bạn cần cleanup resources khi composable leave composition:

@Composable
fun LifecycleObserver(onStart: () -> Unit, onStop: () -> Unit) {
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> onStart()
Lifecycle.Event.ON_STOP -> onStop()
else -> {}
}
}
// Setup: đăng ký observer
lifecycleOwner.lifecycle.addObserver(observer)
// Cleanup: hủy đăng ký khi leave composition hoặc key thay đổi
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
@Composable
fun BackHandler(enabled: Boolean = true, onBack: () -> Unit) {
val currentOnBack by rememberUpdatedState(onBack)
val backCallback = remember {
object : OnBackPressedCallback(enabled) {
override fun handleOnBackPressed() {
currentOnBack()
}
}
}
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
DisposableEffect(backDispatcher) {
backDispatcher?.addCallback(backCallback)
onDispose {
backCallback.remove() // Cleanup!
}
}
// Update enabled state
LaunchedEffect(enabled) {
backCallback.isEnabled = enabled
}
}

Khi bạn cần publish Compose state ra non-Compose code sau mỗi successful recomposition:

@Composable
fun AnalyticsScreen(screenName: String) {
// Chỉ chạy sau recomposition thành công
SideEffect {
Analytics.setCurrentScreen(screenName)
}
// UI content
}
@Composable
fun CounterWithLogging() {
var count by remember { mutableStateOf(0) }
// Log sau mỗi successful recomposition
SideEffect {
Log.d("Counter", "Current count: $count")
}
Button(onClick = { count++ }) {
Text("Count: $count")
}
}

6. rememberUpdatedState - Giữ reference mới nhất

Phần tiêu đề “6. rememberUpdatedState - Giữ reference mới nhất”

Khi LaunchedEffect chạy lâu, nó có thể giữ reference cũ:

// ❌ SAI: onTimeout là closure capture -> có thể outdated
@Composable
fun Timer(onTimeout: () -> Unit) {
LaunchedEffect(Unit) {
delay(5000)
onTimeout() // Có thể gọi callback cũ!
}
}
// ✅ ĐÚNG: Dùng rememberUpdatedState
@Composable
fun Timer(onTimeout: () -> Unit) {
val currentOnTimeout by rememberUpdatedState(onTimeout)
LaunchedEffect(Unit) {
delay(5000)
currentOnTimeout() // Luôn gọi callback mới nhất!
}
}
  • Callback có thể thay đổi nhưng bạn không muốn restart effect
  • Long-running effects cần giữ reference mới nhất

7. produceState - Chuyển đổi sang Compose State

Phần tiêu đề “7. produceState - Chuyển đổi sang Compose State”

produceState chuyển đổi Flow, callback-based APIs, etc. thành Compose State:

@Composable
fun NetworkImage(url: String): State<ImageResult> {
return produceState<ImageResult>(
initialValue = ImageResult.Loading,
key1 = url
) {
value = try {
val image = imageLoader.load(url)
ImageResult.Success(image)
} catch (e: Exception) {
ImageResult.Error(e)
}
}
}
// Sử dụng
@Composable
fun Avatar(url: String) {
val imageState by NetworkImage(url)
when (val result = imageState) {
is ImageResult.Loading -> CircularProgressIndicator()
is ImageResult.Success -> Image(result.image)
is ImageResult.Error -> ErrorPlaceholder()
}
}
@Composable
fun <T> Flow<T>.collectAsStateWithLifecycle(
initial: T
): State<T> {
val lifecycleOwner = LocalLifecycleOwner.current
return produceState(initialValue = initial) {
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
collect { value = it }
}
}
}

// ❌ SAI: Tính toán mỗi recomposition (dù items không đổi)
@Composable
fun ShoppingCart(items: List<Item>) {
val total = items.sumOf { it.price } // Expensive!
Text("Total: $total")
}
// ✅ ĐÚNG: Chỉ tính lại khi items thay đổi
@Composable
fun ShoppingCart(items: List<Item>) {
val total by remember {
derivedStateOf { items.sumOf { it.price } }
}
Text("Total: $total")
}
@Composable
fun ScrollableFab() {
val listState = rememberLazyListState()
// Chỉ update khi điều kiện thay đổi, không phải mỗi scroll pixel
val showFab by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
Scaffold(
floatingActionButton = {
if (showFab) {
FloatingActionButton(onClick = { }) {
Icon(Icons.Default.KeyboardArrowUp, null)
}
}
}
) {
LazyColumn(state = listState) {
items(100) { Text("Item $it") }
}
}
}

@Composable
fun ScrollAnalytics(listState: LazyListState) {
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.collect { index ->
Analytics.logScroll(index)
}
}
}
@Composable
fun SearchScreen() {
var query by remember { mutableStateOf("") }
var results by remember { mutableStateOf<List<Result>>(emptyList()) }
LaunchedEffect(Unit) {
snapshotFlow { query }
.debounce(300) // Debounce 300ms
.filter { it.length >= 3 }
.distinctUntilChanged()
.collectLatest { searchQuery ->
results = searchApi.search(searchQuery)
}
}
Column {
TextField(value = query, onValueChange = { query = it })
LazyColumn {
items(results) { ResultItem(it) }
}
}
}

Effect Mục đích Key behavior
LaunchedEffect Suspend functions Restart khi key thay đổi
rememberCoroutineScope Coroutine từ callbacks Manual trigger
DisposableEffect Setup/cleanup resources onDispose được gọi
SideEffect Sync với non-Compose Sau mỗi successful recomposition
rememberUpdatedState Giữ reference mới nhất Không trigger restart
produceState Chuyển thành Compose State Automatic State
derivedStateOf Tính toán từ states Lazy evaluation
snapshotFlow State → Flow Emit khi state thay đổi
Cần chạy suspend function?
├── Từ composition → LaunchedEffect
└── Từ callback → rememberCoroutineScope
Cần cleanup?
└── DisposableEffect với onDispose
Cần sync state ra ngoài?
└── SideEffect
Cần chuyển đổi data sources?
├── Non-Compose → Compose State → produceState
├── State → State (derived) → derivedStateOf
└── State → Flow → snapshotFlow