Search code examples
javascriptreactjsuse-effectuse-state

React state updates without a setState function - Unexpected


I have a few chat components, chat (parent), CreateMessage(child), and DisplayMessages(child). All three components will be shown below.

The user creates a message with the CreateMessage component. It saves it in the useState hook, indivMessages, stored in the parent Chat component.

The indivMessages are sent to the DisplayMessages component. It displays the messages as well as groups messages by the same author together based off the user id.

The problem is, the indivMessages state is getting set with the value from formattedMessages, which is only set inside the useEffect hook in DisplayMessages.

Why is indivMessages getting set with the value for formattedMessages??

For the sake of this example, I commented out all of the socket stuff to just work in a sterile environment - the same results will happen both ways.

Chat.js

import React, { useState, useEffect, useContext } from "react";
import { useSelector } from "react-redux";
import { SocketContext } from "src/SocketContext";

import CreateMessage from "./details/CreateMessage";
import DisplayMessages from "./details/DisplayMessages";

export default function Chat(props) {
  const state = useSelector((state) => state);
  const [indivMessages, setIndivMessages] = useState([]);
  const socket = useContext(SocketContext);

  // useEffect(() => {
  //   if (state.chatShow) {
  //     socket.emit("SUBSCRIBE_CHAT", state.chat.chatRoom);
  //     return () => {
  //       socket.emit("UNSUBSCRIBE", state.chat.chatRoom);
  //     };
  //   }
  // });

  // useEffect(() => {
  //   socket.on("new_message", (data) => {
  //     setIndivMessages([...indivMessages, data]);
  //   });
  //   return () => {
  //     socket.off("new_message");
  //   };
  // }, [socket, indivMessages]);

  return (
    <div className="d-flex flex-column h-100 justify-content-end">
      <DisplayMessages state={state} indivMessages={indivMessages} />
      <CreateMessage
        state={state}
        indivMessages={indivMessages}
        setIndivMessages={setIndivMessages}
      />
    </div>
  );
}

CreateMessage.js

import React, { useState, useContext } from "react";

import { CInputGroup, CInput, CInputGroupAppend, CButton } from "@coreui/react";
import CIcon from "@coreui/icons-react";
import { SocketContext } from "src/SocketContext";

export default function CreateMessage(props) {
  const { indivMessages, setIndivMessages, state } = props;
  const [newMessage, setNewMessage] = useState("");
  const socket = useContext(SocketContext);

  const sendMessage = () => {
    let messageTemplate = {
      messages: [{ msg: newMessage }],
      username: state.user.username,
      _id: indivMessages.length + 1,
      ownerId: state.user._id,
      picture: state.user.picture,
      chatRoom: state.chat.chatRoom,
      date: Date.now(),
    };
    // socket.emit("create_message", messageTemplate);
    setIndivMessages((msgs) => [...msgs, messageTemplate]);
    document.getElementById("msgInput").value = "";
  };

  return (
    <CInputGroup style={{ position: "relative", bottom: 0 }}>
      <CInput
        type="text"
        style={{ fontSize: "18px" }}
        id="msgInput"
        className="rounded-0"
        placeholder="Type a message here..."
        autoComplete="off"
        onChange={(e) => setNewMessage(e.target.value)}
        onKeyUp={(e) => e.code === "Enter" && sendMessage()}
      />
      <CInputGroupAppend>
        <CButton color="success" className="rounded-0" onClick={sendMessage}>
          <CIcon name="cil-send" />
        </CButton>
      </CInputGroupAppend>
    </CInputGroup>
  );
}

DisplayMessages.js

import React, { useEffect, useState } from "react";

import { CContainer, CCard, CImg, CCol, CLabel } from "@coreui/react";

export default function DisplayMessages(props) {
  const { indivMessages, state } = props;
  const [formattedMessages, setFormattedMessages] = useState([]);

  useEffect(() => {
    //Create Grouped Messesges
    let messagesArray = [...indivMessages];
    let sortedArray = messagesArray.sort((a, b) => {
      return new Date(a.date) - new Date(b.date);
    });

    let grouped = [];

    for (let i = 0; i < sortedArray.length; i++) {
      let index = grouped.length - 1;
      if (sortedArray[i].ownerId === grouped[index]?.ownerId) {
        let lastMessage = grouped.pop();
        sortedArray[i].messages[0]._id = sortedArray[i]._id;
        lastMessage.messages = [
          ...lastMessage.messages,
          sortedArray[i].messages[0],
        ];
        grouped.push(lastMessage);
      } else {
        console.log(i, grouped.length);
        grouped.push(sortedArray[i]);
      }
    }
    setFormattedMessages(grouped);
  }, [indivMessages]);

  useEffect(() => {
    let msgContainer = document.getElementById("msgContainer");
    msgContainer.scrollTop = msgContainer.scrollHeight;
  }, [formattedMessages]);

  return (
    <CContainer
      className="mt-2 no-scroll-bar"
      style={{ overflow: "auto", maxHeight: "85vh" }}
      id="msgContainer"
    >
      {formattedMessages.map((msg) => {
        return (
          <CCard
            key={msg._id}
            className="d-flex flex-row p-2"
            color="secondary"
            accentColor={state.user._id === msg.ownerId && "primary"}
          >
            <CImg
              src={msg.picture}
              alt={msg.owner}
              className="w-25 align-self-start rounded"
            />
            <CCol>
              <CLabel>
                <strong>{msg.username}</strong>
              </CLabel>
              {msg.messages.map((message) => {
                return (
                  <p key={message._id ? message._id : msg._id}>{message.msg}</p>
                );
              })}
            </CCol>
          </CCard>
        );
      })}
    </CContainer>
  );
}

I am thinking that something is going on within the useEffect hook in the DisplayMessages component. This function executes every time the indivMessages array changes.

It creates grouped messages by checking if the newest message's author (ownerId), is the same as the previous message's author. If they are the same, it extracts all the messages from the message itself, and adds them to the previous message's message key in order to create grouped messages.

This result is what is being set in the indivMessages array, which is unexpected since I do not set indivMessages with grouped messages!

Thanks for your help and thanks for some pointers in the right direction!


Solution

  • In the useEffect code, you're modifying the objects in sortedArray, which are also held in indivMessages. For instance:

    sortedArray[i].messages[0]._id = sortedArray[i]._id;
    

    Your code setting up sortedArray just copies the array, not the objects within it. If you want to modify those objects, you need to make copies first. For instance, the example above becomes:

    sortedArray[i] = {
        ...sortedArray[i],
        messages: [{...sortedArray[i].messages[0], _id = sortedArray[i]._id}, ...sortedArray[i].messages.slice(1)],
    };
    

    ...or similar.

    You have the same sort of problem with:

    lastMessage.messages = [
      ...lastMessage.messages,
      sortedArray[i].messages[0],
    ];
    

    ...but probably want to solve it elsewhere (perhaps by changing grouped.push(sortedArray[i]); to grouped.push({...sortedArray[i]});, but I haven't done a really deep read of that code).