Build a Fully-Featured React Chat App - Pt. 1

6 min readMar 18, 2020

Team Chat is our sample group chat app. It includes user management, multiple group conversations, online/offline presence status, and more. Its UI is built in React, and it uses the PubNub Redux framework to manage client state in the application. Following this approach allows you to easily implement in-app chat for your own application.

React Chat App Tutorial Overview 

This is part 1 of a multi-part tutorial that walks through how Team Chat’s key features are put together, and how you can add them in your own applications. In this part, we’ll cover logging in with a user, showing multiple conversations, and exchanging messages in a group with other users. 

Part 2 covers joining and leaving conversations, working with occupancy and network status, and profanity filtering. Head over there once you're through part 1 to expand the functionality of your application.

The Github repository for the project can be found here.

Edit team-chatv0.7.0

React Chat App Frameworks and SDKs

React

Our React framework provides tools to leverage the PubNub JavaScript SDK within a React application by making use of React features like Provider and Consumer patterns and React Hooks. View reference docs.

Redux

Our Redux framework makes it easier for you to manage the state of data components inside an application. The framework adds structured data to the Redux store, listens to real-time events generated by the network, stores these events on the client side, and triggers updates to the view inside your application. View reference docs.

Javascript

Our React and Redux frameworks have a dependency on the Javascript SDK to manage connections and communicate with the PubNub network. View reference docs.

Prerequisites

PubNub Account - to run this app, you must create an account with PubNub and obtain your publish and subscribe keys. If you don't already have an account, you can create one for free.

Node.js (10+) - make sure that you have a recent version of Node installed. To check what version you’re running, type node --version into a terminal window.

A Git client - but chances are good you’ve already got one installed.

Run the app

1. Clone the Github repository.

git clone https://github.com/pubnub/typescript-ref-app-team-chat.git

2. Navigate to the root directory

cd typescript-ref-app-team-chat

3. Run the app. The first time you run the app, it will prompt you for your publish and subscribe keys from your account. If you don’t have an account, you can create one for free from the PubNub Dashboard.

npm install
npm start

You should now have the app running in your local web browser. When you're done, type Control-C in the terminal window to exit.

When you run the app for the first time, it runs the setup/populate.js script to populate sample data for users, spaces, and memberships into your PubNub keys. You can make changes to the sample data by editing the input_data.json file.

Initialize the PubNub Client

When the app first loads, it runs the main/App.tsx component to initialize the pubnub client with your publish and subscribe keys that are configured in the config/pubnub-keys.json file. The component also calls pubnub.addListener() to register all listeners at once (message listener, presence listener, and so on). The listener triggers events when the app receives messages, and automatically dispatches reducers to update the local store.

import Pubnub from "pubnub";
import { createPubNubListener } from "pubnub-redux";
import { PubNubProvider } from "pubnub-react";
import keyConfiguration from "config/pubnub-keys.json";

const pubnubConfig = Object.assign(
  {},{
    restore: true,
    heartbeatInterval: 0
  },
  keyConfiguration
);
const pubnub = new Pubnub(pubnubConfig);

const App = () => {
  useEffect(() => {
    pubnub.addListener(createPubNubListener(store.dispatch));
    return leaveApplication;
  }, []);
  
  return (
    <Provider store={store}>
      <PubNubProvider client={pubnub}>
        <Normalize />
        <GlobalStyles />
        <ApplicationRouter />
      </PubNubProvider>
    </Provider>
  );
};
  
export { App };

Log in with a User

While the splash screen displays, the app is selecting a user for you, and retrieving the conversations to which your user is subscribed. Keep reading to see how this works.

Team Chat Loading Screen

The authentication/login/Login.tsx component displays a login screen and selects a random user to log into the application. The user is selected from the list of IDs defined in the knownUserIds.json file. These are the same users that were initially populated on your key.

const Login = () => {
  const dispatch = useDispatch();
  const loggingIn = useSelector(isLoggingIn);
  const loggedIn = useSelector(isUserLoggedIn);

  const loginWithRandomlyPickedUser = () => {
    if (loggingIn || loggedIn) {
      return;
    }
    const userId = KnownIds[Math.floor(Math.random() * KnownIds.length)];
    dispatch(login(userId));
  };

  if (!loggedIn && !loggingIn) {
    loginWithRandomlyPickedUser();
  }

  return (
    <Wrapper>
      <Body>
        <Button onClick={loginWithRandomlyPickedUser}>
          {loggingIn ? "Connecting" : "Connect"}
        </Button>
        <PoweredByPubNub>
          <PoweredBy>Powered By</PoweredBy>
          <img alt="PubNub" src={PubNubLogo} />
        </PoweredByPubNub>
      </Body>
    </Wrapper>
  );
};

export { Login };

Set a user

Once the app selects a user, the following code (in authentication/loginCommand.ts) calls  pubnub.api.setUUID() to set the user’s ID on the pubnub object. This ID will be passed in all API calls to PubNub so that the network can identify the user who performs these operations.  We then call the fetchUserById() command to fetch user details from PubNub and store the logged in user in the local store.

export const login = (userId: string): ThunkAction<Promise<void>> => {
  return (dispatch, getState, context) => {
    dispatch(loggingIn());

    // Show the login screen for a minimum amount of time
    const timer = new Promise(resolve => setTimeout(resolve, 2000));

    // Set the UUID of the current user
    context.pubnub.api.setUUID(userId);

    // Fetch user from PubNub
    const isLoginSuccessful = dispatch(fetchUserById({ userId }))
});

Connect to conversations

Once the login is successful, we call the fetchMemberships() command to retrieve the user’s conversations from PubNub. The method returns a list of space memberships that were initially populated by the setup/populate.js script and stores these conversations in the local store. Next, we call pubnub.api.subscribe() to open a real-time connection with PubNub and subscribe the user to the conversation channels. If a client is subscribed to one or more channels, it will start receiving the messages and events published on those channels. Subscribing with presence also subscribes you to presence channels so you can receive join and leave events, which allow you to show other users as online or offline on the app. (We’ll dive into detail on presence in a future post.)

const isLoginSuccessful = dispatch(fetchUserById({ userId }))
  .then(() => {
    return dispatch(
      // Fetch the user’s conversations from PubNub
      fetchMemberships({
        userId,
        include: {
          spaceFields: true,
          customSpaceFields: false,
          customFields: false,
          totalCount: false
        }
      })
    );
  })
  .then(() => {
    // Subscribe to messages on the user's joined conversations.
    // The store contains a list of the user's conversations; we use
    // this list to perform the subscribe() operation.
    const conversationChannels = getConversationsByUserId(getState())[
      userId
    ].map(membership => membership.id);

    context.pubnub.api.subscribe({
      channels: conversationChannels,
      withPresence: true
    });
  });

Show User Details

In the top left section of the UI, the app shows details for the current user. These include an avatar image, the user’s name and title, and the user’s network status.

Team Chat User Details

The currentUser/MyUserDetails.tsx component displays the details for the user like name, title and profileUrl. The component calls getUsersById() selector to retrieve these user details from the local store. The component also calls the NetworkStatus component to show the user’s connection status. The next section has more details on network status.

export interface MyUserDetailsFragment {
  name: string;
  profileUrl: string;
  custom: {
    title: string;
  };
}

const MyUserDetails = () => {
  const userId = useSelector(getLoggedInUserId);
  const usersById = useSelector(getUsersById);
  const user = usersById[userId];

  // We must always have a user; change this to a development time error check
  if (user === undefined) {
    return <div>Loading...</div>;
  }

  return (
    <Wrapper>
      <Avatar>
        <NetworkStatus />
        <UserInitialsAvatar size={56} name={user.name} uuid={user.id} />
      </Avatar>
      <About>
        <UserName>{user.name}</UserName>
        <UserTitle>{user.custom.title}</UserTitle>
      </About>
    </Wrapper>
  );
};

Get network status

The currentUser/NetworkStatus/NetworkStatus.tsx component displays a green or gray dot to indicate if the user is connected to the network. The component calls the state.networkStatus.isConnected selector to get this connection status from the store.

const NetworkStatus = () => {
  let isConnected: boolean = useSelector(
    (state: AppState) => state.networkStatus.isConnected
  );
  return (
    <Wrapper>
      <PresenceIndicatorIcon fill={isConnected ? "#B8E986" : "#E9EEF4"} />
    </Wrapper>
  );
};

Show Joined Conversations

Below your user info, you’ll find your list of conversations. The Introductions conversation is automatically selected when the app launches.

You can join other conversations with the + button, and leave existing conversations with the Exit button. We’ll dive into that in a future post.

Team Chat my Conversations

The joinedConversations/MyConversations/MyConversations.tsx component gets the user’s list of conversations and allows the user to select a conversation. When the app loads, it selects the Introduction conversation by default.

export interface ConversationFragment {
  id: string;
  name: string;
}

const MyConversations = () => {
  const currentUserId = useSelector(getLoggedInUserId);
  const conversationsById = useSelector(getConversationsById);
  const conversations: ConversationFragment[] = useSelector(
    getJoinedConversations
  );
  const currentConversationId: string = useSelector(getCurrentConversationId);

  if (conversationsById === undefined) {
    return <div>Loading...</div>;
  }

  return (
    <Wrapper>
      <Title>
        Conversations
      </Title>
      <ConversationList>
        {conversations.map(conversation => (
          <ConversationItem
            id={conversation.id}
            name={conversation.name}
            selected={conversation.id === currentConversationId}
            key={conversation.id}
            onClick={() => {
              dispatch(focusOnConversation(conversation.id));
              dispatch(setLayoutDefault());
            }}
          ></ConversationItem>
        ))}
      </ConversationList>
    </Wrapper>
  );
};

Get conversations for user

The MyConversations component calls the  getConversationsByUserId() selector to get the user’s conversations from the local store. This selector returns the list of conversations, along with properties like id and name, to be displayed in the UI.

export const getJoinedConversations = createSelector(
  [getConversationsById, getLoggedInUserId, getConversationsByUserId],
  (
    conversations: ConversationsIndexedById,
    userId: string,
    userConversations: MembershipHash
  ): ConversationFragment[] => {
    return userConversations[userId]
      ? userConversations[userId].map(conversation => {
          return {
            id: conversation.id,
            name: conversations[conversation.id].name
          };
        })
      : [];
  }
);

Send and Receive Messages

Along the top of the conversation pane, the app displays details about the conversation: a title, description, and membership and presence information. Below that, the app shows messages in the conversation as they arrive. At the bottom, we have a component to let us input text and emoji.

Team Chat Conversation

Once a conversation is selected, the currentConversation/CurrentConversation/CurrentConversation.tsx component displays details of the conversation as well as its messages. There are three sub-components:

  • Header includes the conversation name, description, and occupancy count

  • MessageList shows the messages in the conversation (we’ll talk about this in the next section)

  • MessageInput shows the text input area and emoji picker (we’ll talk about this in the next section, too)

const CurrentConversation = () => {
  const panels = useSelector(getPanelStates);
  return (
    <AnimatedWrapper pose={panels.Content ? "open" : "closed"}>
      <Header />
      <MessageList />
      <MessageInput />
    </AnimatedWrapper>
  );
};

export { CurrentConversation };

Show the current conversation

The currentConversation/Header/Header.tsx component calls getConversationsById() to get details of the selected conversation from the local store, and renders them in the UI.

export interface ConversationDescriptionFragment {
  id: string;
  name: string;
  description: string;
}

export const getCurrentConversationDescription = createSelector(
  [getConversationsById, getCurrentConversationId],
  (
    conversations: ConversationsIndexedById,
    currentConversationId: string
  ): ConversationDescriptionFragment => {
    return {
      ...conversations[currentConversationId]
    };
  }
);

const Header = () => {
  const conversation: ConversationDescriptionFragment = useSelector(
    getCurrentConversationDescription
  );

  const dispatch = useDispatch();
  return (
    <Wrapper>
      <Body>
        <BackIconWrapper
          onClick={() => {
            dispatch(setLayoutLeft());
          }}
        >
          <BackIcon />
        </BackIconWrapper>
        <Information>
          <Name>{conversation.name}</Name>
          <Description>{conversation.description}</Description>
        </Information>
        <ConversationOccupancy />
      </Body>
      <Border />
    </Wrapper>
  );
};

export { Header };

We’ll explain the MessageList and MessageInput components in the next section.

Send messages

The currentConversation/MessageInput/MessageInput.tsx component renders the text input field and emoji picker.

const MessageInput = () => {
  const [message, setMessage]: MessageFragment = useState(emptyMessage);
  const conversationId: string = useSelector(getCurrentConversationId);
  const textareaRef = useRef<HTMLTextAreaElement>(
    document.createElement("textarea")
  );
  const conversationMessageInputValue: string = useSelector(
    getConversationMessageInputValue
  );

const send = () => {
    dispatch(
      sendMessageAction({
        type: "text",
        body: cleanMessage(message)
      })
    );
    dispatch(
      updateConversationMessageInputValueAction(conversationId, emptyMessage)
    );
    setMessage(emptyMessage);
  };

  useEffect(() => {
    setMessage(conversationMessageInputValue);
    autoExpand(textareaRef.current);
  }, [conversationId, conversationMessageInputValue]);

  return (
    <Wrapper>
      <EmojiSuggestion
        value={message}
        onSelection={messageWithEmoji => {
          setMessage(messageWithEmoji);
        }}
      />
      <Container>
        <TextArea
          ref={textareaRef}
          rows={1}
          value={message}
          onChange={changed}
          onKeyPress={handleKeyPress}
          placeholder="Type Message"
        />
        <EmojiInput
          value={message}
          onSelection={messageWithEmoji => {
            setMessage(messageWithEmoji);
          }}
        />
      </Container>
    </Wrapper>
  );
};

Send Message Command

The sendMessageAction() method (in messages/sendMessageCommand.ts) sends your message to the other users in the conversation. It calls the sendMessage() command to publish a message to PubNub on the conversation channel. The message includes sender details.

export const sendMessageAction = (message: MessageContent): ThunkAction => {
  return (dispatch, getState) => {
    const state = getState();
    return dispatch(
      sendMessage({
        channel: getCurrentConversationId(state),
        message: {
          content: message,
          sender: getLoggedInUserId(state)
        }
      })
    );
  };
};

Add emoji to a message

The MessageInput.tsx component also includes an emoji/EmojiInput/EmojiInput.tsx component to add emoji in a message. The component uses the emoji-mart library to render emoji on the screen. If an emoji is selected, it adds that emoji to the message input.

import "emoji-mart/css/emoji-mart.css";
import { Picker, EmojiData } from "emoji-mart";

interface EmojiInputProps {
  value: string;
  onSelection(contentWithEmoji: string): any;
}

const EmojiInput = ({ value, onSelection }: EmojiInputProps) => {
  const [showPicker, setPickerState] = useState(false);
  const picker = useRef<HTMLDivElement>(null);

  const dismissPicker = useCallback(() => {
    setPickerState(false);
  }, [setPickerState]);

  useClickOutside([picker], dismissPicker);

  const togglePicker = () => {
    setPickerState(!showPicker);
  };

  const addEmoji = (emoji: EmojiData) => {
    if ("native" in emoji) {
      onSelection(`${value}${emoji.native}`);
      dismissPicker();
    }
  };

  return (
    <div ref={picker}>
      <Dialog>
        {showPicker && <Picker emoji="" title="" onSelect={addEmoji} />}
      </Dialog>
      <EmojiButton onClick={togglePicker}>
        <FunnyEmoji />
      </EmojiButton>
    </div>
  );
};

export { EmojiInput };

Receive messages

The currentConversation/MessageList/MessageList.tsx component displays the list of messages when they are received by the app. The component calls the getCurrentConversationMessages() selector to fetch messages from the local store and render them on the screen.

Each message that is received includes the message text as well as the id of the user who published that message. This component calls the getUsersById() selector to fetch details for the user from the local store to display it alongside the message.

Go to the next section for more details on how a message is displayed.

const MessageList = () => {
  type ConversationScrollPositionsType = { [conversationId: string]: number };
  const conversationId: string = useSelector(getCurrentConversationId);
  const [
    conversationsScrollPositions,
    setConversationsScrollPositions
  ] = useState<ConversationScrollPositionsType>({});

  const updateCurrentConversationScrollPosition = (scrollPosition: number) => {
    setConversationsScrollPositions({
      ...conversationsScrollPositions,
      [conversationId]: scrollPosition
    });
  };

  const handleScroll = (e: any) => {
    const scrollPosition = e.target.scrollTop;
    if (scrollPosition !== 0) {
      updateCurrentConversationScrollPosition(scrollPosition);
    }
  };

  const restoreConversationScrollPosition = (conversationId: string) => {
    const conversationScrollPosition: number =
      conversationsScrollPositions[conversationId];
    if (conversationScrollPosition) {
      wrapper.current.scrollTo(0, conversationScrollPosition);
    }
  };

  const memoizedRestoreConversationScrollPositionCallback = useCallback(
    restoreConversationScrollPosition,
    [conversationId]
  );

  const messages: MessageFragment[] = useSelector(
    getCurrentConversationMessages
  );
  const wrapper = useRef<HTMLDivElement>(document.createElement("div"));
  const el = wrapper.current;

  const scrollToBottom = useCallback(() => {
    return (el.scrollTop = el.scrollHeight - el.clientHeight);
  }, [el]);

  const hasReachedBottom = el.scrollHeight - el.clientHeight === el.scrollTop;

  useEffect(() => {
    if (hasReachedBottom) {
      scrollToBottom();
    }
  }, [messages.length, hasReachedBottom, scrollToBottom]);

  useEffect(() => {
    memoizedRestoreConversationScrollPositionCallback(conversationId);
  }, [memoizedRestoreConversationScrollPositionCallback, conversationId]);

  return (
    <Wrapper ref={wrapper} onScroll={handleScroll}>
      <WelcomeMessage />
      {messages.map(message => (
        <Message message={message} key={message.timetoken} />
      ))}
    </Wrapper>
  );
};

Display a message

The Message/Message.tsx component styles a message and displays it in the UI. Messages are displayed with a timetoken, content, sender’s name and avatar. The message displays the sender as “unknown” if the user ID isn’t present in the local store.

export interface MessageFragment {
  sender: {
    id: string;
    name: string;
  };
  timetoken: string;
  message: {
    content: {
      body: string;
    };
  };
}

interface MessageProps {
  message: MessageFragment;
  avatar?: ReactNode;
}

const Message = ({ message, avatar }: MessageProps) => {
  // show unknown sender when sender is missing
  let sender = message.sender || { id: "unknown", name: "Unknown" };

  return (
    <Wrapper>
      <Avatar>
        {avatar ? (
          avatar
        ) : (
          <UserInitialsAvatar size={36} name={sender.name} uuid={sender.id} />
        )}
      </Avatar>
      <Body>
        <Header>
          <SenderName>{sender.name}</SenderName>
          <TimeSent>{convertTimestampToTime(message.timetoken)}</TimeSent>
        </Header>
        <Content>{message.message.content.body}</Content>
      </Body>
    </Wrapper>
  );
};

export { Message };

The features/messages/messageModel.ts file calls createMessageReducer(), which responds to actions dispatched to update the state of messages in the store. The message state is automatically updated and the UI change is rendered as the app receives messages in a conversation.

/**
 * create a reducer which holds all known message envelope objects in a normalized form
 */
export const MessageStateReducer = createMessageReducer<MessageEnvelope>();

Conclusion

That's it! You should now have a better understanding of how the team chat app uses the PubNub React and Redux frameworks and the underlying Javascript SDK to show user details, conversations, and send and receive messages with other users in the app. We’ll cover more features in future posts, including joining and leaving conversations, working with occupancy and network status, and profanity filtering.

Team Chat Intro

As next steps, go to our React, Redux and Javascript reference docs to build your real-time application with PubNub.

More from PubNub

How to Create a Dating App: 7 Steps to Fit Any Design
Insights6 minMar 15, 2023

How to Create a Dating App: 7 Steps to Fit Any Design

There are common underlying technologies for a dating app, and in this post, we’ll talk about the major technologies and designs...

Michael Carroll

Michael Carroll

How to Create a Real-time Public Transportation Schedule App
Build6 minMar 14, 2023

How to Create a Real-time Public Transportation Schedule App

How to use geohashing, JavaScript, Google Maps API, and BART API to build a real-time public transit schedule app.

Michael Carroll

Michael Carroll

How to Create Real-Time Vehicle Location Tracking App
Build2 minMar 9, 2023

How to Create Real-Time Vehicle Location Tracking App

How to track and stream real-time vehicle location on a live-updating map using EON, JavaScript, and the Mapbox API.

Michael Carroll

Michael Carroll

Talk to an expert