Likes and comments are high-frequency interactions. The gold standard UX: instant optimistic update on tap, graceful rollback on failure, and realtime increments from other users via WebSocket.
Like System: Optimistic Update
data class Post(
val id: String,
val authorName: String,
val imageUrl: String,
val likeCount: Int,
val isLikedByMe: Boolean,
// For display: show "You and 42 others" vs "43 likes"
)
@HiltViewModel
class PostViewModel @Inject constructor(
private val repository: PostRepository,
private val analytics: AnalyticsTracker
) : ViewModel() {
private val _posts = MutableStateFlow<List<Post>>(emptyList())
val posts: StateFlow<List<Post>> = _posts
// In-flight like requests — prevent double-tap races
private val pendingLikes = ConcurrentHashMap<String, Job>()
fun onLikeTapped(postId: String) {
// Cancel pending request for this post (debounce)
pendingLikes[postId]?.cancel()
val current = _posts.value.find { it.id == postId } ?: return
val wasLiked = current.isLikedByMe
// Optimistic update
updatePost(postId) { post ->
post.copy(
isLikedByMe = !wasLiked,
likeCount = if (wasLiked) post.likeCount - 1 else post.likeCount + 1
)
}
pendingLikes[postId] = viewModelScope.launch {
try {
if (wasLiked) {
repository.unlike(postId)
} else {
repository.like(postId)
analytics.track(AnalyticsEvent.PostLiked(postId))
}
pendingLikes.remove(postId)
} catch (e: Exception) {
// Rollback on failure
updatePost(postId) { post ->
post.copy(
isLikedByMe = wasLiked,
likeCount = if (wasLiked) post.likeCount + 1 else post.likeCount - 1
)
}
// Show error snackbar
_effects.emit(UiEffect.ShowSnackbar("Couldn't update like. Try again."))
}
}
}
private fun updatePost(postId: String, transform: (Post) -> Post) {
_posts.value = _posts.value.map { if (it.id == postId) transform(it) else it }
}
}
Realtime Like Count via WebSocket
class FeedRealtimeUpdater @Inject constructor(
private val webSocket: FeedWebSocket,
private val viewModel: PostViewModel
) {
fun start() = viewModelScope.launch {
webSocket.events().collect { event ->
when (event) {
is FeedEvent.LikeCountUpdate -> {
viewModel.applyServerLikeCount(event.postId, event.count, event.isLikedByViewer)
}
is FeedEvent.NewComment -> {
viewModel.applyNewComment(event.postId, event.comment)
}
}
}
}
}
// In ViewModel: accept server truth without animation
fun applyServerLikeCount(postId: String, count: Int, isLikedByViewer: Boolean) {
updatePost(postId) { it.copy(likeCount = count, isLikedByMe = isLikedByViewer) }
}
Comment System
data class Comment(
val id: String,
val postId: String,
val authorId: String,
val authorName: String,
val authorAvatarUrl: String,
val text: String,
val createdAt: Long,
val isPending: Boolean = false // for optimistic display
)
fun onCommentSubmit(postId: String, text: String) {
if (text.isBlank()) return
val tempId = "pending_${System.currentTimeMillis()}"
val optimisticComment = Comment(
id = tempId,
postId = postId,
authorId = currentUser.id,
authorName = currentUser.name,
authorAvatarUrl = currentUser.avatarUrl,
text = text,
createdAt = System.currentTimeMillis(),
isPending = true
)
// Add optimistic comment immediately
_comments.value = _comments.value + optimisticComment
viewModelScope.launch {
try {
val serverComment = repository.postComment(postId, text)
// Replace pending with server version
_comments.value = _comments.value.map {
if (it.id == tempId) serverComment else it
}
} catch (e: Exception) {
// Remove the pending comment on failure
_comments.value = _comments.value.filter { it.id != tempId }
_effects.emit(UiEffect.ShowSnackbar("Comment failed. Try again."))
}
}
}
Like Button Animation
@Composable
fun LikeButton(isLiked: Boolean, likeCount: Int, onLike: () -> Unit) {
val scale = remember { Animatable(1f) }
val scope = rememberCoroutineScope()
val color by animateColorAsState(
targetValue = if (isLiked) Color.Red else Color.Gray,
animationSpec = tween(200),
label = "like_color"
)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = if (isLiked) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = if (isLiked) "Unlike" else "Like",
tint = color,
modifier = Modifier
.scale(scale.value)
.clickable {
scope.launch {
scale.animateTo(1.3f, spring(dampingRatio = Spring.DampingRatioMediumBouncy))
scale.animateTo(1f)
}
onLike()
}
)
Text(
text = likeCount.toString(),
style = MaterialTheme.typography.labelSmall
)
}
}
Key Takeaways
| Pattern | Rule |
|---|---|
| Optimistic like | Update UI immediately; rollback on API failure |
ConcurrentHashMap for pending likes | Cancel previous request on double-tap |
| Debounce double-taps | Cancel old like coroutine before issuing new request |
| WebSocket for live counts | Push count updates when other users like; reconcile with optimistic state |
| Pending comments | Show immediately with isPending = true; replace with server version |
| Bounce animation | Animatable scale 1.0 → 1.3 → 1.0 on tap; adds delight without performance cost |