Next.js 14使用Server Actions时更新客户端组件UI状态的正确方法
我计划用Next.js 14构建一个股票扫描页面,该页面通过默认参数从外部API加载数据,同时支持用户自定义参数并重新执行扫描、加载结果(页面示例如下):

目前数据加载正常,且能通过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.
想问两个问题:
- 这种客户端/服务器组件与Server Actions的交互组织方式是否合理?
- 如果合理,点击扫描按钮时,正确更新
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

