# Inline message editing

This recipe edits a message where it sits in the message list, instead of in the composer at the bottom of the channel.

>
> **Note:** **What you'll build**
>
> - Editing a message in place in the message list
> - An editor card with Cancel and Save buttons
> - Inline editing of replies in a thread
> - The composer's unsent text left as it was
>

---

**Preview**

![The user's message open in an inline editor, a white card with a blue border that holds the input, the keyboard hint, and Cancel and Save, while the composer at the bottom still holds a message the user was writing](https://getstream.io/docs-assets/images/b20337d493bf.png)

---

**Approach:** Replace one part. [Choosing where to start](https://getstream.io/chat/docs/sdk/react/ui-cookbooks/overview/#choosing-where-to-start) explains the approaches.

**Starts from:** [the base app](https://getstream.io/chat/docs/sdk/react/ui-cookbooks/overview/#the-base-app). The page shows only what the recipe adds.

By default, Edit Message loads the message into the composer the user types in, `channel.messageComposer` or `thread.messageComposer`, and the edit replaces what was there until the user saves or cancels. An inline editor uses an edit composer instead: a second composer whose composition context is the message. [Editing a message in an edit composer](https://getstream.io/chat/docs/sdk/react/features/messaging/sending-editing-and-deleting/#editing-a-message-in-an-edit-composer) introduces it, and [Message composer](https://getstream.io/chat/docs/sdk/react/state/writing-messages/message-composer/#editing) compares the two.

## Best practices

- Keep the edit composer outside the message's component. The message list re-renders and remounts messages, and an edit held there would be lost with them.
- Edit one message at a time, and clear the previous edit composer when the user starts another edit.
- Keep the `edit` type on the replaced action, so the base filter still shows it only to users who can edit the message.
- Save with `useMessageComposerSubmitFn()`, so your Save button does what the checkmark and the Enter key do.
- Let Esc close an open suggestion list or command first, and cancel the edit only after that.

## An Edit action that opens the editor

The default Edit action loads the message into the channel's or thread's composer. This one hands the message to `startEditing`, from the recipe's own context (next step), and keeps the rest of the default action set:

```tsx title="src/InlineEditAction.tsx"
import type { MessageActionSetItem } from "stream-chat-react";
import {
  ContextMenuButton,
  defaultMessageActionSet,
  MessageActions,
  useComponentContextIcons,
  useContextMenuContext,
  useMessageContext,
  useThreadContext,
} from "stream-chat-react";
import { useInlineEdit } from "./InlineEditContext";

const InlineEditAction = () => {
  const { IconEdit } = useComponentContextIcons();
  const { closeMenu } = useContextMenuContext();
  const { message } = useMessageContext();
  const isInThread = !!useThreadContext();
  const { startEditing } = useInlineEdit();

  return (
    <ContextMenuButton
      Icon={IconEdit}
      onClick={() => {
        startEditing(message, isInThread);
        closeMenu();
      }}
    >
      Edit Message
    </ContextMenuButton>
  );
};

// Declared once: the base filter recomputes whenever it gets a new list. The item keeps the
// `edit` type, so the menu still shows it only to users who can edit the message. The "more"
// button has no type, hence the `in` check.
const messageActionSet: MessageActionSetItem[] = defaultMessageActionSet.map(
  (item) =>
    "type" in item && item.type === "edit"
      ? { ...item, Component: InlineEditAction }
      : item,
);

export const InlineEditMessageActions = () => (
  <MessageActions messageActionSet={messageActionSet} />
);
```

[Custom message actions](https://getstream.io/chat/docs/sdk/react/ui-cookbooks/message-actions/custom-message-actions/) replaces and adds actions the same way.

## An edit composer for the message

`InlineEditProvider` holds the edit in progress: the message's id, whether it was opened in a thread's list, and the edit composer. `startEditing` creates the composer with the message as both its `composition` and its `compositionContext`. The composer then holds the message as `editedMessage`, and `MessageComposer.update()` saves it.

```tsx title="src/InlineEditContext.tsx"
import type { PropsWithChildren } from "react";
import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import type { LocalMessage, MessageComposerState } from "stream-chat";
import { MessageComposer as MessageComposerController } from "stream-chat";
import { useChatContext } from "stream-chat-react";

/** The message being edited, the list it is edited in, and the composer that holds the edit. */
export type InlineEdit = {
  composer: MessageComposerController;
  isInThread: boolean;
  messageId: string;
};

type InlineEditContextValue = {
  edit: InlineEdit | null;
  startEditing: (message: LocalMessage, isInThread: boolean) => void;
};

const InlineEditContext = createContext<InlineEditContextValue>({
  edit: null,
  startEditing: () => undefined,
});

export const useInlineEdit = () => useContext(InlineEditContext);

const editedMessageSelector = (state: MessageComposerState) => ({
  editedMessage: state.editedMessage,
});

/** Owns the edit composer: at most one message is edited at a time. */
export const InlineEditProvider = ({ children }: PropsWithChildren) => {
  const { client } = useChatContext();
  const [edit, setEdit] = useState<InlineEdit | null>(null);
  // The edit in progress, read when a new one starts, so its composer is emptied exactly once.
  const editRef = useRef(edit);
  useEffect(() => {
    editRef.current = edit;
  }, [edit]);

  const startEditing = useCallback(
    (message: LocalMessage, isInThread: boolean) => {
      // The composition context is the message, so this composer edits only that message.
      const composer = new MessageComposerController({
        client,
        composition: message,
        compositionContext: message,
      });
      editRef.current?.composer.clear();
      setEdit({ composer, isInThread, messageId: message.id });
    },
    [client],
  );

  // Saving or cancelling empties the composer, which ends the edit.
  useEffect(() => {
    if (!edit) return;
    return edit.composer.state.subscribeWithSelector(
      editedMessageSelector,
      ({ editedMessage }) => {
        if (!editedMessage)
          setEdit((current) => (current === edit ? null : current));
      },
    );
  }, [edit]);

  const value = useMemo(() => ({ edit, startEditing }), [edit, startEditing]);

  return (
    <InlineEditContext.Provider value={value}>
      {children}
    </InlineEditContext.Provider>
  );
};
```

The edit doesn't live in the message's component. The list can remount a message, for example when the user switches channels and comes back, and the edit is still there.

## The editor in place of the message

`InlineEditor` gives the edit composer to a `MessageComposer` with `MessageComposerControllerProvider`. Every part of that composer, the textarea, the checkmark and the Enter key among them, works on the edit composer instead of the channel's. The composer's own "Edit Message" preview is left out, because the editor already stands where the message was.

The stylesheet draws the editor as a white card with the accent border of an active field, so it stands apart from the message list and the bubbles around it. Inside the card, the composer drops the bottom bar's background, top border and padding.

A custom `MessageUI` renders the editor for the message being edited, and the default `MessageUI` for every other message:

```tsx label="React" title="src/InlineEditor.tsx"
import type { KeyboardEvent } from "react";
import type { MessageComposer as MessageComposerController } from "stream-chat";
import {
  MessageComposer,
  MessageComposerControllerProvider,
  useMessageComposerSubmitFn,
  WithComponents,
} from "stream-chat-react";
import "./InlineEditor.css";

// The editor stands where the message was, so the composer's "Edit Message" preview of it is left
// out.
const overrides = { EditedMessagePreview: () => null };

const InlineEditorFooter = ({ onCancel }: { onCancel: () => void }) => {
  // Saves the edit, as the composer's checkmark and the Enter key do.
  const submit = useMessageComposerSubmitFn();

  return (
    <div className="cookbook-inline-editor__footer">
      <span className="cookbook-inline-editor__hint">
        Esc to cancel · Enter to save
      </span>
      <button
        className="cookbook-inline-editor__button"
        onClick={onCancel}
        type="button"
      >
        Cancel
      </button>
      <button
        className="cookbook-inline-editor__button cookbook-inline-editor__button--save"
        onClick={() => void submit()}
        type="button"
      >
        Save
      </button>
    </div>
  );
};

export const InlineEditor = ({
  composer,
}: {
  composer: MessageComposerController;
}) => {
  // Clearing the composer ends the edit and closes the editor.
  const cancel = composer.clear;

  // Runs before the textarea's own handler, so an open suggestion list or command takes Esc first.
  const handleKeyDownCapture = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.key !== "Escape") return;
    const { command, suggestions } = composer.textComposer;
    if (command || suggestions) return;
    event.preventDefault();
    cancel();
  };

  return (
    <div
      className="cookbook-inline-editor"
      onKeyDownCapture={handleKeyDownCapture}
    >
      <MessageComposerControllerProvider messageComposerController={composer}>
        <WithComponents overrides={overrides}>
          <MessageComposer focus />
          <InlineEditorFooter onCancel={cancel} />
        </WithComponents>
      </MessageComposerControllerProvider>
    </div>
  );
};
```

```css label="CSS" title="src/InlineEditor.css"
@layer stream-overrides {
  /* A raised card with the accent border of an active field, so the editor stands apart from the
     list and the bubbles around it. */
  .cookbook-inline-editor {
    display: flex;
    flex-direction: column;
    gap: var(--str-chat__spacing-xs);
    margin-inline: var(--str-chat__spacing-md);
    padding: var(--str-chat__spacing-sm);
    border: 1px solid var(--str-chat__border-utility-active);
    border-radius: var(--str-chat__radius-xl);
    background: var(--str-chat__background-core-elevation-1);
    box-shadow: var(--str-chat__box-shadow-1);
  }

  /* The card frames the composer, so the bottom bar's background, top border and padding go. */
  .cookbook-inline-editor .str-chat__message-composer-container {
    padding: 0;
    border: 0;
    background: transparent;
  }

  .cookbook-inline-editor__footer {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: var(--str-chat__spacing-sm);
    padding-inline: var(--str-chat__spacing-xs);
    font: var(--str-chat__font-caption-default);
  }

  .cookbook-inline-editor__hint {
    margin-inline-end: auto;
    color: var(--str-chat__text-secondary);
  }

  .cookbook-inline-editor__button {
    padding: 0;
    border: 0;
    background: none;
    color: var(--str-chat__text-secondary);
    font: var(--str-chat__font-caption-emphasis);
    cursor: pointer;
  }

  .cookbook-inline-editor__button--save {
    color: var(--str-chat__text-link);
  }
}
```

```tsx title="src/WithInlineMessageEditing.tsx"
import type { PropsWithChildren } from "react";
import {
  MessageUI,
  useMessageContext,
  useThreadContext,
  WithComponents,
} from "stream-chat-react";
import { InlineEditMessageActions } from "./InlineEditAction";
import { InlineEditor } from "./InlineEditor";
import { InlineEditProvider, useInlineEdit } from "./InlineEditContext";

const InlineEditingMessageUI = () => {
  const { message } = useMessageContext();
  const isInThread = !!useThreadContext();
  const { edit } = useInlineEdit();

  // The same message can show in the channel and in its thread; only the list it was opened in
  // shows the editor.
  const isEditedHere =
    edit?.messageId === message.id && edit.isInThread === isInThread;

  return isEditedHere ? (
    <InlineEditor composer={edit.composer} />
  ) : (
    <MessageUI />
  );
};

// Declared once, so WithComponents gets the same object on every render.
const overrides = {
  MessageActions: InlineEditMessageActions,
  MessageUI: InlineEditingMessageUI,
};

export const WithInlineMessageEditing = ({ children }: PropsWithChildren) => (
  <InlineEditProvider>
    <WithComponents overrides={overrides}>{children}</WithComponents>
  </InlineEditProvider>
);
```

Wrap `ChatView` in `WithInlineMessageEditing`. Above `ChatView`, it applies in the channel and in the thread beside it:

```tsx title="src/App.tsx"
<Chat client={client}>
  <WithInlineMessageEditing>
    <ChatView>{/* the base app's views */}</ChatView>
  </WithInlineMessageEditing>
</Chat>
```

The screenshot at the top of this page shows the result.

## Save, cancel and Esc

The editor ends the edit the way the composer does, by emptying the edit composer:

- **Save**, the checkmark and Enter call the function from `useMessageComposerSubmitFn()`. While the composer holds `editedMessage`, it calls `MessageComposer.update()`, which empties the composer before the request goes out and shows the new text in the message list at once.
- **Cancel** and **Esc** call `MessageComposer.clear()`. Nothing is sent.

`InlineEditProvider` sees `editedMessage` become `null` and drops the edit, and the message shows again in place of the editor. `MessageComposer.update()` never throws: a failed request leaves the new text on the message, marks it `failed`, and publishes an error notification. [Editing](https://getstream.io/chat/docs/sdk/react/features/messaging/sending-editing-and-deleting/#editing) explains the save.

The textarea's own key handler uses Esc to close the suggestion list or leave a command. The editor listens in the capture phase, before that handler, and leaves Esc to the textarea while `composer.textComposer.suggestions` or `composer.textComposer.command` is set.

## Editing in a thread

The same components edit a reply in the thread beside the channel. `InlineEditAction` and `InlineEditingMessageUI` each read `!!useThreadContext()`, which is `true` in a thread's list. The action passes it to `startEditing`, and the message UI shows the editor only when it matches `edit.isInThread`. So the editor shows only in the list the edit was opened in, so a reply that also shows in the channel isn't edited in two places at once.

The edit composer finds its thread from the message. A reply carries `parent_id`, so `MessageComposer.update()` goes through the thread when the client holds it, and `thread.messagePaginator` shows the new text. What the user was typing in `thread.messageComposer` stays there too.

## Cleaning up the edit composer

`MessageComposer` treats a composer supplied with `MessageComposerControllerProvider` as yours:

- It registers the composer's subscriptions while it's mounted and removes them when it unmounts.
- It doesn't save a draft of it, because `MessageComposer.createDraft()` skips edits.
- It doesn't clear it on unmount. Clearing it is up to you.

`InlineEditProvider` does that clean-up. Starting another edit clears the previous edit composer, so two messages are never edited at once. Saving or cancelling empties the composer, the provider drops the edit, and the composer is left for garbage collection. The effect's clean-up removes the provider's subscription to the old composer.

While an edit composer is rendered, the SDK also keeps it in `client.messageComposerCache` under its tag, `message_<message id>`, which keeps the message's channel in the client. The cache holds up to 64 composers and drops the oldest when it's full. `client.disconnectUser()` empties it.

## How it works

>
> **Info:** **Example**
>
> The user picks Edit Message on their message in Design review, with "I will share the sidebar draft after lunch" in the composer at the bottom:
>
> 1. `InlineEditAction` calls `startEditing` with the message. `InlineEditProvider` creates an edit composer for it and stores it with the message's id.
> 2. The list re-renders the messages. `InlineEditingMessageUI` finds the message being edited and renders `InlineEditor` in its place, and the default `MessageUI` for the others.
> 3. `InlineEditor` supplies the edit composer with `MessageComposerControllerProvider`. `useMessageComposerController()` picks a supplied composer before the channel's, so the textarea shows the message's text, and `channel.messageComposer` still holds the half-written message.
> 4. The user changes the text and presses Enter. `MessageComposer.update()` empties the edit composer and sends the update. The message shows the new text, marked "Edited".
> 5. `InlineEditProvider` sees that the edit composer is empty and drops the edit. The message shows again, and the composer at the bottom still holds "I will share the sidebar draft after lunch".
>

A message that changes on the server during an edit, for example by a new reaction, reloads the edit composer from the server's copy, and unsaved changes are lost. See [Message composer](https://getstream.io/chat/docs/sdk/react/state/writing-messages/message-composer/#pitfalls).

## Related

- [Sending, editing and deleting](https://getstream.io/chat/docs/sdk/react/features/messaging/sending-editing-and-deleting/#editing-a-message-in-an-edit-composer): the default edit and the edit composer.
- [Message composer](https://getstream.io/chat/docs/sdk/react/state/writing-messages/message-composer/#editing): editing in the channel's composer or in an edit composer, and `MessageComposer.update()`, `MessageComposer.clear()` and `MessageComposer.restore()`.
- [Message composer UI](https://getstream.io/chat/docs/sdk/react/ui/components/message-composer-ui/#which-composer-it-drives): how the UI picks a supplied composer.
- [MessageComposer](https://getstream.io/chat/docs/sdk/react/component-reference/message-composer/messagecomposer/#edit-a-message-inline): the props of `MessageComposer`, and a smaller inline editor.
- [Actions](https://getstream.io/chat/docs/sdk/react/ui/components/actions/#message-actions): how the message action set and its base filter work.
- [Custom message actions](https://getstream.io/chat/docs/sdk/react/ui-cookbooks/message-actions/custom-message-actions/): replacing and adding actions.
- [Custom composer](https://getstream.io/chat/docs/sdk/react/ui-cookbooks/composer/custom-composer/): a composer layout of your own, which an inline editor can use too.

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/react/ui-cookbooks/composer/inline-message-editing/](https://getstream.io/chat/docs/sdk/react/ui-cookbooks/composer/inline-message-editing/).