Search code examples
reactjsnode.jstypescriptreact-hooksdrop-down-menu

Dropdown Select Component Has Data One Step Behind


I have a dropdown menu in my app. When it is selected it triggers a method that changes the sortOption state, which then triggers a useEffect to sort the data (called allTabs). However, the data always seems to be a step behind. For example, the default is "new to old", but it initially displays the data unsorted. If a new option is then pressed, say "A-Z", the data then becomes sorted by "new to old". If a new option is then pressed again, say "old to new", the data then becomes sorted by "A-Z" and etc. I am unsure of why this is happening. I have seen posts about using callbacks but I'm pretty new to React, Node.js, and Typescript and I'm unsure how to integrate those answers into my code.

Here is what the dropdown looks like:

{/* Dropdown for sorting */}
<div className="mb-4">
  <label htmlFor="sortOptions" className="mr-2">Sort by:</label>
  <select
    style={{ color: 'black' }}
    id="sortOptions"
    value={sortOption}
    onChange={handleSortChange}
    className="p-2 border rounded"
  >
    <option value="Newest to Oldest">Newest to Oldest</option>
    <option value="Oldest to Newest">Oldest to Newest</option>
    <option value="A-Z">A-Z</option>
    <option value="Z-A">Z-A</option>
  </select>
</div>

Here is what the handleSortChange callback is:

// Handle dropdown change with type annotation
const handleSortChange = (event: ChangeEvent<HTMLSelectElement>) => {
  setSortOption(event.target.value);
};

Here is the useState of sortOption, and the useEffect that is triggered by a change in sortOption or allTabs (the data I am sorting):

const [sortOption, setSortOption] = useState<string>("Newest to Oldest");
// sort tabs based on selected option, update if allTabs or sortOption changes
useEffect(() => {
  console.log(allTabs);
  console.log(sortOption);
  switch (sortOption) {
    case "Newest to Oldest":
      allTabs.sort(compareDates);
      console.log("sorted new to old");
      break;
    case "Oldest to Newest":
      allTabs.sort((a, b) => compareDates(b, a));
      console.log("sorted old to new");
      break;
    case "A-Z":
      allTabs.sort((a, b) => a.name.localeCompare(b.name));
      console.log("sorted A-Z");
      break;
    case "Z-A":
      allTabs.sort((a, b) => b.name.localeCompare(a.name));
      console.log("sorted Z-A");
      break;
    default:
      return;
  }
  console.log(allTabs);
}, [sortOption, allTabs])

I know that the methods within this useEffect work properly, and so do the sorts.

As you can see, there are console logs in the useEffect method. They print out the data and the path taken. They have given me an interesting result. When I print allTabs before and after the switch, they are both sorted according to the proper path that is printed out, meaning everything looks correct. The option selected by the user is indeed what is printed, and how the data becomes sorted. But the data that is actually displayed on the website is still of the data from the last selection. I therefore assume that the render of the data is not updating when allTabs is updated, which I need to be able to do. If I try to actually use the setter of allTabs in the useEffect, it just causes an infinite loop, so I'm unsure how I could get react to realize the data has been updated. A force reload of the page just resets everything including the data and sort option.

To display the data I am currently doing a map of allTabs. Should I instead do this in a component and make it take in some data so that I can call this component with the data whenever I change it or something?

This is how the data is displayed. This occurs within a return at the bottom of the app, within a <main> with some other stuff:

{allTabs.map((allTab, index) => (
  <React.Fragment key={index}>
    <Link
      className=""
      href="/"
      onClick={() => {
        localStorage.setItem("tabTitle", allTab.name);
        localStorage.setItem("tabContent", allTab.tab);
      }}
    >
      {allTab.name}
    </Link>
    <span>
      {allTab.created_by + " on " + formatDate(allTab.created_at)}
    </span>
    <div className="ml-auto">
      {savedTabs.some(
        (savedTab) => savedTab.tabs.id === allTab.id
      ) ? (
        <Image
          src="/saved.png"
          alt="saved tab"
          className="dark:invert"
          width={24}
          height={24}
          onClick={() => {
            toggleSaved(userId, allTab);
          }}
        />
      ) : (
        <Image
          src="/not_saved.png"
          alt="unsaved tab"
          className="dark:invert"
          width={24}
          height={24}
          onClick={() => {
            toggleSaved(userId, allTab);
          }}
          priority
        />
      )}
    </div>
  </React.Fragment>
))}

Solution

  • It appears you are not aware that .sort applies an in-place mutation. If allTabs is some React state, then mutating the state doesn't trigger a component rerender. I suspect this is why you see the correct sorted array mutations and logs in the effect yet the component isn't triggered to rerender to display the new sorted array. The useEffect callback is also called at the end of the render cycle, so even if mutation was visible it's already too late, the render cycle has completed.

    Generally speaking, when sorting or filtering array data it's a common pattern to not filter/sort the source array, e.g. the state source of truth, but to instead compute the derived filtered/sorted value using the source array and filtering/sorting criteria applied against it.

    Because .sort applies an in-place sorting/mutation you will want to shallow copy the allTabs array first:

    • Use .slice to create a copy: allTabs.slice().sort(compareFn)
    • Use the Spread Syntax to copy into a new array reference: [...allTabs].sort(compareFn)
    • Use the newer .toSorted method if your system supports it: allTabs.toSorted(compareFn)

    Example:

    Curried function declared outside React component so it can be safely accessed as a stable reference.

    const compareFn = (sortOption: string) => (a: Tab, b: Tab) => {
      switch (sortOption) {
        case "Newest to Oldest":
        default:
          return compareDates(a, b);
    
        case "Oldest to Newest":
          return compareDates(b, a));
    
        case "A-Z":
          return a.name.localeCompare(b.name);
    
        case "Z-A":
          return b.name.localeCompare(a.name);
      }
    }
    

    In the component pass the current sortOption value to compareFn so the return comparator function is used by the .sort method.

    const [sortOption, setSortOption] = useState<string>("Newest to Oldest");
    const [allTabs, setAllTabs] = useState<Tab[]>([......]);
    
    const sortedAllTabs = allTabs.slice().sort(compareFn(sortOption));
    // const sortedAllTabs = [...allTabs].sort(compareFn(sortOption));
    // const sortedAllTabs = allTabs.toSorted(compareFn(sortOption));
    

    If you like you could additionally memoize the computed/derived value as a bit of a performance optimization. Using the useMemo hook only re-computes the value when any of the dependencies update rather than any time the component body is called when rendered.

    const sortedAllTabs = useMemo(() => {
      return allTabs.slice().sort(compareFn(sortOption));
      // return [...allTabs].sort(compareFn(sortOption));
      // return allTabs.toSorted(compareFn(sortOption));
    }, [allTabs, sortOption]);
    

    Use the computed/derived sortedAllTabs array to map to the rendered JSX. Note also that because you are mutating the array order that using the array index as a React key will work very poorly for you since the array indices don't "stick" to the data they ought to represent. You should use a value/property that is intrinsic to, and unique within, the data. Id properties or any property that is unique among the sibling elements is sufficient. In your code it appears that allTab.id might be suitable.

    {sortedAllTabs.map((allTab) => (
      <React.Fragment key={allTab.id}>
        <Link
          className=""
          href="/"
          onClick={() => {
            localStorage.setItem("tabTitle", allTab.name);
            localStorage.setItem("tabContent", allTab.tab);
          }}
        >
          {allTab.name}
        </Link>
        <span>
          {allTab.created_by + " on " + formatDate(allTab.created_at)}
        </span>
        <div className="ml-auto">
          {savedTabs.some(
            (savedTab) => savedTab.tabs.id === allTab.id
          ) ? (
            <Image
              src="/saved.png"
              alt="saved tab"
              className="dark:invert"
              width={24}
              height={24}
              onClick={() => {
                toggleSaved(userId, allTab);
              }}
            />
          ) : (
            <Image
              src="/not_saved.png"
              alt="unsaved tab"
              className="dark:invert"
              width={24}
              height={24}
              onClick={() => {
                toggleSaved(userId, allTab);
              }}
              priority
            />
          )}
        </div>
      </React.Fragment>
    ))}