mattmar10
mattmar10

Reputation: 515

NextJS Proper way to update UI State in client component when fetching data with Server Actions

My objective is to build a page using nextjs 14 that is a stock scanner that loads some data from an external api using some default parameters. In addition, I want to provide the user some additional options to customize paramaters and re-run the scan and load the results (e.g, below).

Page example

The data is loading in fine and I can actually load in new data with a server action, but there is one problem. As it is, the line

setIsLoading(true in my reload function leads to the dreaded async/await error

Unhandled Runtime Error Error: async/await is not yet supported in Client Components, only Server Components. This error is often caused by accidentally adding 'use client' to a module that was originally written for the server.

Is this a reasonable approach to organizing this client/server component interaction with server actions. If so, what is the proper way to update the isLoading state so that I can provide some feedback to the user when the scan button is clicked?

here is my page.tsx

"use client";
import { BollingerBandsScreenerResult } from "../../screener-types";
import { ScreenerErrorMsg } from "@/app/services/ScreenerService";
import { Either, match } from "@/app/utils/BasicUtils";
import NavWrapper from "@/app/components/NavWrapper";
import TableSkeleton from "@/app/components/screener/TableSkeleton";
import BollingerBandsBreachHeader from "../breach/BollingerBandsBreachHeader";
import SimpleScreenerResultsTable from "@/app/components/screener/SimpleScreenerResultsTable";
import BollingerBandsBreachParameters from "./BollingerBreachParameters";
import { getBBBreachData } from "./actions";
import { useEffect, useState } from "react";

const BollingerBreachScreen = () => {
  const [isLoading, setIsLoading] = useState(true);
  const [data, setData] = useState<Either<
    ScreenerErrorMsg,
    BollingerBandsScreenerResult[]
  > | null>(null);

  useEffect(() => {
    const fetchData = async () => {
      const data = await getBBBreachData();

      setIsLoading(false);
      setData(data);
    };

    fetchData();
  }, []);

  const reloadData = async (
    minClosePrice: number,
    period: number,
    multiplier: number,
    lookback: number
  ) => {
    setIsLoading(true); //this is the problem line
    const reload = await getBBBreachData(
      minClosePrice,
      period,
      multiplier,
      lookback
    );
    setData(reload);
  };

  if (!data || isLoading) {
    return (
      <NavWrapper>
        <div className="h-full w-5/6 mx-auto text-black">
          <BollingerBandsBreachHeader />
          <TableSkeleton />
        </div>
      </NavWrapper>
    );
  }

  return match(
    data,
    (errorString) => {
      return (
        <NavWrapper>
          <div className="h-full w-5/6 mx-auto text-black">
            <BollingerBandsBreachHeader />
            <BollingerBandsBreachParameters updateFn={reloadData} />
            <div>{errorString}</div>
          </div>
        </NavWrapper>
      );
    },
    (bbResults) => {
      return (
        <NavWrapper>
          <div className="h-full w-5/6 mx-auto text-black">
            <BollingerBandsBreachHeader />

            <BollingerBandsBreachParameters updateFn={reloadData} />
            <SimpleScreenerResultsTable results={bbResults} />
          </div>
        </NavWrapper>
      );
    }
  );
};

export default BollingerBreachScreen;

Here is my component for user interaction

"use client";
import { Roboto } from "next/font/google";
import { useState } from "react";

const roboto = Roboto({
  subsets: ["latin"],
  weight: ["100", "300", "400", "500", "700"],
});

interface BollingerBandsBreachParametersProps {
  updateFn: (
    minClosePrice: number,
    period: number,
    multiplier: number,
    lookback: number
  ) => void;
}

const BollingerBandsBreachParameters: React.FC<
  BollingerBandsBreachParametersProps
> = ({ updateFn }) => {
  const [minClosePrice, setMinClosePrice] = useState<number>(10);
  const [period, setPeriod] = useState<number>(20);
  const [multiplier, setMultiplier] = useState<number>(2);
  const [lookback, setLookback] = useState<number>(1);

  const handlePeriodSet = (event: React.ChangeEvent<HTMLInputElement>) => {
    const minValue = parseInt(event.target.value);
    setPeriod(isNaN(minValue) ? 0 : minValue);
  };

  const handleMultiplierSet = (event: React.ChangeEvent<HTMLInputElement>) => {
    const minValue = parseFloat(event.target.value);
    setMultiplier(isNaN(minValue) ? 0 : minValue);
  };

  const handleLookbackSet = (event: React.ChangeEvent<HTMLInputElement>) => {
    const minValue = parseInt(event.target.value);
    setLookback(isNaN(minValue) ? 0 : minValue);
  };

  const handleMinCloseSet = (event: React.ChangeEvent<HTMLInputElement>) => {
    const minValue = parseInt(event.target.value);
    setMinClosePrice(isNaN(minValue) ? 0 : minValue);
  };

  const handleClick = async () => {
    updateFn(minClosePrice, period, multiplier, lookback);
  };

  return (
    <>
      <div className="font-semibold text-lg mb-2">Scan Criteria</div>
      <div className="flex items-center space-x-2 mt-1 mb-4 ">
        <div>Period</div>
        <input
          type="number"
          className="w-16 p-2 border border-gray-300 text-xs"
          placeholder="Min"
          value={period}
          id="period"
          name="period"
          required
          onChange={handlePeriodSet}
        />
        <div>Multiplier</div>
        <input
          type="number"
          className="w-16 p-2 border border-gray-300 text-xs"
          placeholder="2"
          id="multiplier"
          name="multiplier"
          required
          value={multiplier}
          onChange={handleMultiplierSet}
        />
        <div>Lookback</div>
        <input
          type="number"
          className="w-16 p-2 border border-gray-300 text-xs"
          placeholder="2"
          id="lookback"
          name="lookback"
          required
          value={lookback}
          onChange={handleLookbackSet}
        />
        <div>Minimum Close Price</div>
        <input
          type="number"
          className="w-16 p-2 border border-gray-300 text-xs"
          placeholder="10"
          id="minClosePrice"
          name="minClosePrice"
          required
          value={minClosePrice}
          onChange={handleMinCloseSet}
        />

        <button
          className="bg-transparent hover:bg-blue-500 text-blue-700 font-semibold hover:text-white py-1 px-3 border border-blue-500 hover:border-transparent rounded"
          onClick={handleClick}
        >
          Scan
        </button>
      </div>
    </>
  );
};

export default BollingerBandsBreachParameters;

Here is my server action for loading data in actions.ts

export async function getBBBreachData(
  prevState: any,
  minClosePrice: number = 10,
  period: number = 20,
  multiplier: number = 2,
  lookback: number = 1
): Promise<Either<ScreenerErrorMsg, BollingerBandsScreenerResult[]>> {
  try {
    const url = `${BOLLINGER_BASE}/lower-breach?minClosePrice=${minClosePrice}&period=${period}&multiplier=${multiplier}&lookback=${lookback}`;

    const response = await fetch(url, {
      next: { revalidate: 0 },
    });

    if (response.ok) {
      const res = await response.json();
      return Right(res);
    } else {
      return Left("Could not fetch launchpad results");
    }
  } catch (error) {
    console.error(error);
    return Left("Error fetching mg leader results");
  }
}

Upvotes: 0

Views: 660

Answers (1)

Yilmaz
Yilmaz

Reputation: 49571

if you are using action for the form submission you can use useFormStatus

useFormStatus is a Hook that gives you status information of the last form submission.

const { pending, data, method, action } = useFormStatus();

Be careful to the caveats

  • The useFormStatus Hook must be called from a component that is rendered inside a .
  • useFormStatus will only return status information for a parent . It will not return status information for any rendered in that same component or children components.

other wise you could use Suspense

lets you display a fallback until its children have finished loading.

<Suspense fallback={<Loading />}>
  <SomeComponent />
</Suspense>

Upvotes: 0

Related Questions