Search code examples
javascriptreactjssetstatespread-syntax

Nothing shows up after setState filling by components


Client: React, mobx Server: NodeJS, MongoDB

Short question:

I have an array of elements which fills inside of useEffect function, expected result: each element of array should be rendered, actual result: nothing happens. Render appears only after code changing in VSCode.

Tried: changing .map to .forEach, different variations of spread operator in setState(...[arr]) or even without spread operator, nothing changes.

Info:

Friends.jsx part, contains array state and everything that connected with it, also the fill-up function.

  const [requestsFrom, setRequestsFrom] = useState([]) //contains id's (strings) of users that will be found in MongoDB
  const [displayRequestsFrom, setDisplayRequestsFrom] = useState([]) //should be filled by elements according to requestsFrom, see below

  const getUsersToDisplayInFriendRequestsFrom = () => {
    const _arr = [...displayRequestsFrom]
    requestsFrom.map(async(f) => {
      if (requestsFrom.length === 0) {
        console.log(`empty`) //this part of code never executes
        return
      } else {
        const _candidate = await userPage.fetchUserDataLite(f)
        _arr.push( //template to render UserModels (below)
          {
            isRequest: true,
            link: '#',
            username: _candidate.login,
            userId: _candidate._id
          }
        )
        console.log(_arr)
      }
    })
    setDisplayRequestsFrom(_arr)
    // console.log(`displayRequestsFrom:`)
    console.log(displayRequestsFrom) //at first 0, turns into 3 in the second moment (whole component renders twice, yes)
  }

Render template function:

  const render = {
    requests: () => {
      return (
        displayRequestsFrom.map((friendCandidate) => {
          return (
            <FriendModel link={friendCandidate.link} username={friendCandidate.username} userId={friendCandidate.userId}/>
          )
        })
      )
    }
  }

useEffect:

  useEffect(() => {
    console.log(`requestsFrom.length === ${requestsFrom.length}`)
    if (!requestsFrom.length === 0) {
      return 
    } else if (requestsFrom.length === 0) {
      setRequestsFrom(toJS(friend.requests.from))
      if (toJS(friend.requests.from).length === 0) {
        const _arr = [...requestsFrom]
        _arr.push('0')
        setRequestsFrom(_arr)
      }
    }
      if (displayRequestsFrom.length < 1 && requestsFrom.length > 0) {
         getUsersToDisplayInFriendRequestsFrom()
         //displayRequestsFrom and requestsFrom lengths should be same
      }
    
  },
   [requestsFrom]
  )

Part of jsx with rendering:

    <div className={styles.Friends}>
      <div className={styles['friends-container']}>
           {render.requests()}
      </div>
    </div>

UPD: my console.log outputs in the right order from beginning:

requestsFrom.length === 0
requestsFrom.length === 3
displayRequestsFrom === 0
displayRequestsFrom === 3 

As we can see, nor requestsFrom, neither displayRequestsFrom are empty at the end of the component mounting and rendering, the only problem left I can't find out - why even with 3 templates in displayRequestsFrom component doesn't render them, but render if I press forceUpdate button (created it for debug purposes, here it is:)

  const [ignored, forceUpdate] = React.useReducer(x => x + 1, 0);

  <button onClick={forceUpdate}>force update</button>

Solution

  • PRICIPAL ANSWER

    The problem here is that you are executing fetch inside .map method. This way, you are not waiting for the fetch to finish (see comments)

    Wrong Example (with clarification comments)

      const getUsersToDisplayInFriendRequestsFrom =  () => {
        const _arr = [...displayRequestsFrom];
        // we are not awating requestsFrom.map() (and we can't as in this example, cause .map is not async and don't return a Promise)
        requestsFrom.map(async (f) => { 
            const _candidate = await userPage.fetchUserDataLite(f)
            // This is called after setting the state in the final line :( 
            _arr.push( 
              {
                isRequest: true,
                link: '#',
                username: _candidate.login,
                userId: _candidate._id
              }
            )
        } )
        setDisplayRequestsFrom(_arr) // This line is called before the first fetch resolves.  
       // The _arr var is still empty at the time of execution of the setter
     }
    

    To solve, you need to await for each fetch before updating the state with the new array.

    To do this, your entire function has to be async and you need to await inside a for loop.

    For example this code became

      const getUsersToDisplayInFriendRequestsFrom =  async () => {  // Note the async keyword here
         const _arr = [...displayRequestsFrom]
         for (let f of requestsFrom) {
           const _candidate = await fetchUserData(f)
           _arr.push(
             {
               isRequest: true,
               link: '#',
               username: _candidate.login,
               userId: _candidate._id
             }
           )
         }
         setDisplayRequestsFrom(_arr)
     }
    

    You can also execute every fetch in parallel like this

      const getUsersToDisplayInFriendRequestsFrom =  async () => {  // Note the async keyword here
         const _arr = [...displayRequestsFrom]
         await Promise.all(requestsFrom.map((f) => { 
           return fetchUserData(f).then(_candidate => {
              _arr.push(
                {
                  isRequest: true,
                  link: '#',
                  username: _candidate.login,
                  userId: _candidate._id
                }
              )
          });
         }));
         setDisplayRequestsFrom(_arr);
     }
    

    Other problems

    Never Calling the Service

    Seems you are mapping on an empty array where you are trying to call your service.

    const getUsersToDisplayInFriendRequestsFrom = () => {
    const _arr = [...displayRequestsFrom]
    /* HERE */ requestsFrom.map(async(f) => {
      if (requestsFrom.length === 0) {
        return
    

    If the array (requestsFrom) is empty ( as you initialized in the useState([]) ) the function you pass in the map method is never called.

    Not sure what you are exactly trying to do, but this should be one of the problems...


    Don't use state for rendered components

    Also, you shoudn't use state to store rendered components

     _arr.push(
              <FriendModel key={_candidate.id} isRequest={true} link='#' username={_candidate.login} userId={_candidate._id}/>
            )
    

    , instead you should map the data in the template and then render a component for each element in your data-array.

    For example:

    function MyComponent() {
       const [myData, setMyData] = useState([{name: 'a'}, {name: 'b'}])
    
       return (<>
            {
             myData.map(obj => <Friend friend={obj} />)
             }
       </>)
    }
    

    Not:

    function MyComponent() {
       const [myDataDisplay, setMyDataDisplay] = useState([
             <Friend friend={{name: 'a'}} />, 
             <Friend friend={{name: 'b'}} />
       ])
    
       return <>{myDataDisplay}</>
    }
    

    Don't use useEffect to initialize your state

    I'm wondering why you are setting the requestsFrom value inside the useEffect.

    Why aren't you initializing the state of your requestsFrom inside the useState()?

    Something like

    const [requestsFrom, setRequestsFrom] = useState(toJS(friend.requests.from))
    

    instead of checking the length inside the useEffect and fill it

    So that your useEffect can became something like this

    useEffect(() => {
      if (displayRequestsFrom.length < 1 && requestsFrom.length > 0) {
         getUsersToDisplayInFriendRequestsFrom()
      }
    },
     [requestsFrom]
    )