React - reduce strange interaction on input setState

Viewed 36

I have a quiz and it has an arr of questions, a question has value.

I want to assign the total of questions question.value to the quiz.value as the value updates.

Here is how i am currently handling the updating (obviously i havent dealt with if a question is removed yet)

const handleUpdateQuestionScore = (e) => {
    setQuiz({...quiz, questions: quiz.questions.map((child, index) => index === selectedQuestion ? { ...child, value: parseInt(e.target.value) } : child)})
    setReUpdateTotal(true);
  }

  useEffect(() => { // this has weird behaviour
    console.log("my total", quiz.questions.reduce((total, question) => total = total + question.value, 0))
    if(quiz.questions.length < 1) {
      setQuiz({ ...quiz, value: 0  } )
    }
    else{
      setQuiz({...quiz, value: quiz.questions.reduce((total, question) => total = total + question.value, 0) } )
    }
  },[reUpdateTotal])

The issue with this is, when i set question 1, its correct. When i add question 2 and set its value, sometimes it only interpolates the first number. going back off the form and loading it up will update.

Other times it will not update the value at all.

For a much more indept demonstration, this codeSandbox demonstrates functionality: https://codesandbox.io/s/stupefied-shadow-i9z2wx?file=/src/App.js

Here is the default code in the codeSandbox

import { useEffect, useState } from "react";

export default function App() {
  const [quiz, setQuiz] = useState({
    title: "",
    number: 6,
    questions: [
      {
        question: "q1",
        value: 0
      },
      {
        question: "q2",
        value: 0
      }
    ]
  });
  const [selectedQuestion, setSelectedQuestion] = useState(undefined);

  useEffect(() => {
    console.log("quiz", quiz);
  }, [quiz]);

  const [reUpdateTotal, setReUpdateTotal] = useState();

  const handleUpdateQuestionScore = (e) => {
    setQuiz({
      ...quiz,
      questions: quiz.questions.map((child, index) =>
        index === selectedQuestion
          ? { ...child, value: parseInt(e.target.value) }
          : child
      )
    });
    setReUpdateTotal(true);
  };

  useEffect(() => {
    // this has weird behaviour
    console.log(
      "my total",
      quiz.questions.reduce(
        (total, question) => (total = total + question.value),
        0
      )
    );
    if (quiz.questions.length < 1) {
      setQuiz({ ...quiz, value: 0 });
    } else {
      setQuiz({
        ...quiz,
        value: quiz.questions.reduce(
          (total, question) => (total = total + question.value),
          0
        )
      });
    }
  }, [reUpdateTotal]);

  return (
    <div className="App">
      <div>
        {quiz.questions.map((question, index) => (
          <div key={index}>
            <p>
              {question.question} - {question.value}
              <button onClick={() => setSelectedQuestion(index)}>select</button>
            </p>

            <br />
          </div>
        ))}
      </div>

      <br />
      <small>change value for question #{selectedQuestion}</small>
      {selectedQuestion !== undefined ? (
        <div>
          <input
            type="number"
            id="Value"
            name="Value"
            required="required"
            className="edit-input shadow"
            min="0"
            value={quiz.questions[selectedQuestion].value}
            onChange={(e) => {
              handleUpdateQuestionScore(e);
            }}
          />
          <button onClick={() => setSelectedQuestion(undefined)}>remove</button>
        </div>
      ) : (
        <div>nothing selected</div>
      )}

      <h1>current total score = {quiz.value}</h1>
    </div>
  );
}


1 Answers

Technically - your useEffect that has a [reUpdateTotal] as a dependency array executes only once. When switching value from false to true. Im not seeing where you are switching it back. But still, you dont even need this variable and rely on the useEffect with [quiz] which will execute only 1 function - setQuiz. And no, it will not cause infinite rerenderings, if used correctly. It can accept a function with 1 parameter, which will be your "current" value of a quiz. I used prev but actually it is curr. And this function can return either a new value and fire a rerender, either the current value and do nothing in this case.

import "./styles.css";
import { useEffect, useState } from "react";

export default function App() {
  const [quiz, setQuiz] = useState({
    title: "",
    number: 6,
    questions: [
      { question: "q1", value: 0 },
      { question: "q2", value: 0 }
    ]
  });
  const [selectedQuestionIndex, setSelectedQuestionIndex] = useState(undefined);

  const handleUpdateQuestionScore = (e) => {
    setQuiz((prev) => {
      prev.questions[selectedQuestionIndex].value = parseInt(
        e.target.value,
        10
      );
      return { ...prev };
    });
  };

  useEffect(() => {
    setQuiz((prev) => {
      const currValue = prev.value;
      const newValue =
        prev.questions?.reduce(
          (total, question) => (total = total + question.value),
          0
        ) || 0;
      if (newValue === currValue) return prev;
      return {
        ...prev,
        value: newValue
      };
    });
  }, [quiz]);

  return (
    <div className="App">
      <div>
        {quiz.questions?.map((question, index) => (
          <div key={index}>
            <p>
              {question.question} - {question.value}
              <button onClick={() => setSelectedQuestionIndex(index)}>
                select
              </button>
            </p>

            <br />
          </div>
        ))}
      </div>

      <br />
      <small>change value for question #{selectedQuestionIndex}</small>
      {selectedQuestionIndex !== undefined ? (
        <div>
          <input
            type="number"
            id="Value"
            name="Value"
            required="required"
            className="edit-input shadow"
            min="0"
            value={quiz.questions[selectedQuestionIndex].value}
            onChange={handleUpdateQuestionScore}
          />
          <button onClick={() => setSelectedQuestionIndex(undefined)}>
            remove
          </button>
        </div>
      ) : (
        <div>nothing selected</div>
      )}

      <h1>current total score = {quiz.value}</h1>
    </div>
  );
}

Here is a bit more clear way of handling "quiz.value" using useMemo:

import "./styles.css";
import { useMemo, useState } from "react";

export default function App() {
  const [quiz, setQuiz] = useState({
    title: "",
    number: 6,
    questions: [
      { question: "q1", value: 0 },
      { question: "q2", value: 0 }
    ]
  });
  const [selectedQuestionIndex, setSelectedQuestionIndex] = useState(undefined);
  const selectedQuestion = useMemo(() => {
    if (!quiz?.questions || selectedQuestionIndex === undefined)
      return undefined;
    return quiz.questions[selectedQuestionIndex];
  }, [quiz, selectedQuestionIndex]);

  const quizTotalValue = useMemo(() => {
    if (!quiz || !quiz.questions) return 0;
    return quiz.questions.reduce((acc, curr) => acc + curr.value, 0);
  }, [quiz]);

  const handleUpdateQuestionScore = (e) => {
    if (!selectedQuestion) return;
    selectedQuestion.value = parseInt(e.target.value, 10);
    setQuiz((prev) => ({ ...prev }));
  };

  return (
    <div className="App">
      <div>
        {quiz.questions?.map((question, index) => (
          <div key={index}>
            <p>
              {question.question} - {question.value}
              <button onClick={() => setSelectedQuestionIndex(index)}>
                select
              </button>
            </p>

            <br />
          </div>
        ))}
      </div>

      <br />
      <small>change value for question #{selectedQuestionIndex}</small>
      {selectedQuestion !== undefined ? (
        <div>
          <input
            type="number"
            id="Value"
            name="Value"
            required="required"
            className="edit-input shadow"
            min="0"
            value={selectedQuestion.value}
            onChange={handleUpdateQuestionScore}
          />
          <button onClick={() => setSelectedQuestionIndex(undefined)}>
            remove
          </button>
        </div>
      ) : (
        <div>nothing selected</div>
      )}

      <h1>current total score = {quizTotalValue}</h1>
    </div>
  );
}
Related