You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Next.js 14使用Server Actions时更新客户端组件UI状态的正确方法

问题:Next.js 14中Server Actions与客户端组件交互时的isLoading状态更新错误

我计划用Next.js 14构建一个股票扫描页面,该页面通过默认参数从外部API加载数据,同时支持用户自定义参数并重新执行扫描、加载结果(页面示例如下):

Page example

目前数据加载正常,且能通过Server Actions加载新数据,但reloadData函数中的setIsLoading(true)代码行触发了如下错误:

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.

想问两个问题:

  1. 这种客户端/服务器组件与Server Actions的交互组织方式是否合理?
  2. 如果合理,点击扫描按钮时,正确更新isLoading状态以向用户提供反馈的方法是什么?

代码实现

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); // 触发错误的代码行
    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;

用户交互组件(BollingerBandsBreachParameters.tsx)

"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;

数据加载Server Action(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");
  }
}

问题解答

1. 交互组织方式是否合理?

合理。你的实现符合Next.js 14 App Router的设计模式:

  • 客户端组件(带'use client')负责UI渲染、状态管理和用户交互逻辑
  • Server Action负责在服务器端执行数据获取操作,避免在客户端暴露敏感API信息,同时利用服务器环境优化请求性能

这种分工是Next.js推荐的最佳实践,无需调整整体架构。

2. 正确更新isLoading状态的方法

错误的核心原因是Server Action的参数传递不匹配:你定义的getBBBreachData第一个参数是prevState(表单状态回传的约定参数),但调用时直接传入了minClosePrice,导致参数顺序混乱,触发了错误的async/await提示(实际是参数错误引发的连锁问题)。

修正方案如下:

步骤一:修正Server Action的参数传递

有两种方式可选:

方式A:调用时传入占位的prevState

在reloadData中调用getBBBreachData时,第一个参数传undefined占位:

const reloadData = async (
  minClosePrice: number,
  period: number,
  multiplier: number,
  lookback: number
) => {
  setIsLoading(true);
  try {
    const reload = await getBBBreachData(
      undefined, // 占位prevState参数
      minClosePrice,
      period,
      multiplier,
      lookback
    );
    setData(reload);
  } finally {
    setIsLoading(false); // 确保请求完成后关闭加载状态
  }
};
方式B:修改Server Action的参数顺序(推荐)

如果不需要表单状态回传功能,将prevState移到最后作为可选参数:

// actions.ts
export async function getBBBreachData(
  minClosePrice: number = 10,
  period: number = 20,
  multiplier: number = 2,
  lookback: number = 1,
  prevState?: any // 改为可选参数,移到最后
): Promise<Either<ScreenerErrorMsg, BollingerBandsScreenerResult[]>> {
  // 原有逻辑不变
}

这样调用时就可以直接传递业务参数,无需占位符。

步骤二:完善isLoading状态管理

必须在请求完成(无论成功或失败)后调用setIsLoading(false),避免加载状态一直保持:

  • 使用try/finally块确保状态更新逻辑一定会执行
  • 初始加载的useEffect中也要保持同样的逻辑(你的现有代码已经正确实现)

步骤三:优化客户端组件的async调用

在交互组件的handleClick中添加await,确保异步逻辑执行完成:

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

内容的提问来源于stack exchange,提问作者mattmar10

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.01 22:42:07