Search code examples
reactjsreact-hooksuse-effect

Adding a dependency to useEffect() causes inifnite loop. But removing dependency causes component to not rerender when database updates


The following code caused useEffect() to be called infinitely. Why is that the case? Removing drawingboarditems from the useEffect dependencies array solves the infinite loop, but as a result DrawingBoard will not automatically rerender whenenver a user adds an item to the database.

DrawingBoard.jsx

export default function DrawingBoard() {
  const [drawingboarditems, setdrawingboarditems] = useState([]);
  const currentUser = useContext(CurrentUserContext);
  const [loading, setLoading] = useState(true);
  const classes = useStyles();

  useEffect(() => {
    if (currentUser) {
      const items = [];
      //retrieving data from database 
      db.collection("drawingboarditems")
        .where("userID", "==", currentUser.id)
        .get()
        .then((query) => {
          query.forEach((doc) => {
            items.push({
              id: doc.id,
              ...doc.data(),
            });
          });
          setdrawingboarditems(items);
          setLoading(false);
        });
    }
  }, [currentUser, drawingboarditems]);

 return (
    <>
      {loading == false ? (
        <Container>
          <Masonry
            breakpointCols={breakpoints}
            className="my-masonry-grid"
            columnClassName="my-masonry-grid_column"
          >
            {drawingboarditems.map((item) => (
              <div>
                <Note item={item} />
              </div>
            ))}
            <Note form />
          </Masonry>
        </Container>
      ) : (
        <div className={classes.root}>
          <CircularProgress />
        </div>
      )}
    </>
  );

Note.jsx

import React from "react";
import { Card, CardHeader, IconButton, makeStyles } from "@material-ui/core";
import MoreVertIcon from "@material-ui/icons/MoreVert";
import Form from "./Form";
import CardWindow from "./CardWindow"; 

const useStyles = makeStyles((theme) => ({
  card: {
    backgroundColor: theme.palette.secondary.main,
    margin: theme.spacing(1, 0),
  },
}));

export default function Note({ item, form }) {
  const classes = useStyles();
  return (
    <Card className={classes.card}>
      {form ? (
        <Form />
      ) : (
        <CardHeader
          action={
            <CardWindow/>
          }
          title={item.title}
        />
      )}
    </Card>
  );
}

Form.jsx

import React, { useContext } from "react";
import TextField from "@material-ui/core/TextField";
import { makeStyles } from "@material-ui/core/styles";
import AddCircleOutlineIcon from "@material-ui/icons/AddCircleOutline";
import IconButton from "@material-ui/core/IconButton";
import { db } from "../FireStore";
import { CurrentUserContext } from "../utils/Context";

const useStyles = makeStyles((theme) => ({
  form: {
    "& .MuiTextField-root": {
      margin: theme.spacing(1),
      width: "70%", // 70% of card in drawing board
    },
  },
}));

export default function Form() {
  const classes = useStyles();
  const [value, setValue] = React.useState("");
  const currentUser = useContext(CurrentUserContext);

  const handleChange = (event) => {
    setValue(event.target.value);
  };

  const handleSubmit = (event) => {
    if (value) {
      event.preventDefault();
      db.collection("drawingboarditems").add({
        title: value,
        userID: currentUser.id,
      });
      setValue("");
    }
  };

  return (
    <form className={classes.form} noValidate autoComplete="off">
      <div>
        <TextField
          id="standard-textarea"
          placeholder="Add item"
          multiline
          onChange={handleChange}
          value={value}
        />
        <IconButton aria-label="add" onClick={handleSubmit}>
          <AddCircleOutlineIcon fontSize="large" />
        </IconButton>
      </div>
    </form>
  );
}

Solution

  • In your case I would move the logic to the DrawingBoard component, and would pass props to the children, so when a children adds an item, the main component would know to refresh the list of items.

    Example (not tested):

    Extract the logic to work with FireBase to functions. In that way they would be more re-usable, and would not add clutter to your code.

    const drawingboarditemsCollection = 'drawingboarditems';
    
    function getAllNotes(userID) {
      return db.collection(drawingboarditemsCollection)
        .where("userID", "==", userID)
        .get()
        .then((query) => {
          return query.map(doc => {
            items.push({
              id: doc.id,
              ...doc.data(),
            });
          });
        });
    }
    
    function addNote(userID, title) {
      return db.collection(drawingboarditemsCollection).add({
        title,
        userID,
      });
    }
    

    The DrawingBoard component should handle the connection with the server, and should pass functions as props to children:

    export default function DrawingBoard() {
      const [drawingboarditems, setdrawingboarditems] = useState([]);
      const currentUser = useContext(CurrentUserContext);
      const [loading, setLoading] = useState(true);
      const classes = useStyles();
      
      // add the logic to get notes by 
      const getNotes = useCallback(() => {
        setLoading(true);
        
        getAllNotes(currentUser.id)
          .then(items => {
            setdrawingboarditems(items);
          })
          .finally(=> {
            setLoading(false);
          });
      }, [currentUser]);
      
      // create the submit handler
      const handleSubmit = value => {
        addNote(currentUser.id, value)
          .then(getNotes); // after adding a note update the items
      }
    
      // initial get notes, or when currentUser changes
      useEffect(() => {
        getNotes();
      }, [getNotes]);
    
     return (
        <>
          {loading == false ? (
            <Container>
              <Masonry
                breakpointCols={breakpoints}
                className="my-masonry-grid"
                columnClassName="my-masonry-grid_column"
              >
                {drawingboarditems.map((item) => (
                  <div>
                    <Note item={item} />
                  </div>
                ))}
                <Note form onSubmit={handleSubmit} />
              </Masonry>
            </Container>
          ) : (
            <div className={classes.root}>
              <CircularProgress />
            </div>
          )}
        </>
      );
    }
    

    Pass the onSubmit function to Form:

    export default function Note({ item, form, onSubmit }) {
      const classes = useStyles();
      return (
        <Card className={classes.card}>
          {form ? (
            <Form onSubmit={onSubmit} />
          ) : (
            <CardHeader
              action={
                <CardWindow/>
              }
              title={item.title}
            />
          )}
        </Card>
      );
    }
    

    Use onSubmit to handle the submission:

    export default function Form({ onSubmit }) {
      const classes = useStyles();
      const [value, setValue] = React.useState("");
    
      const handleChange = (event) => {
        setValue(event.target.value);
      };
    
      const handleSubmit = (event) => {
        if (value) {
          event.preventDefault();
          
          onSubmit(value); // let the parent handle the actual update
    
          setValue("");
        }
      };
    
      return ( ... );
    }