# Message operations

> **Info:** This is release candidate documentation for **Stream Chat React SDK v15**. For the latest stable version, see the [latest version (v14)](https://getstream.io/chat/docs/sdk/react/).

Every channel and every thread has `messageOperations`: the operations that send, retry, edit and delete a message, and add or remove a reaction. Each one changes the message list first, then sends the request, and settles the list when the server answers. The composer's `MessageComposer.send()` and `MessageComposer.update()` go through them.

>
> **Info:** **Example**
>
> The user sends "Hello" and the request fails:
>
> 1. The message appears in the list at once, with the status `sending`.
> 2. The request fails. The message stays in the list, now `failed`, with the server's error in `message.error`.
> 3. The user retries. `MessageOperations.retry({ localMessage })` marks the message `sending` again and sends the same request.
> 4. The server answers. Its copy replaces the local one, with the status `received`.
>

```ts
const retryMessage = async (messageId: string) => {
  const localMessage = channel.messagePaginator.getItem(messageId);
  if (localMessage?.status !== "failed") return;

  try {
    await channel.messageOperations.retry({ localMessage });
  } catch {
    // The message is marked failed again, ready for another retry.
  }
};
```

Unlike the composer's `MessageComposer.send()`, the operations reject when the request fails. The list is already settled by then, so the rejection only tells the caller.

## Message statuses

A message on its way has one of three statuses in `message.status`:

- `sending`: shown in the list, the request is on its way.
- `received`: the server's copy is in the list.
- `failed`: the request failed. `message.error` holds the error. Retry a failed send with `MessageOperations.retry({ localMessage })`. A failed edit is saved by editing again with `MessageOperations.update()`, not by retrying.

These statuses describe the request, and don't match the statuses a UI shows on the user's own messages: sending, sent, delivered and read. A UI shows a `received` message as sent until another member has it, and takes delivered and read from the other members' read state, as [Read state, unread and receipts](https://getstream.io/chat/docs/sdk/react/v15/features/messaging/read-state-unread-and-receipts/) explains.

A failed send keeps its request for five minutes, for up to 100 messages, so a retry sends exactly what failed. After that, a retry rebuilds the request from the message. Both limits can be changed under `messageOperations` in [Configuration](https://getstream.io/chat/docs/sdk/react/v15/state/state-and-entities/configuration/).

If the server rejects a send because the message already exists, the earlier attempt did arrive. The message is marked `received` rather than `failed`, although the call still rejects.

The operations and what they take:

| Operation                                        | Takes                                                                    |
| ------------------------------------------------ | ------------------------------------------------------------------------ |
| `send({ localMessage, message?, options? })`     | The message to show, and optionally the request body and send options.   |
| `retry({ localMessage, message?, options? })`    | A failed message. Without `message`, it resends the request that failed. |
| `update({ localMessage, options? })`             | The edited message. `options` takes `skip_enrich_url` and `skip_push`.   |
| `delete({ localMessage, options? })`             | The message. `options` takes `hard` and `delete_for_me`.                 |
| `addReaction({ messageId, reaction, options? })` | The message id and the reaction, such as `{ type: "like" }`.             |
| `deleteReaction({ messageId, type })`            | The message id and the reaction type.                                    |

A thread sends its replies through `thread.messageOperations`. The requests go to the thread's channel with the thread's id as `parent_id`, and the reply lands in the thread's message list. `channel.messageOperations` and `thread.messageOperations` are reached through their channel and thread; the class isn't exported from `stream-chat`.

Calls such as `Channel.sendMessage()` or `client.updateMessage()` send the request and nothing else. They don't touch the list, as [Raw API calls](https://getstream.io/chat/docs/sdk/react/v15/state/writing-messages/whats-optimistic-and-what-isnt/#raw-api-calls) explains.

## Optimistic updates

The operations change the list before the request goes out:

- A send shows the message as `sending`.
- An edit shows the new text, marked as edited.
- A delete shows the message as deleted, or removes it for a hard delete.
- A reaction appears or disappears at once.

When the server answers, its copy replaces the local one. For a send or an edit, that happens only if nothing newer arrived in the meantime, such as a `message.updated` event. When a request fails, a send is marked `failed`, an edit is kept and marked `failed`, and a delete or a reaction is undone.

Conversation actions, such as marking a channel read or archiving it, work the other way: their effect appears when the server's event arrives. Every action is listed on [What's optimistic and what isn't](https://getstream.io/chat/docs/sdk/react/v15/state/writing-messages/whats-optimistic-and-what-isnt/).

## Customizing requests

To change how a request is made, register a request handler in `client.config`. The operation then calls your handler instead of making the request itself, and hands it the default request, so the handler can still send the usual way:

```ts
import type { OperationRequestFn } from "stream-chat";

const sendMessageRequest: OperationRequestFn<"send"> = (
  params,
  defaultRequest,
) =>
  defaultRequest({
    ...params,
    options: { ...params.options, skip_push: true },
  });

client.config.set({ channel: { requestHandlers: { sendMessageRequest } } });
```

Handlers are registered for the client and apply to every channel, the ones that exist and the ones created later. To treat channels differently, branch inside the handler, for example on `params.localMessage.cid`. There is no request function for a single call: every call goes through the registered handler.

The optimistic update happens before the handler runs, and settling the list happens after it, whatever the handler does.

### Request handlers

| Handler                   | Runs on                                                                                   | Receives                             | Resolves to                       |
| ------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------ | --------------------------------- |
| `sendMessageRequest`      | `MessageOperations.send()`, and `MessageOperations.retry()` when there's no retry handler | `{ localMessage, message, options }` | `{ message }`                     |
| `retrySendMessageRequest` | `MessageOperations.retry()`                                                               | `{ localMessage, message, options }` | `{ message }`                     |
| `updateMessageRequest`    | `MessageOperations.update()`                                                              | `{ localMessage, options }`          | `{ message }`                     |
| `deleteMessageRequest`    | `MessageOperations.delete()`                                                              | `{ localMessage, options }`          | `{ message }`                     |
| `markReadRequest`         | `Channel.markReadViaReporter()` and automatic marking read                                | `{ channel, options }`               | the mark-read response, or `null` |

In `send` and `retry`, `message` is the request body and `localMessage` the copy in the list. The message the handler resolves with is the server's copy, and it replaces the local one.

`markReadRequest` has no default request. To mark read the usual way, call `Channel.markRead(options)` in it. A direct `Channel.markRead()` call doesn't go through the handler. Marking read is explained on [Read state and unread counts](https://getstream.io/chat/docs/sdk/react/v15/state/channels-and-threads/read-state-and-unread-counts/).

### Wrapping the default request

The default request is the one the operation would make without a handler, and it's the second argument of every message handler. Call it to send the usual way, with the parameters as given or changed. Leave it out to send somewhere else:

```ts
import type { OperationRequestFn } from "stream-chat";

const sendMessageRequest: OperationRequestFn<"send"> = async (
  params,
  defaultRequest,
) => {
  if (!params.localMessage.cid.startsWith("support:")) {
    return defaultRequest(params);
  }

  const response = await fetch("https://api.example.com/support/messages", {
    body: JSON.stringify(params.message),
    headers: { "Content-Type": "application/json" },
    method: "POST",
  });
  const { message } = await response.json();

  return { message };
};

client.config.set({ channel: { requestHandlers: { sendMessageRequest } } });
```

Here, messages in `support` channels go through the app's backend, which sends them to Stream and returns the server's copy. Every other channel sends the usual way.

### Threads use their channel's handlers

A thread reply is sent, retried, edited and deleted through the handlers registered under `channel`. The `thread` key takes one handler of its own, `markReadRequest`, which runs when a thread is marked read. It receives `{ thread, options }`, where `options` already carries the thread's id, and resolves to `null` when nothing was sent:

```ts
import type { CustomThreadMarkReadRequestFn } from "stream-chat";

const markReadRequest: CustomThreadMarkReadRequestFn = async ({
  thread,
  options,
}) => {
  // Keep the thread unread while the browser tab is in the background.
  if (document.hidden) return null;

  return thread.channel.markRead(options);
};

client.config.set({ thread: { requestHandlers: { markReadRequest } } });
```

## Pitfalls

- **Resolve with the server's message.** A send handler that resolves without a `message` leaves the local copy as `sending` until the server's `message.new` event replaces it.
- **Retrying a failed edit.** `MessageOperations.retry()` sends the edited message as a new one. The server rejects it because the message exists, and the list then shows the edit as `received`, although the server kept the old text. Load the message into a composer and call `MessageComposer.update()` again.
- **Options of an edit.** `MessageOperations.update()` sends the options it was called with, even when an `updateMessageRequest` handler passes changed options to the default request. To change them, set `sendOptions` in a [composition middleware](https://getstream.io/chat/docs/sdk/react/v15/state/writing-messages/composer-middleware/) placed after `stream-io/message-composer-middleware/data-cleanup`. For an edit with `skip_enrich_url` set, which loaded link previews set, that step keeps only `skip_enrich_url`.
- **Raw calls skip all of this.** `Channel.sendMessage()` and the like neither show the message first nor mark it failed.

## Used in

- [Sending, editing and deleting](https://getstream.io/chat/docs/sdk/react/v15/features/messaging/sending-editing-and-deleting/)
- [Reactions](https://getstream.io/chat/docs/sdk/react/v15/features/messaging/reactions/)
- [Message actions](https://getstream.io/chat/docs/sdk/react/v15/features/messaging/message-actions/)
- [Sending with pending uploads](https://getstream.io/chat/docs/sdk/react/v15/features/messaging/sending-with-pending-uploads/)

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/react/v15/state/writing-messages/message-operations/](https://getstream.io/chat/docs/sdk/react/v15/state/writing-messages/message-operations/).