Search code examples
javascriptreactjsrecoiljs

React: Dynamic number of stateful items, AKA dealing with "Rendered more hooks than during the previous render"


I'm trying to build a tree based forum similar to reddit and hackernews. I'm using react and recoil to manage state. I'm not sure if recoil is necessary for this particular problem as the state exists within a single page, but I'm open to using it in any potential solution. I would prefer to stick with these libraries unless there is a convincing reason to branch out.

I have a useEffect hook that fetches a thread's replies.

  useEffect(() => {
    async function fetchReplies() {
      if (!router.isReady) return;
        const res = await fetchWithTimeout(
          `${process.env.NEXT_PUBLIC_API_URL}/threads/${threadId}/replies`,
          {
            headers: {
              Authorization: `Bearer ${userToken}`,
            },
            timeout: 15000,
          }
        );
        //build replies object from response here
        setReplies(myRepliesObject);
    }
    fetchReplies();
  }, [router.isReady]);

These replies are then pumped into the thread body which loops over the data to create replies.

function ThreadBody(replies: any) {
  let replyItems = [];
  for (let i = 0; i < replies.length; i++) {
    replyItems.push(ThreadReply(replies[i], 0));
  }

Like most tree based forums, I want to give users the ability to open and close particular reply trees. To do so, I gave each reply a state of isOpen.

function ThreadReply(post, level) {
  const [isOpen, setIsOpen] = useState(true);
....

This worked great when I had a static json object to mock my replies.

Trouble is, once I linked my backend and have a dynamic number of replies, I get the error Rendered more hooks than during the previous render upon setting replies.

I've tried atomFamilies, but I can't seem to figure out how I would set one (to update isOpen) with a dynamic atomFamilyId without violating the same constraint of more renders.

It seems like it'd be possible for me to clone, edit, and replace the entire replies state, but that seems insanely expensive to set a single field in what could be a thousands long list. I haven't gone down that road because I know I'll end up shipping it due to "it works" inertia.

I'm self-taught when it comes to frontend and am surely missing some seminal knowledge. Any recommendations?


Solution

  • I figured it out, I need to use JSX components instead of calling the functions directly. I'd love it if someone could explain exactly why, though. I also changed my components to be arrow functions, but I'm pretty sure that's unnecessary.

    ThreadBody now creates ThreadReplies as so

    const ThreadBody = (replies: any) => {
      let replyItems = [];
      for (let i = 0; i < replies.length; i++) {
        replyItems.push(<ThreadReply post={replies[i]} level={0} />);
      }
    

    Thread Replies now look like this

    const ThreadReply = ({ post, level }) => {
      const [isOpen, setIsOpen] = useState(true);
    

    I spent nearly two days figuring this out, so don't feel bad if you get stuck for a few hours!