Platform docs
Auth, users, webhooks & more
Info:
This is beta documentation for Stream Video React SDK v2. For the latest stable version, see the latest version (v1).

Chat Integration

Embed video calling into a chat application with ring call support.

Best Practices

  • Use the same API key for both chat and video clients - no separate apps needed.
  • Use unique call IDs for ring calls (UUID recommended).
  • Filter useCalls() by call.ringing to show pending calls only.
  • Use call.isCreatedByMe to distinguish incoming vs outgoing calls.
  • Handle all calling states: RINGING, JOINING, JOINED.
  • Render components that need to know which channel is open inside ChatView.
  • Clean up call instances properly with call.leave() in useEffect cleanup.

See the demo application for complete implementation.

Project setup and prerequisites

Make sure you have the following prerequisites checked:

  1. Registered Stream account
  2. Have an app created in the Stream's dashboard to obtain app API key and secret.
  3. Initiate the project (you can follow our introductory tutorial setup guide)
  4. Have installed the Stream video and chat SDKs in the project:
npm install @stream-io/video-react-sdk@beta stream-chat-react@15 stream-chat@10
yarn add @stream-io/video-react-sdk@beta stream-chat-react@15 stream-chat@10

This guide uses stream-chat-react v15 and stream-chat v10. If your chat code is on an earlier version, follow the upgrade guide first.

Tip:

When implementing a ring call scenario as we do in this demo, it is important to have a good understanding of our ring call lifecycle. You can learn more about the topic in the Joinging & Creating Calls guide.

App boilerplate

We have prepared a demo application to accompany this guide. We do not aim to explain the whole demo application source code. The demo application will serve us to demonstrate the main concepts behind the video-in-chat integration.

Note:

To initiate chat and video clients you are encouraged to use the same API key. The user tokens should be generated with the same secret. There is no need to create separate apps for chat and video.

Initiating chat and video clients

useCreateChatClient from stream-chat-react creates the chat client and connects the user. The video client is created for the same user:

import { useEffect, useState } from "react";
import { Chat, useCreateChatClient } from "stream-chat-react";
import { StreamVideo, StreamVideoClient } from "@stream-io/video-react-sdk";
import { Messenger } from "./components/Messenger";

type RootProps = {
  apiKey: string;
  user: { id: string; name?: string; image?: string };
  userToken: string;
};

export const Root = ({ apiKey, user, userToken }: RootProps) => {
  const chatClient = useCreateChatClient({
    apiKey,
    tokenOrProvider: userToken,
    userData: user,
  });
  const [videoClient, setVideoClient] = useState<StreamVideoClient>();
  const { id: userId, name, image } = user;

  useEffect(() => {
    const client = new StreamVideoClient({
      apiKey,
      user: { id: userId, name, image },
      token: userToken,
    });
    setVideoClient(client);

    return () => {
      client
        .disconnectUser()
        .catch((error) => console.error("Failed to disconnect", error));
      setVideoClient(undefined);
    };
  }, [apiKey, userId, name, image, userToken]);

  if (!chatClient || !videoClient) return null;

  return (
    <Chat client={chatClient}>
      <StreamVideo client={videoClient}>
        <Messenger userId={userId} />
      </StreamVideo>
    </Chat>
  );
};

The effect depends on the user's fields rather than on the user object, so a parent that passes a new object with the same values keeps the same video client.

Laying out the chat and the calls

The chat UI is a channel list next to the open channel, laid out by ChatView from stream-chat-react/slot-layout. The channel list renders a ChannelPaginator registered on the chat client's channel manager. Clicking a channel opens it in the channel slot, and opening a message's replies opens the thread in the thread slot.

The Video component renders the calls. It goes into ChatView as a child, so it's shown in every view and can tell which channel is open:

import { useEffect, useMemo } from "react";
import { ChannelPaginator } from "stream-chat";
import type { ChannelFilters, SortParamRequest } from "stream-chat";
import {
  ChannelHeader,
  ChannelNavigation,
  MessageComposer,
  MessageList,
  ThreadHeader,
  WithComponents,
  useChatContext,
} from "stream-chat-react";
import {
  ChannelSlot,
  ChatView,
  ThreadSlot,
} from "stream-chat-react/slot-layout";
import type { LayoutDescriptor } from "stream-chat-react/slot-layout";
import { ChannelPreviewActions } from "./ChannelPreviewActions";
import { CreateCallButton } from "./CreateCallButton";
import { Video } from "./Video";

const sort: SortParamRequest[] = [{ field: "last_message_at", direction: -1 }];

const layouts: LayoutDescriptor[] = [
  { id: "channels", slots: ["channel", "thread"] },
];

const ChannelsView = () => (
  <>
    <WithComponents
      overrides={{ ChannelListItemActionButtons: ChannelPreviewActions }}
    >
      <ChannelNavigation />
    </WithComponents>
    <ChannelSlot slot="channel">
      <ChannelHeader EndContent={CreateCallButton} />
      <MessageList />
      <MessageComposer />
    </ChannelSlot>
    <ThreadSlot slot="thread">
      <ThreadHeader />
      <MessageList />
      <MessageComposer />
    </ThreadSlot>
  </>
);

export const Messenger = ({ userId }: { userId: string }) => {
  const { client } = useChatContext();

  const paginator = useMemo(() => {
    const filters: ChannelFilters = {
      members: { $in: [userId] },
      type: "messaging",
    };
    return new ChannelPaginator({ client, filters, id: "my-channels", sort });
  }, [client, userId]);

  useEffect(() => {
    client.channelManager.insertPaginator({ paginator });
    return () => {
      client.channelManager.removePaginator(paginator);
    };
  }, [client, paginator]);

  return (
    <ChatView layouts={layouts} views={{ channels: <ChannelsView /> }}>
      <Video />
    </ChatView>
  );
};

No channel is open until the user picks one. To open the first channel of the list as soon as it loads, see the demo's useOpenFirstChannel hook.

Initiating a ring call

In the ring call scenario we recommend to first create a call without immediately joining it. Use the Call method getOrCreate() to accomplish this. An example can be found in CreateCallButton component in the demo app. It's rendered at the end of the channel header, so useChannel() returns the open channel:

import {
  type MemberRequest,
  useStreamVideoClient,
} from "@stream-io/video-react-sdk";
import { useChannel } from "stream-chat-react";
import { LocalPhone } from "@mui/icons-material";
import { meetingId } from "../../utils/meetingId";

export const CreateCallButton = () => {
  const videoClient = useStreamVideoClient();
  const channel = useChannel();

  const createCall = () => {
    videoClient
      ?.call("default", meetingId())
      .getOrCreate({
        ring: true,
        data: {
          custom: { channelCid: channel.cid },
          members: Object.values(channel.state.members).map<MemberRequest>(
            (member) => ({ user_id: member.user_id! }),
          ),
        },
      })
      .catch((error) => console.error("Failed to create the call", error));
  };

  const disableCreateCall = !videoClient;
  return (
    <button
      className="rmc__button rmc__button--green"
      disabled={disableCreateCall}
      onClick={createCall}
    >
      <LocalPhone />
    </button>
  );
};
Note:

There is a flexibility in what channel members will be included in the call. And so the call can be a 1:1 or a group call. In our implementation we include all channel members.

Handling the ring call states

Once a ring call is initiated, call members start to receive ring call events (call.created, call.accepted, call.rejected, call.ended) over the WebSocket maintained by the video client. The video client updates the calls pool state and calling state of individual affected Call in response to these events.

Observing the calls pool state

The array of all Call objects representing created pending (not accepted, rejected, neither ended) calls is continuously updated in response to each new call creation. You can use this array of Call objects to display incoming or outgoing calls in your application's UI. In our app the Video component observes the changes using the useCalls hook and re-renders the UI to reflect the changes:

import { StreamCall, useCalls } from "@stream-io/video-react-sdk";
import { CallPanel } from "./CallPanel";

export const Video = () => {
  const calls = useCalls().filter((call) => call.ringing);
  return (
    <>
      {calls.map((call) => (
        <StreamCall call={call} key={call.cid}>
          <CallPanel />
        </StreamCall>
      ))}
    </>
  );
};
Note:

To identify an outgoing call, use the call.isCreatedByMe flag.

In our demo app, we reflect the incoming calls state in channel list. Channel preview shows buttons to accept or reject the incoming call. The outgoing call or incoming call in an active channel is represented by a CallPanel component floating above the chat UI:

Image of incoming calls in channel preview

Each row of the channel list renders its action buttons with the ChannelListItemActionButtons component, which the Messenger above replaces with a ChannelPreviewActions of its own. Buttons rendered there sit inside the row, so the channel list's keyboard navigation reaches them. useChannelListItemContext() returns the row's channel, and a call made in that channel gets the call controls in place of the row's default actions:

import {
  ChannelListItemActionButtons,
  useChannelListItemContext,
} from "stream-chat-react";
import { StreamCall, useCalls } from "@stream-io/video-react-sdk";
import { ChannelPreviewCallControls } from "./ChannelPreviewCallControls";

export const ChannelPreviewActions = () => {
  const { channel } = useChannelListItemContext();
  const callToChannel = useCalls().find(
    (call) => call.state.custom.channelCid === channel.cid,
  );

  if (!callToChannel) return <ChannelListItemActionButtons />;

  return (
    <StreamCall call={callToChannel}>
      <ChannelPreviewCallControls />
    </StreamCall>
  );
};

The controls show only while the call rings and its channel isn't open, since an open channel shows the call in the CallPanel. Otherwise the row keeps its default actions. isChannelActive() from useWorkspaceNavigation() tells whether a channel is open:

import {
  AcceptCallButton,
  CallingState,
  CancelCallButton,
  useCall,
  useCallStateHooks,
} from "@stream-io/video-react-sdk";
import {
  ChannelListItemActionButtons,
  useWorkspaceNavigation,
} from "stream-chat-react";

export const ChannelPreviewCallControls = () => {
  const { isChannelActive } = useWorkspaceNavigation();
  // the Call instance is passed down from StreamCallProvider located in StreamCall
  const call = useCall();
  const { useCallCallingState, useCallCustomData } = useCallStateHooks();
  const callingState = useCallCallingState();
  const customData = useCallCustomData();

  const isRinging = callingState === CallingState.RINGING;

  if (!call || !isRinging || isChannelActive(customData.channelCid)) {
    return <ChannelListItemActionButtons />;
  }

  return (
    <div className="str-chat__channel-list-item__action-buttons rmc__channel-preview__call-controls">
      <AcceptCallButton
        onClick={() => {
          call.join().catch((error) => console.error("Failed to join", error));
        }}
      />
      <CancelCallButton
        onClick={() => {
          const reason = call.isCreatedByMe ? "cancel" : "decline";
          call
            .leave({ reject: true, reason })
            .catch((error) => console.error("Failed to reject", error));
        }}
      />
    </div>
  );
};

The controls' container carries the SDK's str-chat__channel-list-item__action-buttons class, which the keyboard navigation looks for. The SDK shows that container only while the row is hovered or focused, so the default styles keep the ringing controls visible.

Observing the state of a specific call

Each call can pass through different states. The call calling state is made available through useCallCallingState hook. Therefore, our custom CallPanel component displays different UI (pending call, active call) based on the information provided by the hook. Like the preview controls, it hides a ringing call whose channel isn't open:

import {
  CallingState,
  CancelCallButton,
  RingingCall,
  ScreenShareButton,
  SpeakerLayout,
  SpeakingWhileMutedNotification,
  ToggleAudioPublishingButton,
  ToggleVideoPublishingButton,
  useCall,
  useCallStateHooks,
} from "@stream-io/video-react-sdk";
import { useWorkspaceNavigation } from "stream-chat-react";
import { useState } from "react";
import { useDraggable } from "../../hooks";

export const CallPanel = () => {
  const call = useCall();
  const { useCallCallingState, useCallCustomData } = useCallStateHooks();
  const callingState = useCallCallingState();
  const customData = useCallCustomData();

  const { isChannelActive } = useWorkspaceNavigation();
  const [panelElement, setPanelElement] = useState<HTMLDivElement | null>(null);
  useDraggable(panelElement);

  if (!call) return null;

  const callingToActiveChannel = isChannelActive(customData.channelCid);

  if (CallingState.RINGING === callingState && !callingToActiveChannel)
    return null;

  if (callingState === CallingState.JOINED) {
    return (
      <div
        className="str-video__call-panel rmc__call-panel-wrapper"
        ref={setPanelElement}
      >
        <SpeakerLayout />
        <div className="rmc__active-call-controls">
          <ScreenShareButton />
          <SpeakingWhileMutedNotification>
            <ToggleAudioPublishingButton />
          </SpeakingWhileMutedNotification>
          <ToggleVideoPublishingButton />
          <CancelCallButton />
        </div>
      </div>
    );
  }

  if ([CallingState.RINGING, CallingState.JOINING].includes(callingState)) {
    return (
      <div className="rmc__call-panel-wrapper" ref={setPanelElement}>
        <RingingCall />
      </div>
    );
  }

  return null;
};
Note:

useWorkspaceNavigation() knows the open channels only inside ChatView. That's why Video renders as a child of ChatView, and the channel list renders inside its views.

Terminating a call

What call termination means depends on the perspective:

  1. A user can reject an incoming pending call.
  2. A user can end / cancel own outgoing pending call.
  3. A call participant (who joined a call) can leave a call.

In case of the group call scenario, a single user rejecting an incoming call or participant leaving a call does not terminate the call for anybody else. However, a user who initiated a pending call will end it for everybody if it has not been joined by others yet. Call is ended also, if the last member rejected the incoming call.

A user who rejected or left a call can re-join the same call again as long as the call has not been ended. The call cannot be re-joined only if ended.

So depending on the context, we associate a bit different click handlers with the button, that terminates the call for the current user:

Reject an incoming call Our RingingCallControls attaches the following callback to the CancelCallButton button that rejects the incoming call:

<CancelCallButton
  onClick={() => call.leave({ reject: true, reason: "decline" })}
/>

End/Cancel an outgoing call Our RingingCallControls attaches the following callback to the CancelCallButton button that cancels the outgoing call:

<CancelCallButton
  onClick={() => call.leave({ reject: true, reason: "cancel" })}
/>

Leave already joined call The default callback of the CancelCallButton button is pure call.leave() without any parameters. Again the RingingCallControls as well as the demo's active call panel components make use of this by not overriding the default onClick:

<CancelCallButton />

Adding default styles

In order the default styles that come with both the chat and video SDKs can be applied, we import them into the file index.scss, each in its own cascade layer. The channel list needs a width, or the channel next to it takes the whole row. The ringing call controls in the channel list stay visible, while the row's default actions show only on hover or focus:

@import "stream-chat-react/dist/css/index.css" layer(default-chat-sdk);
@import "@stream-io/video-react-sdk/dist/css/styles.css"
  layer(default-video-sdk);

.str-chat {
  --str-chat__channel-list-width: 320px;
}

.rmc__channel-preview__call-controls {
  display: flex;
}

File index.scss is then imported into the app's root component. Additionally, we add StreamTheme component to wrap the app in an element carrying CSS class str-video. This will make sure all the variables and styles are applied to the child components:

import { Chat } from "stream-chat-react";
import { StreamTheme, StreamVideo } from "@stream-io/video-react-sdk";
import { Messenger } from "./components/Messenger";

import "./styles/index.scss";

export const Root = ({ apiKey, user, userToken }: RootProps) => {
  // create clients and connect users

  return (
    <StreamTheme as="main" className="main-container">
      <Chat client={chatClient}>
        <StreamVideo client={videoClient}>
          <Messenger userId={user.id} />
        </StreamVideo>
      </Chat>
    </StreamTheme>
  );
};