Next.js客户端组件中tRPC useQuery加载状态的正确处理
解决Next.js客户端组件tRPC useQuery hydration失败问题
问题原因
Next.js服务端渲染(SSR)阶段,客户端组件中的useQuery不会发起请求,因此服务端渲染时isLoading始终为false。而客户端完成hydrate后,useQuery开始执行请求,isLoading变为true,此时客户端显示骨架屏,和服务端输出的UI(通常是null)不一致,触发hydration不匹配错误。
另外你的代码中,服务端无法访问localStorage,所以getDeviceId()初始返回空字符串,第一个useQuery的enabled为false,服务端渲染时直接走到return null分支;客户端hydrate后,localStorage有值时enabled变为true,isLoading变为true,显示骨架屏,这就导致前后UI不一致。
解决方案
方法1:使用useIsClient钩子判断客户端环境
创建一个钩子判断当前是否处于客户端,仅在客户端环境下才显示加载状态,确保服务端和客户端初始UI一致。
首先实现useIsClient钩子:
"use client"; import { useState, useEffect } from "react"; export function useIsClient() { const [isClient, setIsClient] = useState(false); useEffect(() => { setIsClient(true); }, []); return isClient; }
修改你的组件:
"use client"; // 导入useIsClient import { useIsClient } from "./useIsClient"; export function ActiveArtists() { const isClient = useIsClient(); const { getValue: getDeviceId } = useLocalStorage({ key: DEVICE_ID_LOCAL_STORAGE_KEY, defaultValue: "", }); const { data: activeGrid, isLoading: isActiveGridLoading } = api.grids.getActiveGrid.useQuery( { device_id: getDeviceId() }, { enabled: !!getDeviceId() }, ); const { data: artists, isLoading: isArtistsLoading } = api.grids.getArtists.useQuery( { grid_id: Number(activeGrid?.id) ?? 0 }, { enabled: !!activeGrid?.id }, ); // 仅在客户端时才显示加载状态 if (isClient && (isActiveGridLoading || isArtistsLoading)) { return ( <div className="flex items-center space-x-4"> <Skeleton className={cn(avatarClasses.lg, "rounded-full")} /> <Skeleton className={cn(avatarClasses.lg, "rounded-full")} /> <Skeleton className={cn(avatarClasses.lg, "rounded-full")} /> </div> ); } if (!activeGrid?.id || !artists?.length) { return null; } const validArtists = artists.filter( (artist) => artist?.id && artist?.username, ); return <ArtistList artists={validArtists} />; }
方法2:用useEffect同步客户端加载状态
通过本地状态管理加载状态,利用useEffect仅在客户端执行的特性,同步tRPC的isLoading值,避免服务端和客户端初始UI差异。
修改组件:
"use client"; import { useState, useEffect } from "react"; export function ActiveArtists() { const [clientIsLoading, setClientIsLoading] = useState(false); const { getValue: getDeviceId } = useLocalStorage({ key: DEVICE_ID_LOCAL_STORAGE_KEY, defaultValue: "", }); const { data: activeGrid, isLoading: isActiveGridLoading } = api.grids.getActiveGrid.useQuery( { device_id: getDeviceId() }, { enabled: !!getDeviceId() }, ); const { data: artists, isLoading: isArtistsLoading } = api.grids.getArtists.useQuery( { grid_id: Number(activeGrid?.id) ?? 0 }, { enabled: !!activeGrid?.id }, ); // 仅在客户端同步加载状态 useEffect(() => { setClientIsLoading(isActiveGridLoading || isArtistsLoading); }, [isActiveGridLoading, isArtistsLoading]); if (clientIsLoading) { return ( <div className="flex items-center space-x-4"> <Skeleton className={cn(avatarClasses.lg, "rounded-full")} /> <Skeleton className={cn(avatarClasses.lg, "rounded-full")} /> <Skeleton className={cn(avatarClasses.lg, "rounded-full")} /> </div> ); } if (!activeGrid?.id || !artists?.length) { return null; } const validArtists = artists.filter( (artist) => artist?.id && artist?.username, ); return <ArtistList artists={validArtists} />; }
原理说明
两种方法核心都是让服务端渲染时输出的UI和客户端初始hydrate时的UI保持一致:
- 服务端渲染阶段,
useEffect不会执行,本地状态保持初始值(false),所以服务端返回的是null; - 客户端hydrate完成后,
useEffect执行,同步加载状态,此时才显示骨架屏,不会触发hydration不匹配错误。
内容的提问来源于stack exchange,提问作者crevulus
相关产品推荐
相关产品推荐

