reactjstypescriptreact-hooks

React project doesn't compile in CI pipeline because of missing dependency in useEffect Hook (runs fine in local deployment)


I get this error message:

src/questions/QuestionComponent.tsx Line 22:8: React Hook useEffect has a missing dependency: 'setWorkdayAnswer'. Either include it or remove the dependency array react-hooks/exhaustive-deps

The purpose of the code concerning the problem is that I have checkboxes from Monday to Friday and every time any checkbox gets hit, the state information of all checkboxes should be sent to the backend via postWorkdayAnswer().

Then I do npm start everything works fine, no bugs in executing the frontend whatsoever, but if run npm run build I get the above error, which prevents my CI pipeline to build successfully.

This is the code:

import { Question, TimeAnswer, TimeUnit, WorkdayAnswer } from "../service/models";
import { useEffect, useState } from "react";
import { getTimeUnitList, postTimeAnswer, postWorkdayAnswer } from "../service/apiService";
import TimeAnswerProperties from "./TimeAnswerProperties";
import "./QuestionComponent.css"
import { convertTimeUnitToMinutes } from "../utilities/Util"

interface QuestionProps {
  question: Question
  answers: Array<TimeAnswer>
  answerCallback: () => void // refreshing site
}

export default function QuestionListComponent(props: QuestionProps) {
  const [timeUnitList, setTimeUnitList] = useState<Array<TimeUnit>>([])
  const [currentTimeAnswer, setCurrentTimeAnswer] = useState<string>()
  const [workdays, setWorkdays] = useState<boolean[]>([true, true, true, true, true, false, false]);**
  const [errorMessage, setErrorMessage] = useState("")

  useEffect(() => {
    setWorkdayAnswer();
  }, [workdays]);

  const setWorkdayAnswer = () => {
    const workdayAnswer: WorkdayAnswer = {
      questionId: props.question.id,
      question: props.question.question,
      monday: workdays[0],
      tuesday: workdays[1],
      wednesday: workdays[2],
      thursday: workdays[3],
      friday: workdays[4],
      saturday: workdays[5],
      sunday: workdays[6],
    };

    postWorkdayAnswer(workdayAnswer)
      .then(() => props.answerCallback()) // refreshing site
      .catch(() => {
        console.error("Error posting workday answer:", Error);
        setErrorMessage("error posting answer");
      })
  };

  useEffect(() => {
    const loadTimeUnitList = async () => {
      try {
        const data = await getTimeUnitList();
        setTimeUnitList(data);
      } catch {
        setErrorMessage("Failed to load time unit list")
      }
    };

    loadTimeUnitList();

    const currentAnswer = props.answers.find(answer => answer.questionId === props.question.id)
    setCurrentTimeAnswer(currentAnswer ? currentAnswer.time : "00:00");
  }, [props.answers, props.question.id])

  const setTimeAnswer = (timeInMinutes: number) => {
    const timeAnswer: TimeAnswer = {
      questionId: props.question.id,
      question: props.question.question,
      timeInMinutes: timeInMinutes
    }

    postTimeAnswer(timeAnswer)
      .then(() => props.answerCallback()) // refreshing site
      .catch(() => {
        console.error("Error posting time answer:", Error);
        setErrorMessage("error posting answer");
      })
  };

  const timeUnitsToChoose = timeUnitList
    .filter(timeUnit => {
      if (!props.question.previousQuestionId) {
        return true;
      } else {
        const previousQuestionAnswer = props.answers.find(answer => answer.questionId === props.question.previousQuestionId)
        if (previousQuestionAnswer) {
          const currentTimeAnswerInMinutes = convertTimeUnitToMinutes(currentTimeAnswer);
          return (
            timeUnit.timeInMinutes! >= previousQuestionAnswer.timeInMinutes) &&
            timeUnit.timeInMinutes !== currentTimeAnswerInMinutes;
          } else {
            return true;
          }
        }
      }
    )
    .map(timeUnit => <TimeAnswerProperties key={timeUnit.id} timeUnit={timeUnit}/>)

  const renderWorkdayCheckboxes = () => {
    const days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
    return days.map((day, index) => (
      <span key={index}>
        <input
          type="checkbox"
          id={`check${index}`}
          checked={workdays[index]}
          onChange={event => {
            const newWorkdays = [...workdays];
            newWorkdays[index] = event.target.checked;
            setWorkdays(newWorkdays);
          }}
        />
        <label htmlFor={`check${index}`}>{day}</label>
      </span>
    ));
  };

  const QuestionType = () => {
    if (props.question.question === "On which days do you work ?") {
      return (
        <div>
          {renderWorkdayCheckboxes()}
        </div>
      );
    } else {
      return (
        <div>
          <select
            className="questionAnswer"
            name={props.question.type}
            id={props.question.id}
            onChange={event => setTimeAnswer(Number(event.target.value))}
          >
            value=<TimeAnswerProperties
              key={currentTimeAnswer}
              timeUnit={{
                time: currentTimeAnswer + "",
                length: 15,
                end: "24:00"
              }}
            />
            {timeUnitsToChoose}
          </select>
        </div>
      );
    }
  };

  return (
    <div className="question">
      <p>{props.question.question}</p>
      {errorMessage && <div>{errorMessage}</div>}
      {QuestionType()}
    </div>
  );
}

I did try adding setWorkdayAnswer as a dependency in

useEffect(() => {
  setWorkdayAnswer();
}, [workdays]);

which causes weird bugs that days are not being saved right are the last one is missing or something like that.

I also tried adding setWorkdays, which also causes weird bugs.

Solution:

Parent Component "Question.tsx" with useCallback after Drew's feedback:

import {NavLink} from "react-router-dom";
import {useEffect, useState, useCallback} from "react";
import {getQuestionList, getTimeAnswer} from "../service/apiService";
import QuestionListComponent from "./QuestionComponent";
import {Question, TimeAnswer} from "../service/models";
import "./Questions.css"


export default function QuestionList() {

    const [questionList, setQuestionList] = useState<Array<Question>>([])
    const [errorMessage, setErrorMessage] = useState("")
    const [answers, setAnswers] = useState<Array<TimeAnswer>>([])

    useEffect(() => {
        getQuestionList()
            .then(data => setQuestionList(data))
            .catch(() => setErrorMessage("questionList doesnt load"));
    }, [])

    const onAnswer = useCallback(() => {
/*         getQuestionList()
            .then(data => setQuestionList(data))
            .catch(() => setErrorMessage("questionList doesnt load")); */
        getTimeAnswer()
            .then(data => setAnswers(data))
            .catch(() => setErrorMessage("timeAnswer doesnt load"));
    }, []);

    const questions = questionList.sort((s1, s2) => s1.order - s2.order).map(question => <QuestionListComponent
            key={question.id} question={question} answers={answers} answerCallback={onAnswer}/>)

    return (
        <div className="body">
        <div className="questions">
            <h1 className="questionHeadline"> Questions:</h1>
            {errorMessage && <div>{errorMessage}</div>}
            {questions}

            <br/>
            <br/>

            <NavLink to={"/timetable"}>
                <button className="createButton">create</button>
            </NavLink>
        </div>

        </div>
    )
}

fixed "QuestionComponent.tsx":

import {Question, TimeAnswer, TimeUnit, WorkdayAnswer} from "../service/models";
import {useEffect, useState} from "react";
import {getTimeUnitList, postTimeAnswer, postWorkdayAnswer} from "../service/apiService";
import TimeAnswerProperties from "./TimeAnswerProperties";
import "./QuestionComponent.css"
import {convertTimeUnitToMinutes} from "../utilities/Util"

interface QuestionProps {
    question: Question
    answers: Array<TimeAnswer>
    answerCallback: () => void //refreshing site
}

export default function QuestionListComponent(props: QuestionProps) {
    const [timeUnitList, setTimeUnitList] = useState<Array<TimeUnit>>([])
    const [currentTimeAnswer, setCurrentTimeAnswer] = useState<string>()
    const [workdays, setWorkdays] = useState<boolean[]>([true, true, true, true, true, false, false]);
    const [errorMessage, setErrorMessage] = useState("")

    const question = props.question
    const answerCallback = props.answerCallback

    useEffect(() => {
      const workdayAnswerDbUpdate = () => {
        const workdayAnswer: WorkdayAnswer = {
          questionId: question.id,
          question: question.question,
          monday: workdays[0],
          tuesday: workdays[1],
          wednesday: workdays[2],
          thursday: workdays[3],
          friday: workdays[4],
          saturday: workdays[5],
          sunday: workdays[6],
        };

        postWorkdayAnswer(workdayAnswer)
          .then(() => answerCallback()) // refreshing site
          .catch(() => {
            console.error("Error posting workday answer:", Error);
            setErrorMessage("error posting answer");
          });
      };

      workdayAnswerDbUpdate();
    }, [answerCallback, question, workdays]);

    useEffect(() => {
        const loadTimeUnitList = async () => {
            try {
                const data = await getTimeUnitList();
                setTimeUnitList(data);
                } catch {
                    setErrorMessage("Failed to load time unit list")
                }
            };

            loadTimeUnitList();

        const currentAnswer = props.answers.find(answer => answer.questionId === props.question.id)
        setCurrentTimeAnswer(currentAnswer ? currentAnswer.time : "00:00");
    }, [props.answers, props.question.id])

    const timeAnswerDbUpdate = (timeInMinutes: number) => {
        const timeAnswer: TimeAnswer = {
            questionId: props.question.id,
            question: props.question.question,
            timeInMinutes: timeInMinutes
        }
        postTimeAnswer(timeAnswer)
            .then(() => props.answerCallback()) // refreshing site
            .catch(() => {
                console.error("Error posting time answer:", Error);
                setErrorMessage("error posting answer");
                })
    };

    const timeUnitsToChoose = timeUnitList
        .filter(timeUnit => {
            if (!props.question.previousQuestionId) {
                return true;
            } else {
                const previousQuestionAnswer = props.answers.find(answer => answer.questionId === props.question.previousQuestionId)
                if (previousQuestionAnswer) {
                    const currentTimeAnswerInMinutes = convertTimeUnitToMinutes(currentTimeAnswer);
                    return (
                        timeUnit.timeInMinutes! >= previousQuestionAnswer.timeInMinutes) &&
                        timeUnit.timeInMinutes !== currentTimeAnswerInMinutes;
                } else {
                    return true;
                }
            }
        })
        .map(timeUnit => <TimeAnswerProperties key={timeUnit.id} timeUnit={timeUnit}/>)

       const renderWorkdayCheckboxes = () => {
           const days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
           return days.map((day, index) => (
               <span key={index}>
                   <input
                       type="checkbox"
                       id={`check${index}`}
                       checked={workdays[index]}
                       onChange={event => {
                           const newWorkdays = [...workdays];
                           newWorkdays[index] = event.target.checked;
                           setWorkdays(newWorkdays);
                       }}
                   />
                   <label htmlFor={`check${index}`}>{day}</label>
               </span>
           ));
       };

       const QuestionType = () => {
           if (props.question.question === "On which days do you work ?") {
               return (
                   <div>
                       {renderWorkdayCheckboxes()}
                   </div>
               );
           }

           else{
               return(
               <div>
                   <select
                       className="questionAnswer"
                       name={props.question.type}
                       id={props.question.id}
                       onChange={event => timeAnswerDbUpdate(Number(event.target.value))}
                   >
                              value=<TimeAnswerProperties
                              key={currentTimeAnswer}
                              timeUnit={{
                                time: currentTimeAnswer + "",
                                length: 15,
                                 end: "24:00"
                              }}
                              />
                              {timeUnitsToChoose}
                   </select>
               </div>
               );
           }
        };

    return (
        <div className="question">
            <p>{props.question.question}</p>
            {errorMessage && <div>{errorMessage}</div>}
            {QuestionType()}
        </div>
    );
}

Solution

  • setWorkdayAnswer is missing as a useEffect hook dependency since it's an external (to the hook callback) reference. workdays is indirectly a dependence since setWorkdayAnswer depends on it.

    You've a few options:

    1. Memoize setWorkdayAnswer on workdays, answerCallback, and question as dependencies via useCallback and use setWorkdayAnswer as the useEffect hook dependency

      const { answerCallback, question } = props;
      
      ...
      
      const setWorkdayAnswer = useCallback(() => {
        const workdayAnswer: WorkdayAnswer = {
          questionId: props.question.id,
          question: props.question.question,
          monday: workdays[0],
          tuesday: workdays[1],
          wednesday: workdays[2],
          thursday: workdays[3],
          friday: workdays[4],
          saturday: workdays[5],
          sunday: workdays[6],
        };
      
        postWorkdayAnswer(workdayAnswer)
          .then(() => props.answerCallback()) // refreshing site
          .catch(() => {
            console.error("Error posting workday answer:", Error);
            setErrorMessage("error posting answer");
          });
      }, [answerCallback, question, workdays]);
      
      useEffect(() => {
        setWorkdayAnswer();
      }, [setWorkdayAnswer]);
      
    2. Move setWorkdayAnswer into the useEffect callback body to eliminate it as an external dependency.

      const { answerCallback, question } = props;
      
      ...
      
      useEffect(() => {
        const setWorkdayAnswer = () => {
          const workdayAnswer: WorkdayAnswer = {
            questionId: props.question.id,
            question: props.question.question,
            monday: workdays[0],
            tuesday: workdays[1],
            wednesday: workdays[2],
            thursday: workdays[3],
            friday: workdays[4],
            saturday: workdays[5],
            sunday: workdays[6],
          };
      
          postWorkdayAnswer(workdayAnswer)
            .then(() => props.answerCallback()) // refreshing site
            .catch(() => {
              console.error("Error posting workday answer:", Error);
              setErrorMessage("error posting answer");
            });
        };
      
        setWorkdayAnswer();
      }, [answerCallback, question, workdays]);
      
    3. Just apply the fetching logic directly.

      const { answerCallback, question } = props;
      
      ...
      
      useEffect(() => {
        const workdayAnswer: WorkdayAnswer = {
          questionId: props.question.id,
          question: props.question.question,
          monday: workdays[0],
          tuesday: workdays[1],
          wednesday: workdays[2],
          thursday: workdays[3],
          friday: workdays[4],
          saturday: workdays[5],
          sunday: workdays[6],
        };
      
        postWorkdayAnswer(workdayAnswer)
          .then(() => props.answerCallback()) // refreshing site
          .catch(() => {
            console.error("Error posting workday answer:", Error);
            setErrorMessage("error posting answer");
          });
        };
      }, [answerCallback, question, workdays]);
      

    Note that if answerCallback is passed down as a prop that it should also be memoized and passed as a stable reference. In the parent you would also use the useCallback hook for this.

    Example:

    const answerCallback = useCallback(() => {
      // ... callback logic
    }, [/* appropriate dependencies */]);
    
    ...
    
    return (
      ...
      <QuestionListComponent
        answerCallback={answerCallback}
        ...
      />
      ...
    );