Stop Passing multiple Callbacks in Jetpack Compose

Do that only if it looks better to you and it’s not some overkill

I saw somewhere horror composable like this:

@Composable
fun HomeFeedWithArticleDetailsScreen(
    uiState: HomeUiState,
    showTopAppBar: Boolean,
    onToggleFavorite: (String) -> Unit,
    onSelectPost: (String) -> Unit,
    onRefreshPosts: () -> Unit,
    onErrorDismiss: (Long) -> Unit,
    onInteractWithList: () -> Unit,
    onInteractWithDetail: (String) -> Unit,
    openDrawer: () -> Unit,
    homeListLazyListState: LazyListState,
    articleDetailLazyListStates: Map<String, LazyListState>,
    snackbarHostState: SnackbarHostState,
    modifier: Modifier = Modifier,
    onSearchInputChanged: (String) -> Unit,
) {

And I created a horror one my self.

So, at the beginning there was this:

@Composable
fun NotesListScreen(
    viewModel: NotesListViewModel = hiltViewModel(),
    ...
) {
  
  ...
  
  SearchFieldWithCloseButton(
      ...
      onSearchTextChanged = { newText ->
          searchText = newText
          viewModel.performSearchNotes(newText)
      },
      onShowSearchFieldChanged = { newValue ->
          showSearchField = newValue
      },
      onSearchFieldClosed = {
          showSearchField = false
          searchText = ""
          viewModel.getNotes(sortingOptionState.value)
      }
  )
  
  ...
  
}

And in horror one – SearchFieldWithCloseButton,

@Composable
fun SearchFieldWithCloseButton(
    ...
    onSearchTextChanged: (String) -> Unit,
    onShowSearchFieldChanged: (Boolean) -> Unit,
    onSearchFieldClosed: () -> Unit
) {
    IconButton(onClick = { 
        onShowSearchFieldChanged(true) 
    }) {
        ...
    }

    if (showSearchField) {
        SearchTextField(
            searchText = searchText,
            onSearchTextChanged = { newText ->
                onSearchTextChanged(newText)
            },
            onSearchFieldClosed = { 
                onShowSearchFieldChanged(false)
                onSearchFieldClosed()
            }
        )
    }
}

there was another horror like – SearchTextField:

@Composable
fun SearchTextField(
    ...
    onSearchTextChanged: (String) -> Unit,
    onSearchFieldClosed: () -> Unit
) {
    ...
        BasicTextField(
            value = searchText,
            onValueChange = onSearchTextChanged,
            ...
        )

        IconButton(
            onClick = {
                onSearchFieldClosed()
            }
        ) {
            Icon(
                ...
            )
        }
    }
}

To lower the horror feeling I had to replace all three callbacks by one = “action”:

@Composable
fun NotesListScreen(
    viewModel: NotesListViewModel = hiltViewModel(),
    ...
) {
  
  ...
                   
  SearchFieldWithCloseButton(
      ...
      onAction = { action ->
          when (action) {
              ...
          }
      }
  )
  
  ...
}

@Composable
fun SearchFieldWithCloseButton(
    ...
    onAction: (NoteListAction) -> Unit
) {
    IconButton(onClick = { 
        onAction(NoteListAction.ShowSearchFieldChanged(true)) 
    }) {
        ...
    }

    if (showSearchField) {
        SearchTextField(
            ...
            onAction = onAction
        )
    }
}

@Composable
fun SearchTextField(
    ...
    onAction: (NoteListAction) -> Unit
) {
    ...
    
        BasicTextField(
            ...
            onValueChange = { newText ->
                onAction(NoteListAction.SearchTextChanged(newText))
            },
            ...
        )

        ...

        IconButton(
            onClick = {
                onAction(NoteListAction.ShowSearchFieldChanged(false))
                onAction(NoteListAction.SearchFieldClosed)
            }
        ) {
            ...
        }
    }
}

And NoteListAction is a sealed class defined at the top of NotesListScreen:

sealed class NoteListAction {
    data class SearchTextChanged(val newText: String) : NoteListAction()
    data class ShowSearchFieldChanged(val newValue: Boolean) : NoteListAction()
    data object SearchFieldClosed : NoteListAction()
}

And the only thing that “bothers” me a little is when block, but it’s ok at the end:

val performSearchNotes = viewModel::performSearchNotes
val getNotes = viewModel::getNotes

SearchFieldWithCloseButton(
    ...
    onAction = { action ->
        when (action) {
            is NoteListAction.SearchTextChanged -> {
                ...
                performSearchNotes(action.newText)
            }
            is NoteListAction.ShowSearchFieldChanged -> {
                ...
            }
            is NoteListAction.SearchFieldClosed -> {
                ...
                getNotes(sortingOptionState.value)
            }
        }
    }
)

More about view model methods reference calls in separate blog post.

So, yeah, let us conclude something at the end:

Using a Single Unified Callback (with a Sealed Class or Similar Approach)

Pros:

  • Scalability and Maintenance: Reducing the callback interface to a single point simplifies the component’s usage and scales better with complexity. Adding new actions doesn’t require changes to the composable’s parameter list.
  • Flexibility: Encapsulates all user actions into a single stream, providing flexibility in handling them, which is particularly useful in more complex components or when using a state management pattern (like MVVM, MVI or Redux).
  • Reduced Coupling: Offers a more decoupled design, as the composable doesn’t need to know about specific state management or actions beyond sending event notifications.

Cons:

  • Slightly More Complex to Implement: Requires setting up a sealed class and handling actions through a when statement, which might be overkill for very simple components.
  • Indirect Action Handling: The parent component or view model needs to interpret the actions, which adds a layer of indirection. This is generally not a problem but is a consideration in the design.

Using Multiple Callbacks

Pros:

  • Clarity and Simplicity for Simple Components: When a composable performs a few, distinctly different actions, having separate callbacks can make the component’s API clear and straightforward to use.
  • Granularity: It allows the parent component to handle different events distinctly right at the point of definition, which might be preferable for simple interactions that don’t warrant a more elaborate setup.

Cons:

  • Bloat in Parameters: For components with numerous interactions, passing multiple callbacks can clutter the composable’s signature, making it cumbersome to use and maintain.
  • Increased Coupling: Each callback often directly ties to specific state or behavior in the parent, potentially leading to tighter coupling between the composable and its context.

Conclusion

  • For simple components with a few distinct interactions, multiple callbacks might be the clearer, more straightforward choice.
  • For complex components with many potential interactions or when aiming for a highly maintainable and scalable architecture, a single unified callback is often better.

In practice, the choice often comes down to the specific needs of your project, your team’s coding style preferences, and the complexity of the component you’re developing. The unified callback approach is particularly appealing for larger, more complex applications where maintainability, scalability, and flexibility are paramount.

Leave a Reply