# State Layer

The state layer manages reactive chat state — channels, messages, typing indicators, read receipts, and more. It automatically updates when data changes, whether from user actions or real-time server events.

## Setup

State management is built into the `stream-chat-android-client` library. No separate dependency is needed.

To configure state behavior, use the `config()` method on `ChatClient.Builder`:

<Tabs>

<Tab value="kotlin" label="Kotlin">

```kotlin
ChatClient.Builder(apiKey, context)
    .config(
        ChatClientConfig(
            // Enables/disables tracking online states for users
            userPresence = true,
            // Enables/disables automatic sync on reconnect
            isAutomaticSyncOnReconnectEnabled = true,
            // Sets the maximum age threshold for pending local operations before they are discarded
            syncMaxThreshold = TimeDuration.hours(12),
            // Configuration for message limits in memory
            messageLimitConfig = MessageLimitConfig(
                // Set a limit of 500 messages for `livestream` channels
                channelMessageLimits = setOf(ChannelMessageLimit("livestream", 500)),
            ),
            // When true, uses the legacy channel state management logic (v6 behavior)
            useLegacyChannelLogic = false,
        )
    )
    .build()
```

</Tab>

<Tab value="java" label="Java">

```java
// Configuration for message limits in memory
Set<ChannelMessageLimit> channelMessageLimits = new HashSet<>();
channelMessageLimits.add(new ChannelMessageLimit("livestream", 500));
MessageLimitConfig messageLimitConfig = new MessageLimitConfig(channelMessageLimits);

new ChatClient.Builder(apiKey, context)
        .config(new ChatClientConfig(
                true,  // offlineEnabled
                Collections.emptySet(),  // ignoredOfflineChannelTypes
                true,  // userPresence
                true,  // isAutomaticSyncOnReconnectEnabled
                TimeDuration.Companion.hours(12),  // syncMaxThreshold
                System::currentTimeMillis,  // now
                messageLimitConfig,
                false  // useLegacyChannelLogic (default: false)
        ))
        .build();
```

</Tab>

</Tabs>

The `ChatClientConfig` allows you to configure the following options:

- **userPresence**: Controls whether the SDK subscribes to and processes user presence events (online/offline status, last active time). When enabled (default: `true`), the SDK receives real-time updates about user presence changes and updates the user objects in channels, members, and watchers accordingly. This affects both WebSocket event subscriptions and the `presence` parameter in API requests. Disabling this can reduce network traffic and processing overhead if your application doesn't need to display user online/offline status.
- **isAutomaticSyncOnReconnectEnabled**: Specifies if local data is updated with subscribing to web-socket events after reconnection. When turning this off, it is up for SDK user to observe web-socket connection state for reconnection and syncing data by calling, for example `ChatClient.queryChannels`, or the state layer methods such as `ChatClient.queryChannelsAsState`/`ChatClient.watchChannelAsState`.
- **syncMaxThreshold**: The maximum age threshold for pending local operations (channels, messages, reactions) before they are considered too old to retry and are discarded. Default is 12 hours. When the SDK attempts to retry failed operations (e.g., sending a message, creating a channel, adding a reaction) upon reconnection, it checks if the operation's timestamp (createdLocallyAt, updatedLocallyAt, deletedAt, or createdAt) exceeds this threshold. If it does, the operation is removed from the local database instead of being retried, preventing the SDK from attempting to sync stale operations that are no longer relevant.
- **now**: A function that provides the current time in milliseconds since epoch (Unix timestamp). Defaults to `System.currentTimeMillis`. This is used throughout the state layer for time-based operations such as calculating sync thresholds. In general, this is not something that needs to be overridden unless you have specific requirements for time handling in your application.
- **messageLimitConfig**: Configuration that controls the maximum number of messages kept in memory for different channel types. This helps manage memory usage in channels with large message histories. When the number of messages exceeds the configured limit (plus a buffer), older messages are automatically trimmed from the in-memory state. By default, no limits are applied, meaning all messages are kept in memory. See `MessageLimitConfig` and `ChannelMessageLimit` for configuration details.
- **useLegacyChannelLogic**: Controls which channel state management logic the SDK uses. When set to `false` (default), the SDK uses the new active-window pagination model, which is optimized for memory and network usage — it loads only the requested message page and paginates forward/backward on scroll. When set to `true`, the SDK uses the legacy logic from v6 which eagerly fills gaps between the current page and the latest messages. See [Message Loading Behavior](#message-loading-behavior-jump-to-message) for details on the behavioral differences and what to consider when using the default.

## Accessing state

If you are using our XML or Compose UI Components - no further setup is required. The UI components use the state layer internally to render the chat state.

If you are building your own UI layer, you can access the state using the extension functions provided by the client. These functions allow you to query channels, watch channels and watch threads as state objects that can be observed for changes.

### Query channels

To fetch a list of channels, while also receiving real-time updates for it, the state layer provides the `ChatClient.queryChannelsAsState` extension function. It accepts a `QueryChannelsRequest` specifying the query criteria, and it returns a `StateFlow` of `QueryChannelsState`. You can collect this state flow to observe changes to the list of channels matching the query.

```kotlin
// Define the query request
val request = QueryChannelsRequest(
    filter = Filters.and(
        Filters.eq("type", "messaging"),
        Filters.`in`("members", listOf(currentUserId)),
    ),
    limit = 10,
    querySort = QuerySortByField.descByName("lastMessageAt")
).apply {
    watch = true
    state = true
}

// Obtain the StateFlow for the query channels state
val channelsStateFlow: StateFlow<QueryChannelsState?> =
    chatClient.queryChannelsAsState(request)

// Collect the state flow to observe changes
channelsStateFlow
    .filterNotNull()
    .collectLatest { state ->
        // Update your UI with the new state
    }
```

The `QueryChannelsState` exposes multiple observable properties such as:

- `channelsStateData`: A `StateFlow` containing the current state of the query. Can be one of the following:
  - `ChannelsStateData.NoQueryActive`: No query has been executed yet.
  - `ChannelsStateData.Loading`: The query is currently loading.
  - `ChannelsStateData.OfflineNoResults`: No connectivity and no offline results are available.
  - `ChannelsStateData.Result`: A successful result containing the list of channels matching the query.
- `nextPageRequest`: A `StateFlow` containing the next page request if more channels are available for pagination.
- `loadingMore`: A `StateFlow` indicating whether more channels are currently being loaded.
- `endOfChannels`: A `StateFlow` indicating whether the end of the channel list has been reached.

#### Pagination

A common use case when querying channels is to load more channels as the user scrolls. You can use the `nextPageRequest` property to get the next page request, and then call `ChatClient.queryChannels` using the provided request to load more channels. The newly loaded channels will be automatically reflected in the already observed `QueryChannelsState` object.

```kotlin
val nextPageRequest = channelsStateFlow.value?.nextPageRequest?.value ?: return
chatClient.queryChannels(nextPageRequest).enqueue()
```

For a detailed example of querying channels and displaying them in a Compose UI, see [Custom Channel List](https://getstream.io/chat/docs/sdk/android/compose-cookbook/custom-channel-list/).

#### Custom event handling

The `queryChannelsAsState` also accepts an optional `ChatEventHandlerFactory` parameter. This allows you to introduce custom handling of chat events which would affect the channel list state. This is usually not needed, as the default event handling covers most use cases. A common use case for this is when you want to observe two or more different channel lists at the same time—you can create an event handler factory that scopes the events to only the relevant channel list. For more details, see [Channels State and Filtering](https://getstream.io/chat/docs/sdk/android/client/channels/).

### Watch channel

To get the data for a specific channel (including messages, members and watchers), while also receiving real-time updates, you can use the `ChatClient.watchChannelAsState` extension function. This function accepts the ID of the channel to watch, and a message limit for the initial load. It returns a `StateFlow` of `ChannelState` that you can collect to observe changes to the channel state.

```kotlin
val cid = "messaging:123"
val limit = 30

// Obtain the StateFlow for the channel state
val channelStateFlow: StateFlow<ChannelState?> =
    chatClient.watchChannelAsState(cid, limit)

// Collect the state flow to observe changes
channelStateFlow
    .filterNotNull()
    .collectLatest { state ->
        // Update your UI with the new channel state
    }
```

The `ChannelState` exposes multiple observable properties such as:

- `messagesState`: A `StateFlow` containing the current state of messages in the channel. Can be one of the following:
  - `MessagesState.NoQueryActive`: No query is currently running.
  - `MessagesState.Loading`: Messages are currently being loaded.
  - `MessagesState.OfflineNoResults`: No connectivity and no offline messages are available.
  - `MessagesState.Result`: A successful result containing the list of currently loaded messages in the channel.
- `messages`: A `StateFlow` containing the raw list of currently loaded messages in the channel.
- `loadingOlderMessages`: A `StateFlow` indicating whether older messages are currently being loaded.
- `loadingNewerMessages`: A `StateFlow` indicating whether newer messages are currently being loaded.
- `endOfOlderMessages`: A `StateFlow` indicating whether the beginning of the message list has been reached.
- `endOfNewerMessages`: A `StateFlow` indicating whether the end of the message list has been reached.
- `pinnedMessages`: A `StateFlow` containing the list of currently loaded pinned messages in the channel.
- `members`: A `StateFlow` containing the list of currently loaded members in the channel.
- `watchers`: A `StateFlow` containing the list of currently loaded watchers in the channel.
- `unreadCount`: A `StateFlow` containing the unread message count for the current user in the channel.

#### Pagination

To handle the pagination of messages, the state layer exposes several helper methods:

- `ChatClient.loadOlderMessages(cid: String, messageLimit: Int)`: Loads older messages for the specified channel. It will load messages older than the currently loaded oldest message.
- `ChatClient.loadNewerMessages(cid: String, baseMessageId: String, messageLimit: Int)`: Loads newer messages for the specified channel. It will load messages newer than the provided base message ID.
- `ChatClient.loadMessagesAroundId(cid: String, messageId: String)`: Loads messages around a specific message ID for the specified channel. Useful for jumping to a specific message in the channel (ex. jumping to a pinned message or a quoted message).

For a detailed example of watching a channel and displaying its messages in a Compose UI, see [Custom Message List](https://getstream.io/chat/docs/sdk/android/compose-cookbook/custom-message-list/).

#### Message Loading Behavior (Jump to Message)

When using `loadMessagesAroundId` to jump to a specific message (e.g., a pinned message, a quoted message, or a search result), v7 uses an **active-window pagination model** by default. This means the SDK loads only the requested message page and lets the user paginate forward or backward by scrolling — it does **not** eagerly load all pages between the jumped-to message and the latest messages.

This is an optimization over the v6 behavior, which would recursively fetch all intermediate pages to fill the gap. The new model reduces the number of API calls and memory usage, especially when jumping to messages deep in the history.

**Impact when sending messages from a non-latest page**

Because the SDK no longer eagerly loads all messages up to the latest, it is possible that the user is viewing a "middle page" — a page of messages that is not at the bottom of the channel. In this scenario, `ChannelState.endOfNewerMessages` will emit `false`.

If the user sends a new message while viewing a middle page, the sent message will be appended to the server, but the local message list may not include the latest messages, resulting in a visible gap. To prevent this, you must load the latest messages when sending a message from a non-latest page.

<Admonition type="info">

If you are using the Stream UI Components (Compose or XML), this is handled automatically — the `MessageComposer` calls `loadNewestMessages` before sending when the user is not viewing the latest page. This section only applies if you are building your own UI layer using the state extensions directly.

</Admonition>

**Handling message sending from a non-latest page**

Observe the `ChannelState` once when the screen is initialized, and check `endOfNewerMessages` before each send:

```kotlin
// Observe the ChannelState (e.g., when the screen is initialized)
val channelStateFlow: StateFlow<ChannelState?> =
    chatClient.watchChannelAsState(cid, messageLimit = 30)

// Collect endOfNewerMessages to keep track of whether we are at the latest page
val endOfNewerMessages: StateFlow<Boolean> = channelStateFlow
    .filterNotNull()
    .flatMapLatest { it.endOfNewerMessages }
    .stateIn(scope, SharingStarted.Eagerly, true)

// When sending a message, load latest messages if not at the newest page
fun sendMessage(message: Message) {
    if (!endOfNewerMessages.value) {
        chatClient.loadNewestMessages(cid, messageLimit = 30).enqueue()
    }
    chatClient.sendMessage(channelType, channelId, message).enqueue()
}
```

`ChatClient.loadNewestMessages(cid: String, messageLimit: Int)` replaces the current message window with the newest messages from the channel, ensuring the user sees the latest messages alongside their newly sent message.

**Reverting to legacy behavior**

If you prefer the v6 behavior where the SDK eagerly fills the gap between the jumped-to message and the latest messages, you can opt in to the legacy channel logic:

```kotlin
ChatClient.Builder(apiKey, context)
    .config(ChatClientConfig(useLegacyChannelLogic = true))
    .build()
```

<Admonition type="warning">

The legacy channel logic is provided for backward compatibility and may be removed in a future release. We recommend adapting to the new active-window model for better performance and lower memory usage.

</Admonition>

### Query threads

To get a list of threads of which the current user is a participant, while also receiving real-time updates, you can use the `ChatClient.queryThreadsAsState` extension function. This function accepts a `QueryThreadsRequest` specifying the query criteria, and it returns a `StateFlow` of `QueryThreadsState`. You can collect this state flow to observe changes to the list of threads.

```kotlin
// Define the query request
val request = QueryThreadsRequest(
    filter = Filters.eq("channel_cid", "messaging:123"),
    limit = 10,
)

// Obtain the StateFlow for the query threads state
val threadsStateFlow: StateFlow<QueryThreadsState?> =
    chatClient.queryThreadsAsState(request)

// Collect the state flow to observe changes
threadsStateFlow
    .filterNotNull()
    .collectLatest { state ->
        // Update your UI with the new state
    }
```

The `QueryThreadsState` exposes multiple observable properties such as:

- `threads`: A `StateFlow` containing the list of currently loaded thread messages.
- `loading`: A `StateFlow` indicating whether the threads are currently being loaded.
- `loadingMore`: A `StateFlow` indicating whether more threads are currently being loaded.
- `next:` A `StateFlow` containing the next page cursor if more threads are available for pagination.
- `unseenThreadIds`: A `StateFlow` containing the list of thread IDs that are not yet loaded, but have new messages since the current thread list was loaded.

#### Pagination

A common use case when querying threads is to load more threads as the user scrolls. You can use the `next` property to get the next page cursor, and then call `ChatClient.queryThreads` using the provided cursor to load more threads. The newly loaded threads will be automatically reflected in the already observed `QueryThreadsState` object.

```kotlin
val initialRequest = QueryThreadsRequest()
val next = threadsStateFlow.value?.next?.value ?: return
val nextRequest = initialRequest.copy(next = next)
chatClient.queryThreads(nextRequest).enqueue()
```

### Thread replies

To watch the replies of a specific message, while also receiving real-time updates, you can use the `ChatClient.getRepliesAsState` suspend extension function. This function accepts the ID of the thread (ID of the thread parent message), and a message limit for the initial load. It returns a `ThreadState` object that exposes multiple observable properties.

```kotlin
val threadId = "message-123"
val limit = 30

// Obtain the ThreadState for the thread (must be called from a coroutine)
val threadState: ThreadState =
    chatClient.getRepliesAsState(threadId, limit, olderToNewer = false)

// Collect the messages StateFlow to observe changes
threadState
    .messages
    .collectLatest { messages ->
        // Update your UI with the new list of replies
    }
```

The `ThreadState` exposes multiple observable properties such as:

- `messages`: A `StateFlow` containing the list of currently loaded replies in the thread.
- `loading`: A `StateFlow` indicating whether replies are currently being loaded.
- `endOfOlderMessages`: A `StateFlow` indicating whether the beginning of the reply list has been reached.
- `endOfNewerMessages`: A `StateFlow` indicating whether the end of the reply list has been reached.

#### Pagination

To handle the pagination of replies, you can use the `ChatClient.getRepliesMore(messageId: String, firstId: String, limit: Int)` method. It accepts the thread ID (ID of the thread parent message), the ID of the message to use as a base for loading more replies, and the number of replies to load. The newly loaded replies will be automatically reflected in the already observed `ThreadState` object.

```kotlin
val firstId = threadState.oldestInThread.value?.id ?: return
chatClient.getRepliesMore(threadId, firstId, limit = 20).enqueue()
```

### Global state

The state layer also exposes some globally accessible state properties for the currently logged in user, which are not directly linked to a specific channel or thread. These properties are exposed via the `GlobalState` object, which can be accessed using the `ChatClient.globalStateFlow` property:

```kotlin
// Obtain the Flow for the global state
val globalStateFlow: Flow<GlobalState> = chatClient.globalStateFlow

// Collect the global state flow to observe changes
globalStateFlow
    .collectLatest { state ->
        // Update your UI with the new global state
    }
```

The `GlobalState` exposes multiple observable properties such as:

- `totalUnreadCount`: A `StateFlow` containing the total unread message count across all channels for the current user.
- `channelUnreadCount`: A `StateFlow` containing the number of unread channels for the current user.
- `unreadThreadsCount`: A `StateFlow` containing the number of unread threads for the current user.
- `muted`: A `StateFlow` containing the list of currently muted users for the current user.
- `channelMutes`: A `StateFlow` containing the list of currently muted channels for the current user.
- `blockedUserIds`: A `StateFlow` containing the list of user IDs that are currently blocked by the current user.
- `activeLiveLocations`: A `StateFlow` containing the list of active live locations that are being shared in the app.
- `currentUserActiveLiveLocations`: A `StateFlow` containing the list of active live locations that are being shared in the app by the current user.

<Admonition type="info">
The <b>ChatClient.globalStateFlow</b> will not emit any values until a user is connected.
</Admonition>

#### Unread counts

A common use case for the `GlobalState` is to observe the total unread channel count to update the app badge or show unread indicators in the UI. To do this, you can collect the `totalUnreadCount` state flow, emitted by the `globalStateFlow`:

```kotlin
chatClient.globalStateFlow
    .flatMapLatest { it.totalUnreadCount }
    .collectLatest { totalUnreadCount ->
        // Update your UI with the new channel unread count
    }
```

Similarly, you can observe other global state properties as needed.


---

This page was last updated at 2026-08-10T16:00:42.202Z.

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/android/client/state-layer-overview/](https://getstream.io/chat/docs/sdk/android/client/state-layer-overview/).