Getting error "Cannot update a component while rendering a different component". What is the correct way to achieve what I'm attempting?

Viewed 889

As stated in the title, I'm currently getting the following error: Cannot update a component (HomePage) while rendering a different component (Query).

I'm fairly new to React, and even though I think I know the rule I'm breaking (only use hooks at the top level to preserve the order in which they're called) I'm not sure how to achieve what I'm attempting another way.

The goal is to setPreLaunch(true) if we don't have any interviews on the backend. This code does the job, but it throws an error. I tried sending the hook as a prop to the Interviews component and handling it there, but ran into the same error.

Here is the code in question:

import React, { useState } from "react";
import Interviews from "../../components/Interviews";
import Query from "../../components/Query";
import INTERVIEWS_QUERY from "../../queries/interviews/interviews";
import {
  Row,
  Col,
  Button,
  InputGroup,
  FormControl,
  Form,
} from "react-bootstrap";
import Filters from "../../components/Filters";
import jsonp from "jsonp";
import queryString from "query-string";

const HomePage = () => {
  const [genre, setGenre] = useState(["Any"]);
  const [authorHistory, setAuthorHistory] = useState(["Any"]);
  const [preLaunch, setPreLaunch] = useState(false);
  const [emailEntered, setEmailEntered] = useState("");
  const [emailMessage, setEmailMessage] = useState("");

  if (!preLaunch) {
    return (
      <div>
        <Row id="home-banner-container">
          <Col md={12}>
            {" "}
            <h1 id="home-main-heading" className="text-center">
              Text
            </h1>
            <p id="home-sub-heading" className="text-center">
              Text
            </p>
          </Col>
        </Row>
        <Row>
          <Col md={3}>
            <Filters
              genre={genre}
              setGenre={setGenre}
              authorHistory={authorHistory}
              setAuthorHistory={setAuthorHistory}
            />
            <div className="contact-blurb">
              Text
            </div>
            <Button variant="primary" className="contact-blurb-btn">
              Contact
            </Button>{" "}
          </Col>
          <Col md={9}>
            <Query query={INTERVIEWS_QUERY}>
              {({ data: { interviews } }) => {
                if (interviews.length < 1) {
                  setPreLaunch(true);
                } else {
                  setPreLaunch(false);
                }
                return (
                  <Interviews
                    interviews={interviews}
                    genreFilter={genre}
                    authorHistoryFilter={authorHistory}
                  />
                );
              }}
            </Query>
          </Col>
        </Row>
      </div>
    );
  } else {
    return (
      <div>
        <Row id="home-banner-container">
          <Col md={12}>
            {" "}
            <h1 id="home-main-heading" className="text-center">
              Text
            </h1>
            <p id="home-sub-heading" className="text-center">
              Text
            </p>
          </Col>
        </Row>
        <Row>
          <Col md={{ span: 6, offset: 3 }}>
            <Form
              onSubmit={subscribeToNewsLetter}
              className="prelaunch-email-form"
            >
              <InputGroup className="mb-3">
                <FormControl
                  placeholder="Email Address"
                  aria-label="Email Address"
                  type="email"
                  onChange={(e) => setEmailEntered(e.target.value)}
                  value={emailEntered}
                  className="prelaunch-email-input"
                />
                <InputGroup.Append>
                  <Button
                    variant="outline-secondary"
                    type="Submit"
                    className="prelaunch-email-btn"
                  >
                    Keep Me Updated
                  </Button>
                </InputGroup.Append>
              </InputGroup>
              <p>{emailMessage}</p>
            </Form>
          </Col>
        </Row>
        <Row>
          <Col md={3}></Col>
          <Col md={9}>
            <Query query={INTERVIEWS_QUERY}>
              {({ data: { interviews } }) => {
                if (interviews.length < 1) {
                  setPreLaunch(true);
                } else {
                  setPreLaunch(false);
                }
                return (
                  <Interviews
                    interviews={interviews}
                    genreFilter={genre}
                    authorHistoryFilter={authorHistory}
                  />
                );
              }}
            </Query>
          </Col>
        </Row>
      </div>
    );
  }
};

export default HomePage;

EDIT

Here's the updated HomePage code with the Query callback as Hero recommended.

  const [genre, setGenre] = useState(["Any"]);
  const [authorHistory, setAuthorHistory] = useState(["Any"]);
  const [preLaunch, setPreLaunch] = useState(false);
  const [emailEntered, setEmailEntered] = useState("");
  const [emailMessage, setEmailMessage] = useState("");

  const onInterviewsReady = (interviews) => {
    setPreLaunch(interviews.length < 1);
  };

  const myQuery = (
    <Query
      query={INTERVIEWS_QUERY}
      onInterviewsReady={(i) => onInterviewsReady(i)}
    >
      {({ data: { interviews } }) => {
        return (
          <Interviews
            interviews={interviews}
            genreFilter={genre}
            authorHistoryFilter={authorHistory}
          />
        );
      }}
    </Query>
  );

  ...

And this is the full Query file:

import React from "react";
import { useQuery } from "@apollo/react-hooks";

const Query = ({ children, query, id, onInterviewsReady }) => {
  const { data, loading, error } = useQuery(query, {
    variables: { id: id },
  });

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {JSON.stringify(error)}</p>;
  if (onInterviewsReady) {
    onInterviewsReady(data.interviews);
  }
  return children({ data });
};

export default Query;

With this updated code the result is the same. A functioning website that throws the same error as before.

1 Answers

If the interviews are fetched asynchronously within Query, an easy fix might be:

  • introduce a new callback prop onInterviewsReady to the Query component
  • let Query call this callback in an effect (useEffect) as soon as the interviews are ready
    • not using an effect would still cause a foreign component's state to change while rendering Query, which yields the warning
  • implement the callback by the hosting component HomePage with a function like const onInterviewsReady = interviews => setPreLaunch(interviews.length < 1); and pass it to Query
  • if you face unnecessary re-renders, you might want to ensure your callback reference is stable by modifying either
    • the host component (HomePage): wrap your callback definition in useCallback before passing it to the consuming component
    • the consuming component (Query): wrap your passed callback prop in useRef and call the ref's current value in the effect instead of the prop's value
Related