Why setting the state through setTimeout doesn't render the correct props of a child component?

Viewed 47

I have right here a component that should simply render a list of items. Also, the component includes an input that filters the list of items. If there is no items, or if the items are being loaded it should display a message.

import { useState } from "react";

export const List = ({ loading, options }) => {
  const _options = options ?? [];
  const [renderedOptions, setRenderedOptions] = useState(_options);
  const [inputValue, setInputValue] = useState("");

  function handleChange(event) {
    setInputValue(event.target.value);
    const filteredOptions = _options.filter((option) =>
      option.toLowerCase().includes(event.target.value.toLowerCase())
    );
    setRenderedOptions(filteredOptions);
  }

  return (
    <div>
      <input type="text" value={inputValue} onChange={handleChange} />
      <ul>
        {renderedOptions.length > 0 ? (
          renderedOptions.map((option) => <li key={option}>{option}</li>)
        ) : loading ? (
          <li>Loading...</li>
        ) : (
          <li>Nothing to show</li>
        )}
      </ul>
    </div>
  );
};

In App.js, I did a setTimeout, to mock a fetch call. However, there is a problem. Although I'm setting the asyncOptions state to be the new list of items, in my <List /> component the options do not seem to display properly.

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

const ITEMS = ["list_1", "list_2", "list_3", "list_4", "list_5"];

export default function App() {
  const [asyncOptions, setAsyncOptions] = useState([]);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    setIsLoading(true);
    const timeoutId = setTimeout(() => {
      setIsLoading(false);
      setAsyncOptions(ITEMS);
    }, 2000);

    return () => clearTimeout(timeoutId);
  }, []);
  return <List options={asyncOptions} loading={isLoading} />;
}

What is this happening and what is/are the solution(s)?

Sandbox code here: https://codesandbox.io/s/async-list-j97u32

2 Answers

The first time when list component gets rendered, renderedOptions is initialized with []

const [renderedOptions, setRenderedOptions] = useState(options);

But when the state inside the App component changes, it triggers a re-render and henceforth it triggers re-render of List Component. So since you are passing options as argument to useState u might feel it'll update the state automatically but that's not the case

Note -> useState doesn't take into consideration whatever you are passing as argument except for the first time the component loads .So the useState will return back the initial State which is [] every time the component re-renders

So if you want to see changes you have to add useEffect inside the List component and trigger a state update every time options changes

Change your code too this,

import { useState } from "react";

export const List = ({ options, loading }) => {
  console.log("Listt", options);
  const [renderedOptions, setRenderedOptions] = useState([...options]);
  const [inputValue, setInputValue] = useState("");

  console.log(renderedOptions);

  function handleChange(event) {
    setInputValue(event.target.value);
    const filteredOptions = options.filter((option) =>
      option.toLowerCase().includes(event.target.value.toLowerCase())
    );
    setRenderedOptions(filteredOptions);
  }

  useEffect(() => {
      setRenderedOptions(options)
  } , [options])

  return (
    <div>
      <input type="text" value={inputValue} onChange={handleChange} />
      <ul>
        {renderedOptions.length > 0 ? (
          renderedOptions.map((option) => <li key={option}>{option}</li>)
        ) : loading ? (
          <li>Loading...</li>
        ) : (
          <li>Nothing to show</li>
        )}
      </ul>
    </div>
  );
};

Basically, in the beginning, the value of options in an empty array, and the value put in state is a copy of that so the component is not listening to changes on the prop.

For some reason, you have to use the useEffect hook to actively listen to changes in the prop. By using the hook, when the API call returns something, it will set the state.(BTW, if anyone knows what is going on tell us)

I would recommend moving the API call to the List component, it would better encapsulate the logic

import { useEffect, useState } from "react";

export const List = ({ loading, options }) => {
  const [renderedOptions, setRenderedOptions] = useState(options);
  const [inputValue, setInputValue] = useState("");

  useEffect(() => {
    setRenderedOptions(options);
  }, [options]);

  function handleChange(event) {
    setInputValue(event.target.value);
    const filteredOptions = options.filter((option) =>
      option.toLowerCase().includes(event.target.value.toLowerCase())
    );
    setRenderedOptions(filteredOptions);
  }

  return (
    <div>
      <input type="text" value={inputValue} onChange={handleChange} />
      <ul>
        {renderedOptions.length > 0 ? (
          renderedOptions.map((option) => <li key={option}>{option}</li>)
        ) : loading ? (
          <li>Loading...</li>
        ) : (
          <li>Nothing to show</li>
        )}
      </ul>
    </div>
  );
};

Related