import {
AttachmentPreviewList,
AudioRecorder,
CooldownTimer,
LinkPreviewList,
QuotedMessagePreview,
SendButton,
SimpleAttachmentSelector,
TextareaComposer,
WithDragAndDropUpload,
useCooldownRemaining,
useMessageComposerContext,
useMessageComposerController,
useStateStore,
} from "stream-chat-react";
import type { MessageComposerState } from "stream-chat";
const SendButtonWithCooldown = () => {
const cooldownRemaining = useCooldownRemaining();
const { handleSubmit } = useMessageComposerContext();
return cooldownRemaining ? (
<CooldownTimer />
) : (
<SendButton sendMessage={handleSubmit} />
);
};
const editingStateSelector = (state: MessageComposerState) => ({
editedMessage: state.editedMessage,
});
// When the user edits a message the composer's `editedMessage` is set and the textarea is
// pre-filled automatically. Reproduce an "editing" affordance so the user can see and cancel
// the in-progress edit; a custom layout that omits it leaves them stuck in edit mode.
const EditingIndicator = () => {
const messageComposer = useMessageComposerController();
const { editedMessage } = useStateStore(
messageComposer.state,
editingStateSelector,
);
if (!editedMessage) return null;
return (
<div className="editing-indicator">
<span>Editing message</span>
{/* `clear()` exits edit mode and resets the composer (public composer API). */}
<button type="button" onClick={() => messageComposer.clear()}>
Cancel
</button>
</div>
);
};
const CustomMessageComposer = () => {
const { recordingController } = useMessageComposerContext();
return (
// `WithDragAndDropUpload` restores drag-and-drop file uploads over the composer area.
<WithDragAndDropUpload className="message-input" component="div">
{recordingController.recordingState ? (
// While a voice recording is active the SDK swaps the composer for the recorder.
// Skip this swap and voice messages silently break.
<AudioRecorder />
) : (
<>
<div className="left-container">
<SimpleAttachmentSelector />
{/* `recorder` exists only when `audioRecordingEnabled` is set on MessageComposer. */}
{recordingController.recorder && (
<button
type="button"
onClick={() => recordingController.recorder?.start()}
title="Record voice message"
>
🎙️
</button>
)}
</div>
<div className="central-container">
<EditingIndicator />
<QuotedMessagePreview />
<AttachmentPreviewList />
<LinkPreviewList />
<TextareaComposer />
</div>
<div className="right-container">
<SendButtonWithCooldown />
</div>
</>
)}
</WithDragAndDropUpload>
);
};Message Composer UI
The message composer is a primary interaction surface in chat, so custom layouts should preserve the SDK’s composer behavior for uploads, autocomplete, quoted messages, and submission.
Best Practices
- Compose custom input UI from exported building blocks instead of duplicating composer state.
- Keep
TextareaComposercentral so autocomplete and slash commands still work. - Read cooldown through
useCooldownRemaining()orCooldownTimer, not fromMessageComposerContext. - Render custom input UI by scoping
WithComponents overrides={{ MessageComposerUI: ... }}around the target composer subtree. - Decide whether your custom layout should match the default command-selected state, where the attachment selector and extra actions collapse while
CommandChipis active. - Test quoted-message, attachment, link-preview, and voice-recording states together.
You can compose a custom input from the composer building blocks the SDK exports (a few pieces the default MessageComposerUI uses internally, such as its additional-actions menu, are not exported — build those regions yourself):
If you want the default slash-command layout too, drive it from composer state rather than toggling a class by hand. The active command lives on the text composer (messageComposer.textComposer.state.command); read it with useStateStore and apply str-chat__message-composer--command-active while it is set. The built-in composer uses that state to collapse the attachment selector and additional action buttons while keeping CommandChip visible next to the textarea. CommandChip renders nothing when no command is active, so you can render it unconditionally:
import {
CommandChip,
TextareaComposer,
useMessageComposerController,
useStateStore,
} from "stream-chat-react";
import type { TextComposerState } from "stream-chat";
const commandSelector = (state: TextComposerState) => ({
command: state.command,
});
const TextInputWithCommand = () => {
const messageComposer = useMessageComposerController();
const { command } = useStateStore(
messageComposer.textComposer.state,
commandSelector,
);
const className = command
? "str-chat__message-composer str-chat__message-composer--command-active"
: "str-chat__message-composer";
return (
<div className={className}>
<CommandChip command={command} />
<TextareaComposer />
</div>
);
};Render the custom layout through MessageComposer so the SDK still provides the required composer context:
import {
Channel,
ChannelHeader,
MessageComposer,
MessageList,
Thread,
WithComponents,
Window,
} from "stream-chat-react";
const App = () => (
<Channel>
<Window>
<ChannelHeader />
<MessageList />
<WithComponents overrides={{ MessageComposerUI: CustomMessageComposer }}>
{/* `audioRecordingEnabled` is what makes `recordingController.recorder` available. */}
<MessageComposer audioRecordingEnabled />
</WithComponents>
</Window>
<Thread />
</Channel>
);Custom Placeholder
TextareaComposer forwards standard <textarea> attributes, so pass placeholder to replace the default ("Send a message"). When matching a reference design, use its exact string:
<TextareaComposer placeholder="Message" />The SDK still overrides the placeholder contextually — showing the slow-mode countdown during cooldown and command-specific hints while a command is active — so you get those states for free.
Custom Message Data
To attach your own fields to the message being composed, write them through the composer's customDataManager rather than storing them in component state. It exposes two setters:
setMessageData(data)— mergesdatainto the outgoing message payload (both the optimistic local message and the request sent to the server).setCustomData(data)— stores composer-only state that is not sent with the message (useful for UI that shouldn't leak into the message).
import { useMessageComposerController } from "stream-chat-react";
const PrioritySelector = () => {
const messageComposer = useMessageComposerController();
return (
<button
type="button"
onClick={() =>
messageComposer.customDataManager.setMessageData({ priority: "high" })
}
>
Mark as high priority
</button>
);
};Type the extra fields by augmenting CustomMessageData so they are recognized on the composed message:
declare module "stream-chat" {
interface CustomMessageData {
priority?: "high" | "normal";
}
}