Poll with text and attachments
By default, creating a poll sends it at once, as a message of its own. This recipe lets the user write the message first and send the poll with it.
What you'll build
- A Create poll dialog that adds the poll to the composer
- A poll card in the composer's preview area
- One message with the poll, the text and the files
- An unsent poll saved with the draft
Preview

Approach: Replace one part and Change behaviour in the state layer. Choosing where to start explains the approaches.
Starts from: the base app. The page shows only what the recipe adds.
The recipe replaces two ComponentContext keys: PollCreationDialogControls gets a dialog footer that creates the poll without sending, and MessageComposerPreviews gets the default previews with a poll preview among them. A setup function on client.config changes what each composer sends: it removes the SDK's poll-only middleware, so the message keeps its text and attachments.
Best practices
- Create the poll when the user confirms the dialog, but send it only with the message.
MessageComposer.createPoll()creates the poll on the server and setsmessageComposer.state.pollId; the composer then counts the poll as content, like text. - Replace only the dialog's footer. The
PollCreationDialogControlskey holds the buttons, so the default dialog keeps its question, options and settings, and only what the button does changes. - Add the poll to the composer's preview set rather than placing a card around the composer. The previews render in the composer's own preview area, in the order of the set, and the area shows while any of them is visible.
- Remove the poll-only middleware by its exported id,
POLL_ONLY_COMPOSITION_MIDDLEWARE_ID, instead of rewriting it. Send the poll with the rest of the message shows how. - Leave
retainCompositionOnSubmitat its default. Send the poll with the rest of the message explains why. - Let removing the poll only detach it, without deleting it. Removing the poll before sending explains why.
- Drafts can stay on. A draft keeps
messageComposer.state.pollId, so a poll left in the composer comes back with it.
Create the poll without sending
The default <PollCreationDialog/> renders its footer from the PollCreationDialogControls key and passes it close. Its default footer creates the poll and sends it at once. <AttachPollControls/> keeps the dialog's form and changes only the button: Add to message calls MessageComposer.createPoll(), and once the poll is attached it closes the dialog and moves focus back to the composer's input. Closing first would let the user send the message while the poll is still being created, and the message would go out without it. Both buttons are disabled while the request runs: a second click would create a second poll, and the poll would still be attached after a Cancel. It doesn't call MessageComposer.send(), so the poll waits in the composer. A poll already waiting there is replaced by the new one.
import { useState } from "react";
import { flushSync } from "react-dom";
import type { PollCreationDialogControlsProps } from "stream-chat-react";
import {
Prompt,
useCanCreatePoll,
useMessageComposerContext,
useMessageComposerController,
} from "stream-chat-react";
/**
* The Create poll dialog's footer. "Add to message" creates the poll and adds it to the composer,
* without sending anything. The message goes out when the user sends it from the composer.
*/
export const AttachPollControls = ({
close,
}: PollCreationDialogControlsProps) => {
const { textareaRef } = useMessageComposerContext();
const messageComposer = useMessageComposerController();
const canCreatePoll = useCanCreatePoll();
const [isCreating, setIsCreating] = useState(false);
const addPoll = async () => {
setIsCreating(true);
try {
// Creates the poll on the server and sets `messageComposer.state.pollId`, so a new poll
// replaces one already waiting in the composer.
await messageComposer.createPoll();
} catch {
// `createPoll()` already reported the failure as a notification.
} finally {
setIsCreating(false);
// Closed only once the poll is attached: a message sent from the composer while the poll
// is still being created would go out without it. Closing and focusing the composer in one
// step keeps focus from passing through the attachment button that opened the dialog.
flushSync(() => close());
textareaRef?.current?.focus();
}
};
return (
<Prompt.Footer>
<Prompt.FooterControls>
{/* Disabled while the poll is being created: the poll would still be attached after a
Cancel. */}
<Prompt.FooterControlsButtonSecondary
disabled={isCreating}
onClick={close}
type="button"
>
Cancel
</Prompt.FooterControlsButtonSecondary>
<Prompt.FooterControlsButtonPrimary
disabled={!canCreatePoll || isCreating}
onClick={() => void addPoll()}
type="submit"
>
Add to message
</Prompt.FooterControlsButtonPrimary>
</Prompt.FooterControls>
</Prompt.Footer>
);
};<AttachmentSelector/> opens the dialog from its Poll item, which shows only in a channel's composer, with the channel's send-poll capability and polls turned on. It calls PollComposer.initState() each time it opens the dialog, so every poll starts from an empty form with a new id. MessageComposer.createPoll() reports a failed request as an api:poll:create:failed notification and throws, so the footer only closes the dialog. It also returns without creating a poll when the form doesn't compose, and then a poll already waiting in the composer stays.
The dialog's settings (multiple answers, anonymous voting, suggested options, comments) stay as the default dialog has them, and go into the poll with the question and the options. Polls lists them.
The poll in the composer
The composer's preview area renders <MessageComposerPreviews/>, which shows each item of its preview set while the item's isVisible holds. Custom previews describes the set. The recipe adds a poll item to defaultMessageComposerPreviewSet, right after the quoted message, so the poll comes before the attachments, as in the sent message. It's visible while messageComposer.state.pollId is set and no message is being edited, since an edited message keeps its poll.
The item's card shows the poll's question and options, with a button that takes it out. The poll comes from client.polls: MessageComposer.createPoll() puts the created poll there, so client.polls.fromState() finds it by id, and its poll.state holds the name and options. A poll restored from a draft may not be in client.polls: the draft carries only the poll's id, and client.polls doesn't notify components when a poll is added, so usePendingPoll loads it once with client.polls.getPoll(). Until the poll is known, or if loading it fails, the card shows only its label and the remove button, so the user can still see that the message carries a poll.
import { useEffect, useState } from "react";
import type { MessageComposerState, Poll, PollState } from "stream-chat";
import type { MessageComposerPreviewSetItem } from "stream-chat-react";
import {
defaultMessageComposerPreviewSet,
MessageComposerPreviews,
RemoveAttachmentPreviewButton,
useChatContext,
useMessageComposerController,
useStateStore,
} from "stream-chat-react";
import { removePendingPoll } from "./removePendingPoll";
import "./ComposerPollPreview.css";
const composerPollSelector = ({ pollId }: MessageComposerState) => ({ pollId });
const pollPreviewSelector = ({ name, options }: PollState) => ({
name,
options,
});
/**
* The poll `pollId` points to. `createPoll()` puts the created poll into `client.polls`; a poll
* restored from a draft may not be there yet, so it's fetched once.
*/
const usePendingPoll = (pollId: string | null) => {
const { client } = useChatContext();
const cachedPoll = pollId ? client.polls.fromState(pollId) : undefined;
const [fetchedPoll, setFetchedPoll] = useState<Poll>();
useEffect(() => {
if (!pollId || cachedPoll) return;
let isCurrent = true;
client.polls
.getPoll(pollId)
.then((poll) => {
if (isCurrent) setFetchedPoll(poll);
})
.catch((error) => console.error("Loading the poll failed", error));
return () => {
isCurrent = false;
};
}, [cachedPoll, client, pollId]);
return cachedPoll ?? (fetchedPoll?.id === pollId ? fetchedPoll : undefined);
};
type PollPreviewCardProps = {
/** Shown only for a new message: an edited message keeps its poll. */
onRemove?: () => void;
/** Until it's known, the card shows only its label. */
poll?: Poll;
};
const PollPreviewCard = ({ onRemove, poll }: PollPreviewCardProps) => {
const preview = useStateStore(poll?.state, pollPreviewSelector);
return (
<div className="cookbook-poll-preview">
<div className="cookbook-poll-preview__body">
<span className="cookbook-poll-preview__label">Poll</span>
{preview && (
<>
<span className="cookbook-poll-preview__question">
{preview.name}
</span>
<ul className="cookbook-poll-preview__options">
{preview.options.map((option) => (
<li className="cookbook-poll-preview__option" key={option.id}>
{option.text}
</li>
))}
</ul>
</>
)}
</div>
{onRemove && (
<RemoveAttachmentPreviewButton
aria-label="Remove poll"
onClick={onRemove}
/>
)}
</div>
);
};
/** The poll waiting in the composer, while `messageComposer.state.pollId` is set. */
const ComposerPollPreview = () => {
const messageComposer = useMessageComposerController();
const { pollId } = useStateStore(messageComposer.state, composerPollSelector);
const poll = usePendingPoll(pollId);
if (!pollId) return null;
return (
<PollPreviewCard
// Takes the poll out of the composer, without deleting it.
onRemove={() => removePendingPoll(messageComposer)}
poll={poll}
/>
);
};
const pollPreviewItem: MessageComposerPreviewSetItem = {
Component: ComposerPollPreview,
// An edited message keeps its poll, so the preview shows only for a new message.
isVisible: ({ editedMessage, pollId }) => !editedMessage && !!pollId,
type: "poll",
};
// The poll comes right after a quoted message, before the attachments, as in the sent message.
const quotedMessageIndex = defaultMessageComposerPreviewSet.findIndex(
({ type }) => type === "quotedMessage",
);
const previewSetWithPoll = [
...defaultMessageComposerPreviewSet.slice(0, quotedMessageIndex + 1),
pollPreviewItem,
...defaultMessageComposerPreviewSet.slice(quotedMessageIndex + 1),
];
/** The composer's previews, with the poll among them. */
export const MessageComposerPreviewsWithPoll = () => (
<MessageComposerPreviews messageComposerPreviewSet={previewSetWithPoll} />
);@layer stream-overrides {
.cookbook-poll-preview {
position: relative;
display: flex;
align-items: flex-start;
gap: var(--str-chat__spacing-sm);
padding: var(--str-chat__spacing-sm) var(--str-chat__spacing-md);
border: 1px solid var(--str-chat__border-core-default);
border-radius: var(--str-chat__radius-lg);
background: var(--str-chat__background-core-surface-subtle);
}
.cookbook-poll-preview__body {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--str-chat__spacing-xxs);
min-width: 0;
}
.cookbook-poll-preview__label {
color: var(--str-chat__accent-primary);
font: var(--str-chat__font-caption-emphasis);
}
.cookbook-poll-preview__question {
color: var(--str-chat__text-primary);
font: var(--str-chat__font-body-emphasis);
}
.cookbook-poll-preview__options {
display: flex;
flex-wrap: wrap;
gap: var(--str-chat__spacing-xxs);
margin: 0;
padding: 0;
list-style: none;
}
.cookbook-poll-preview__option {
padding: 0 var(--str-chat__spacing-xs);
border-radius: var(--str-chat__radius-max);
background: var(--str-chat__background-core-surface-card);
color: var(--str-chat__text-secondary);
font: var(--str-chat__font-caption-default);
}
}The remove button calls removePendingPoll(), shown in Removing the poll before sending, which takes the poll out of the composer.
Send the poll with the rest of the message
By default, the second middleware of messageComposer.compositionMiddlewareExecutor, the poll-only middleware, turns any message with a poll into the poll alone: it clears the text, attachments, mentions, parent and quoted message, sends only poll_id, and skips the middleware after it. It leaves edits and thread replies to the rest of the chain.
setUpPollWithText is a setup function, and installPollWithText registers it for every composer. It removes the poll-only middleware by its id, POLL_ONLY_COMPOSITION_MIDDLEWARE_ID, so the default middleware build the message as for any other: the text, attachments, mentions and quoted message, and poll_id from the own-state middleware, which adds it while messageComposer.state.pollId is set:
import type { InstanceSetupFunction, StreamChat } from "stream-chat";
import {
createPollOnlyCompositionMiddleware,
POLL_ONLY_COMPOSITION_MIDDLEWARE_ID,
USER_DATA_INJECTION_MIDDLEWARE_ID,
} from "stream-chat";
/**
* Makes one composer send a poll with the text and attachments of its message. Returns a function
* that undoes it.
*/
export const setUpPollWithText: InstanceSetupFunction<"messageComposer"> = ({
composer,
}) => {
const executor = composer.compositionMiddlewareExecutor;
// Without the poll-only step, the message keeps its text and attachments, and the composer
// empties after the send, as for any message.
executor.remove(POLL_ONLY_COMPOSITION_MIDDLEWARE_ID);
return () => {
executor.insert({
middleware: [createPollOnlyCompositionMiddleware(composer)],
position: { after: USER_DATA_INJECTION_MIDDLEWARE_ID },
unique: true,
});
};
};
/**
* Makes every composer send a poll with the text and attachments of its message. Returns a function
* that stops it.
*/
export const installPollWithText = (client: StreamChat) => {
client.config.setSetupFunction("messageComposer", setUpPollWithText);
return () => client.config.setSetupFunction("messageComposer", null);
};The composer then empties after the send without further setup. retainCompositionOnSubmit decides whether it keeps its content after a send, and its default keeps it only while the poll-only middleware is installed, since the default poll message leaves the text and attachments out: Keeping the content after a send explains it.
The function the setup function returns undoes it, when the setup function is replaced or cleared: it puts a new poll-only middleware, made with createPollOnlyCompositionMiddleware() from stream-chat, back after the user data injection middleware, its default place.
Then the components and the setup function go around the channel:
import type { PropsWithChildren } from "react";
import { useEffect } from "react";
import { useChatContext, WithComponents } from "stream-chat-react";
import { AttachPollControls } from "./AttachPollControls";
import { MessageComposerPreviewsWithPoll } from "./ComposerPollPreview";
import { installPollWithText } from "./installPollWithText";
// Declared once, so WithComponents gets the same object on every render.
const overrides = {
MessageComposerPreviews: MessageComposerPreviewsWithPoll,
PollCreationDialogControls: AttachPollControls,
};
const PollWithTextSetup = () => {
const { client } = useChatContext();
useEffect(() => installPollWithText(client), [client]);
return null;
};
export const WithPollWithText = ({ children }: PropsWithChildren) => (
<WithComponents overrides={overrides}>
<PollWithTextSetup />
{children}
</WithComponents>
);import type { Channel as StreamChannel } from "stream-chat";
import { Channel, MessageComposer, MessageList } from "stream-chat-react";
import { WithPollWithText } from "./WithPollWithText";
export const PollChannel = ({ channel }: { channel: StreamChannel }) => (
<WithPollWithText>
<Channel channel={channel}>
<MessageList />
<MessageComposer />
</Channel>
</WithPollWithText>
);The base app passes <WithPollWithText/> as the recipe's wrapper, around the whole chat view; <PollChannel/> is the same wrapper around a channel of your own.
client.config.setSetupFunction() holds one messageComposer setup function, and setting another replaces it. If your app has one of its own, register a single function that calls both and undoes both, in place of installPollWithText:
import type { InstanceSetupFunction, StreamChat } from "stream-chat";
import { setUpPollWithText } from "./installPollWithText";
/** A setup function of your own: no typing events in livestream channels. */
const setUpTypingEvents: InstanceSetupFunction<"messageComposer"> = ({
composer,
}) => {
if (composer.channel.type === "livestream") {
composer.updateConfig({ text: { publishTypingEvents: false } });
}
};
export const installComposerSetup = (client: StreamChat) => {
client.config.setSetupFunction("messageComposer", (args) => {
const undoPollWithText = setUpPollWithText(args);
const undoTypingEvents = setUpTypingEvents(args);
return () => {
undoPollWithText?.();
undoTypingEvents?.();
};
});
return () => client.config.setSetupFunction("messageComposer", null);
};The sent message has the poll, then the image, then the text. The default <MessageUI/> renders the poll of every message with message.poll_id, and its attachments and text as for any message:
The sent message

Removing the poll before sending
The card's remove button takes the poll out of the composer by setting messageComposer.state.pollId to null. The poll stays on the server, unused. The composer can't tell a poll it created from one restored from a draft, which may already be sent, and deleting a sent poll removes it and its votes.
import type { MessageComposer } from "stream-chat";
/**
* Takes the poll waiting in a new message out of the composer, without deleting it. Call it when
* the user removes the poll. A poll of an edited message belongs to that message and stays.
*/
export const removePendingPoll = (composer: MessageComposer) => {
const { editedMessage, pollId } = composer;
if (!pollId || editedMessage) return;
composer.state.partialNext({ pollId: null });
};A new poll from the dialog replaces the one waiting in the composer in the same way, and the replaced poll stays too. So does a poll the user leaves in the composer before switching channels or closing the page. With drafts on, a saved draft keeps messageComposer.state.pollId, and the poll comes back with the draft. To remove polls that never reached a message, clean up outside the composer: for example, have your server keep track of the polls it sees created and the polls it sees in messages, and delete the others on its own schedule.
Attachments and other clients
Attachments need nothing extra. The default attachments middleware adds them to the message, as for any other message, and poll_id goes next to them. Uploads work as they do for a message without a poll: by default, a file that is still uploading keeps the message from being sent, and the composer asks the user to wait for it.
The API accepts such a message, and returns the poll, the text and the attachments with it. Other Stream SDKs, though, send a poll as a message of its own, so their message UI may never have been shown a poll with text and attachments. Check how your other clients display one before your users send them.
How it works
Example
The user creates a poll, attaches a photo, types a line and sends it:
- The Poll item of
<AttachmentSelector/>resets the poll composer and opens the poll dialog. Its Add to message button, from<AttachPollControls/>, callsMessageComposer.createPoll(), which creates the poll on the server, puts it intoclient.pollsand setsmessageComposer.state.pollId. - The
pollitem of the preview set is visible whilemessageComposer.state.pollIdis set. Its<ComposerPollPreview/>reads the poll fromclient.polls.fromState()and shows it in the composer's preview area, above the photo. - While
messageComposer.state.pollIdis set, the composer has content to send, even with no text. Sending runsmessageComposer.compositionMiddlewareExecutor, which no longer has the poll-only step: the text, the attachments andpoll_idall go into the message. - Without the poll-only middleware, the default
retainCompositionOnSubmitreturnsfalse, so the composer empties as the message goes out, andmessageComposer.state.pollIdwith it.
Had the user pressed the card's remove button instead, removePendingPoll() would have taken the poll out of the composer. Had the user created a second poll before sending, it would have taken the first one's place. Had the user opened another channel, the poll would have stayed in the draft, if drafts are on.
Related
- Polls: the poll composer's form, its validation, and
MessageComposer.createPoll(). - Polls feature: the default poll UI, from the dialog to voting.
- Composer middleware: the composition chain, and inserting, removing and replacing middleware.
- Composer middleware › Messages with a poll: what the poll-only middleware changes for your middleware.
- Polls API: creating, sending and deleting polls on the server.
- Message composer UI › Previews: the composer's preview area and its preview set.