0

I have a button FontAwesomeIcon that, when clicking, opens a modal. However, the issue is that for some reason you need to click the button twice in order for the modal to open. I’ve seen many other posts with similar issues, but it doesn’t seem to make sense to me.

Can someone explain what’s going on here? I know that the setter variable is asynchronous, but even adding async await to the useEffect does nothing. I vaguely remember reading somewhere that you shouldn’t (or can’t) use state setting inside a useEffect but can’t remember why, although the next version of react may change that.

From looking at the console logs, A is always logged last, which makes me believe that const [modalIsOpen, setModalIsOpen] = useState<boolean>(false); is running after the state setting in the useEffect and redeclaring the value to false (although maybe not as even when the modal shows after 2 clicks, it logs A last again). The console logs also suggest that after the first click it sets the state correctly but then something immediately sets the open state back to false, despite there being apparently nothing that should do this. Also might be worth noting that hovering over the button, clicking once, then hovering out and then over the button again requires a further 2 clicks to open the modal.

Here’s the Buttons component:

  const Buttons: FC<ButtonsProps> = ({ expanded, setExpanded }) => {
    console.log('A');
    const [modalIsOpen, setModalIsOpen] = useState<boolean>(false);

    const handleClick = () => {
      console.log('B');
      setModalIsOpen(true);
      // use currentPage.id to determine which item to delete
    };

    React.useEffect(() => {
      console.log(
        'change of modalIsOpen or setModalIsOpen detected - now modalIsOpen is:',
        modalIsOpen
      );
    }, [modalIsOpen]);

    return (
      <div className={styles.buttonsContainer}>
        <FontAwesomeIcon
          icon={faTrash}
          onMouseEnter={() => setDeleteHovered(true)}
          onMouseLeave={() => setDeleteHovered(false)}
          onClick={() => handleClick()}
        />
        <DeletePageModal
          modalIsOpen={modalIsOpen}
          setModalIsOpen={setModalIsOpen}
        />
      </div>
    );
  };

And the DeletePageModal component:

export const DeletePageModal: FC<DeletePageModalProps> = ({
  modalIsOpen,
  setModalIsOpen,
}) => {
  const afterOpenModal: () => void = () => {
    // references are now sync'd and can be accessed.
    console.log('Modal opened');
  };

  const closeModal: () => void = () => {
    setModalIsOpen(false);
  };

  // onRequestClose: do something when clicked close
  // contentLabel: just label the modal (aria name for accessibility)

  return (
    <div>
      <Modal
        isOpen={modalIsOpen}
        onAfterOpen={afterOpenModal}
        onRequestClose={closeModal}
        className={styles.modal}
        contentLabel='Delete page confirmation'>
        <button onClick={closeModal} className={styles.close}>
          <FontAwesomeIcon icon={faTimes} />
        </button>
        <div>
          <h2>Title</h2>
          <p>Content</p>
          <button onClick={closeModal} className={styles.button}>
            Close
          </button>
        </div>
      </Modal>
    </div>
  );
};

Thank you

Edit: for more context, I've included more parent containers which show where setDeleteHovered comes from:

export const Sidebar: FC<SidebarProps> = ({ pages, currentPageData }) => {
  const [expanded, setExpanded] = useState<boolean>(true);
  const [deleteHovered, setDeleteHovered] = useState<boolean>(false);
  const { currentPage, fetchPage } = currentPageData;

  const SidebarList: FC<{
    pages: Array<WebPage>;
    deleteHovered: boolean;
  }> = ({ pages }) => (
    <ul>
      {pages.map((page: WebPage) => {
        const isCurrentPage = page?.id === currentPage?.id;
        const classes = `${isCurrentPage ? styles.current : ''} ${
          isCurrentPage && deleteHovered ? styles.deleteHovered : ''
        }`;

        return (
          <li
            key={page.title}
            className={classes}
            onClick={() => {
              if (isCurrentPage) return;
              fetchPage(`http://localhost:8000/api/pages/${page.id}`);
            }}>
            <p>{page.title}</p>
            <div className={styles.iconContainer}>
              <FontAwesomeIcon icon={faTrash} />
            </div>
          </li>
        );
      })}
    </ul>
  );

  interface ButtonsProps {
    expanded: boolean;
    setExpanded: Dispatch<boolean>;
    setDeleteHovered: Dispatch<boolean>;
  }

  const Buttons: FC<ButtonsProps> = ({ expanded, setExpanded }) => {
    console.log('A');
    const [modalIsOpen, setModalIsOpen] = useState<boolean>(false);

    const handleClick = () => {
      console.log('B');
      setModalIsOpen(true);
      // use currentPage.id to determine which item to delete
      // unknown bug SO post: https://stackoverflow.com/questions/70089132/seemingly-simple-react-state-requires-clicking-twice-to-change-the-state-properl
    };

    React.useEffect(() => {
      console.log(
        'change of modalIsOpen or setModalIsOpen detected - now modalIsOpen is:',
        modalIsOpen
      );
    }, [modalIsOpen]);

    return (
      <div className={styles.buttonsContainer}>
        <FontAwesomeIcon icon={faPlusSquare} />
        <FontAwesomeIcon
          icon={faTrash}
          className={styles.expandedOnly}
          onMouseEnter={() => setDeleteHovered(true)}
          onMouseLeave={() => setDeleteHovered(false)}
          onClick={() => handleClick()}
        />
        <FontAwesomeIcon
          icon={expanded ? faCompressArrowsAlt : faExpandArrowsAlt}
          onClick={() => setExpanded(!expanded)}
        />
        <DeletePageModal
          modalIsOpen={modalIsOpen}
          setModalIsOpen={setModalIsOpen}
        />
      </div>
    );
  };

  const classes = `${styles.sidebar} ${
    expanded ? styles.expanded : styles.contracted
  }`;

  return (
    <div className={classes}>
      <h1>Pages</h1>
      <SidebarList pages={pages} deleteHovered={deleteHovered} />
      <Buttons
        expanded={expanded}
        setExpanded={setExpanded}
        setDeleteHovered={setDeleteHovered}
      />
    </div>
  );
};
3
  • Where does setDeleteHovered come from, it doesn't look to be declared in your code? This looks like it should work, but if the hover is causing a total unmount-remount, then your state will be lost Commented Nov 23, 2021 at 23:49
  • I've updated my post to include this, thank you. setDeleteHovered comes from a parent component's state Commented Nov 24, 2021 at 13:59
  • You should create a MCVE, preferably in the form of an online sandbox. -1 Commented Nov 24, 2021 at 14:25

1 Answer 1

1

To solve, move the SidebarList and Buttons function/component declarations outside of Sidebar

The reason you are getting full unmount-remounts of your components when the parent state changes is because you are declaring SidebarList and Buttons as new functions inside the Sidebar component.

That means each time Sidebar is rendered, a new version of the other two components are created. When React attempts to reconcile the render tree, it will see that the old SidebarList is !== the new SidebarList and Buttons, which means that it won't know to persist any state between renders, which is why the state keeps getting reset to false

Sign up to request clarification or add additional context in comments.

1 Comment

Thanks for explaining this. Your solution solved the issue

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.