Hazzaldo
Hazzaldo

Reputation: 563

React: setting a state Hook causes error - Too many re-renders. React limits the number of renders to prevent an infinite loop

In React, I have created a Component to allow the user to submit a book to add to a list of books. The site visually looks like this (the AddBook component is framed in red):

enter image description here

I will share my full code further below, but first I just need to explain the problem. In the AddBook component, the "select author" dropdown menu (that you see in the screenshot above) is using <select> <option> elements, which allows the user to select from a list of authors, before submitting the form to add a book. The list of authors are fetched from a GraphQL API call.

snippet code:


function displayAuthors() {
        if (loading) return (<option disabled>'Loading authors...'</option>);
        if (error) return (`Error: ${error.message}`);
        return data.authors.map((author) => {
            return (
                <option key={author.id} value={author.id}>{author.name}</option>
            );
        });
    }

return (
        <form id="add-book" onSubmit={submitBook}>
            <h1>Add a new book to Wiki Books</h1>
            <div className="field">
                <label>Book name: </label>
                <input type="text" onChange={handleChange} name="name" required value={bookEntry.name}/>
            </div>

            <div className="field">
                <label>Genre: </label>
                <input type="text" onChange={handleChange} name="genre" required value={bookEntry.genre}/>
            </div>

            <div className="field">
                <label>Author (select author): </label>
                <select onChange={handleChange} name="authorId">
                    {displayAuthors()}
                </select>
            </div>

            <button type="submit">+</button>
        </form>

The roadblock I've hit is ensuring the dropdown menu <select> element is REQUIRED, so it ensures selecting an author is required before submitting the book info. Now, after searching through forums I've concluded that React doesn't seem to have a native solution implemented for <select> REQUIRED validation. Instead there are convoluted hack solutions as workarounds.

To mitigate the issue, I simply removed the first <option> Select author </option> (you can't see it in the code shared because I've already removed it). So that leaves the dropdown menu, by default, set on the first author in the list, on the initial render of the page. Thereby forcing the user to have a choice of author selected by default. Of course they can always change the option to choose a different author. But the point is an author choice is already enforced by default.

Now the next issue I faced with this approach is - on initial render of the page, even though an author is already selected in the dropdown list by default, the corresponding <option> element for that author, its value of author.id doesn't get detected by React on initial render of the Component (this authorId value from the option element is needed for the book submission, calling an API to submit the book info to the database). You would have to change the menu option first for the onChange attribute event listener in <select> element to detect the value of the selected <option> (the authorId). Which means my solution for ensuring an author is always selected (even on initial page render) is now pointless as React doesn't pick up the authorID value of the initial selected author.

<option key={author.id} value={author.id}>{author.name}</option> // value={author.id} doesn't get detected in initial render of page, unless menu option is changed for `onChange` to detect value={authorId}

.

To solve this, my solution is to create a state that would be set to the first author from the list of authors fetched from the API call. So that should be Patrick Rothfuss - the default selected author in the dropdown menu. So the authorId of Patrick Rothfuss is set to this state. Then this state can be used in the book submission, if the user doesn't changed the dropdown menu at all when submitting the form.

const [firstAuthorId, setFirstAuthorId] = useState("");

function displayAuthors() {
        if (loading) return (<option disabled>'Loading authors...'</option>);
        if (error) return (`Error: ${error.message}`);
        return data.authors.map((author, index) => {
            if (index === 0) {
                setFirstAuthorId(author.id);
            }
            return (
                <option key={author.id} value={author.id}>{author.name}</option>
            );
        });
    }

Now the issue I'm facing here is when I set the state to the first author Id, I get the error below:

enter image description here

In fact, from troubleshooting the issue, the cause of the issue seems to be with that particular state setter setFirstAuthorId. No matter what I set it to, and from where I set it in the Component, it throws the error shown in the screenshot.

setFirstAuthorId("anything") // will throw an error

So as a workaround (which is not ideal), I created a conditional (ternary expression) that would check, upon the book submission (where the bookEntry object state is submitted to the API), if there is no authorId, then set the authorId property of bookEntry state to a hardcoded authorId of the first author (Patrick Rothfuss). Ideally the state firstAuthorId should be set to this instead of a hardcoded ID.

function submitBook(event) {
        const filledAuthorId = bookEntry.authorId? bookEntry.authorId : "5ed44e015ecb7c42a0bf824d";
        addBook({ variables: { 
            name: bookEntry.name,
            genre: bookEntry.genre,
            authorId: filledAuthorId
            }, 
            refetchQueries: [{ query: GET_ALL_BOOKS_QUERY }]
        })

Full code:

import React, { useState } from 'react';
import { useQuery, useMutation } from '@apollo/react-hooks';
import { GET_ALL_AUTHORS_QUERY, ADD_BOOK_MUTATION, GET_ALL_BOOKS_QUERY } from '../queries/queries';

function AddBook() {
    const { loading, error, data } = useQuery(GET_ALL_AUTHORS_QUERY);
    // to call more than one query using the useQuery, call useQuery hook but give alias names to loading, error, data to avoid queries overwriting each other's returned fields
    // const { loading: loadingAddBook, error: errorAddBook, data: dataAddBook} = useQuery(A_SECOND_QUERY)
    const [ addBook ] = useMutation(ADD_BOOK_MUTATION);

    const [bookEntry, setBookEntry] = useState({
        name: "",
        genre: "",
        authorId: ""
    });

    const [firstAuthorId, setFirstAuthorId] = useState("");

    function handleChange(event) {
        const { name, value } = event.target;
        console.log(`handleChange event: ${event.target.value}`);

        setBookEntry(preValue => {
            return {
                ...preValue,
                [name]: value
            }
        });
    }

    function submitBook(event) {
        const filledAuthorId = bookEntry.authorId? bookEntry.authorId : "5ed44e015ecb7c42a0bf824d";
        addBook({ variables: { 
            name: bookEntry.name,
            genre: bookEntry.genre,
            authorId: filledAuthorId
            }, 
            refetchQueries: [{ query: GET_ALL_BOOKS_QUERY }]
        })
        .then(data => {
            console.log(`Mutation executed: ${JSON.stringify(data)}`);
            setBookEntry(prevValue => ({
                ...prevValue,
                name: "",
                genre: ""
            }));
        })
        .catch(err => console.log(err));
        event.preventDefault();
    } 

    function displayAuthors() {
        if (loading) return (<option disabled>'Loading authors...'</option>);
        if (error) return (`Error: ${error.message}`);
        return data.authors.map((author, index) => {
            if (index === 0) {
                setFirstAuthorId(author.id);
            }
            return (
                <option key={author.id} value={author.id}>{author.name}</option>
            );
        });
    }

    return (
        <form id="add-book" onSubmit={submitBook}>
            <h1>Add a new book to Wiki Books</h1>
            <div className="field">
                <label>Book name: </label>
                <input type="text" onChange={handleChange} name="name" required value={bookEntry.name}/>
            </div>

            <div className="field">
                <label>Genre: </label>
                <input type="text" onChange={handleChange} name="genre" required value={bookEntry.genre}/>
            </div>

            <div className="field">
                <label>Author (select author): </label>
                <select onChange={handleChange} name="authorId">
                    {displayAuthors()}
                </select>
            </div>

            <button type="submit">+</button>
        </form>
    );
}

export default AddBook;

Upvotes: 0

Views: 617

Answers (1)

wentjun
wentjun

Reputation: 42516

Given that you only need to update the firstAuthorId state with the first index of the data.authors array that is returned from the query, you should do it when the component is mounted, rather than calling the displayAuthors method and updating the state everytime to component re-renders.

This can be achieved using the useEffect hook.

useEffect(() => {
  setFirstAuthorId(data.authors[0].id)
}, []);

Alternatively, you can set data.authors as part of the dependency array, such that the firstAuthorId state will be updated every time data.authors has changed.

useEffect(() => {
  setFirstAuthorId(data.authors[0].id)
}, [data.authors]);

Better still, there is actually no need to maintain a separate state(firstAuthorId) to track the first object of data.authors. You can simply reference to it on whereever you need in the code itself.

Upvotes: 2

Related Questions