一、快速开始
一个小型、快速且可扩展的 Bearbones 状态管理解决方案。Zustand 有一个基于 hooks 的舒适 API。它不是样板文件或固执己见,但有足够的惯例来明确和类似通量。
不要因为它很可爱而忽视它,它有爪子!我们花了很多时间来处理常见的陷阱,比如可怕的僵尸子问题、 React 并发以及混合渲染器之间的上下文丢失 。它可能是 React 领域中唯一一个能够满足所有这些要求的状态管理器。
npm i zustand
你的商店是一个钩子!您可以在其中放入任何内容:原语、对象、函数。该set函数合并状态。
import { create } from 'zustand'
const useStore = create((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
}))
您可以在任何地方使用该钩子,而不需要提供者。选择您的状态,当该状态发生变化时,使用组件将重新渲染。
function BearCounter() {
const bears = useStore((state) => state.bears)
return <h1>{bears} around here...</h1>
}
function Controls() {
const increasePopulation = useStore((state) => state.increasePopulation)
return <button onClick={increasePopulation}>one up</button>
}
二、创建Store
创建一个Store,存储用户信息和加载状态
// src/store/userStore.ts
import { create } from 'zustand'
interface BearState {
user: IUser | null | undefined
setUser: (user: IUser | null | undefined) => void
updateUser: (user: Partial<IUser>) => void
loading: boolean
setLoading: (loading: boolean) => void
}
export const useUserStore = create<BearState>((set, get) => ({
user: null,
isLogin: false,
setUser: (user) => {
if (user) {
set({ user: user })
return
}
set({ user: null })
},
// 更新user中的部分字段
updateUser: (user) => {
const oldUser = get().user
if (!oldUser) return
const newUser = { ...oldUser, ...user }
set({ user: newUser })
},
loading: true,
setLoading: (loading) => set({ loading }),
}))
定义用户信息类型
// types/typings.d.ts
interface IUser {
email: string | null
phone: string | null
id: string
bio: string | null
name: string | null
image: string | null
expiredAt: string | null
active: boolean
createdAt: string
}
三、绑定组件
抽离一个Provider,处理用户信息
// src/app/(page)/layout.tsx
import Navbar from '@/components/Navbar'
import Providers from '@/components/Providers'
import { Toaster } from '@/components/ui/toaster'
import { getServerSession } from 'next-auth'
import { authOptions } from '../api/auth/[...nextauth]/route'
import UserProvider from '@/components/UserProvider'
export default async function RootLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await getServerSession(authOptions)
// 如果全局保护路由,这里可以做一些判断
return (
<Providers>
<UserProvider session={session}>
<Navbar />
<main>{children}</main>
</UserProvider>
<Toaster />
</Providers>
)
}
更新用户信息与状态到store
// src/components/UserProvider.tsx
'use client'
import { trpc } from '@/app/_trpc/client'
import { reactQueryRetry } from '@/lib/utils'
import { useUserStore } from '@/store/userStore'
import { useEffect, useState } from 'react'
type Props = {
children?: React.ReactNode
session?: any
}
const UserProvider = ({ children, session }: Props) => {
const [enabled, setEnabled] = useState(false)
const [setUser, setLoading] = useUserStore((state) => [
state.setUser,
state.setLoading,
])
const { data, isLoading } = trpc.getUserInfo.useQuery(undefined, {
retry: reactQueryRetry,
enabled: enabled, // 控制查询的启动
})
useEffect(() => {
if (session) {
setEnabled(true)
}
setUser(data)
setLoading(isLoading)
}, [session, data, setUser, isLoading, setLoading])
return <>{children}</>
}
export default UserProvider
四、更新数据源
1. 导航
导航数据原读取session,由src/components/Navbar/index.tsx传到MenuUser,可以在MenuUser读取store的数据,除了优化数据源外优化了过度动画
// src/components/Navbar/MenuUser.tsx
'use client'
import { LogOut, User } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useRouter } from 'next/navigation'
import { signOut } from 'next-auth/react'
import { useUserStore } from '@/store/userStore'
import ImageAvatar from './ImageAvatar'
export default function DropdownMenuDemo() {
const router = useRouter()
const [userInfo, userLoading] = useUserStore((state) => [
state.user,
state.loading,
])
const closeOnCurrent = (href: string) => {
router.push(href)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="rounded-full w-10 h-10 flex items-center justify-center border border-zinc-200 relative bg-zinc-50 overflow-hidden cursor-pointer">
<ImageAvatar userInfo={userInfo} userLoading={userLoading} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>我的账户</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => {
closeOnCurrent('/account')
}}
className="cursor-pointer"
>
<User className="mr-2 h-4 w-4" />
<span>账户信息</span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
signOut()
}}
>
<LogOut className="mr-2 h-4 w-4" />
<span>退出登录</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
这里抽离了一个处理头像的通用方法,使加载过渡更流畅,兼容了上传头像的提示,需要自定义自行改造
为什么会有如此复杂的判断逻辑
无头像,前两个状态需要加载
userLoading userInfo?.image setIsDefaultImageLoaded
T F F
T F F
F F T
有头像,前三个状态需要加载
userLoading userInfo?.image setIsUserImageLoaded
T F F
T F F
F T F
F T T
// src/components/Navbar/ImageAvatar.tsx
'use client'
import { useState } from 'react'
import Image from 'next/image'
import { cn } from '@/lib/utils'
interface ImageAvatarProps {
userInfo: IUser | null | undefined
userLoading: boolean
animateSize?: string
showTips?: boolean
tipsSize?: string
}
const ImageAvatar = (props: ImageAvatarProps) => {
const { userInfo, userLoading, animateSize, showTips, tipsSize } = props
const [isUserImageLoaded, setIsUserImageLoaded] = useState(false)
const [isDefaultImageLoaded, setIsDefaultImageLoaded] = useState(false)
const showLoading =
userLoading ||
(userLoading && !userInfo?.image && !isDefaultImageLoaded) ||
(userLoading && !userInfo?.image && !isUserImageLoaded) ||
(!userLoading && userInfo?.image && !isUserImageLoaded)
return (
<>
{userInfo?.image ? (
<Image
sizes="100%"
fill={true}
src={userInfo.image}
alt="avatar"
priority={true}
onLoad={() => setIsUserImageLoaded(true)}
/>
) : (
<Image
sizes="100%"
fill={true}
src={'/images/user.png'}
alt="avatar"
priority={true}
onLoad={() => setIsDefaultImageLoaded(true)}
/>
)}
{showLoading && (
<div
className={cn(
'absolute animate-pulse rounded-full bg-zinc-300 opacity-25',
animateSize ? animateSize : 'w-10 h-10'
)}
></div>
)}
{!showLoading && showTips && (
<div
className={cn(
'absolute top-3/4 bg-zinc-600 opacity-90 text-center pt-3 text-sm text-white',
tipsSize ? tipsSize : 'w-64 h-16'
)}
>
<div>拖拽图片到此处</div>
<div>或点击</div>
</div>
)}
</>
)
}
export default ImageAvatar
2. 用户信息
用户设置页面Card组件,只需要更新前半部分的数据源和头像组件
// src/components/UserCard.tsx
'use client'
import { cn } from '@/lib/utils'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from './ui/skeleton'
import Link from 'next/link'
import { buttonVariants } from './ui/button'
import { Mail, PhoneCall, UserCog2 } from 'lucide-react'
import { useUserStore } from '@/store/userStore'
import ImageAvatar from './Navbar/ImageAvatar'
function UserCard() {
const [userInfo, isLoading] = useUserStore((state) => [
state.user,
state.loading,
])
return (
<Card className="w-[350px] md:w-[400px]">
<CardHeader>
<div className="flex gap-4 relative">
<div className="w-16 h-16">
{isLoading ? (
<Skeleton className="h-full w-full rounded-full" />
) : (
<div className="rounded-full w-full h-full flex items-center justify-center border border-zinc-200 relative bg-zinc-50 overflow-hidden">
<ImageAvatar
userInfo={userInfo}
userLoading={isLoading}
animateSize="w-16 h-16"
/>
</div>
)}
</div>
// ...
3. 用户设置
设置页面不再单独请求用户的信息,直接交给子组件去从store获取
'use client'
import Settings from '@/components/Settings'
import UploadAvatar from '@/components/UploadAvatar'
function SettingsPage() {
return (
<div className="flex-1 justify-between flex flex-col h-[calc(100vh-3.5rem)]">
<div className="mx-auto w-full max-w-8xl grow lg:flex lg:flex-row-reverse xl:px-2">
<div className="shrink-0 flex-[0.5] lg:w-96">
<UploadAvatar />
</div>
<div className="flex-1 xl:flex">
<div className="px-4 py-6 sm:px-6 lg:pl-8 xl:flex-1 xl:pl-6">
<Settings />
</div>
</div>
</div>
</div>
)
}
export default SettingsPage
编辑信息组件,改变数据源,更新信息成功后直接更新到store
// src/components/Settings.tsx
'use client'
import { useCallback, useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { trpc } from '@/app/_trpc/client'
import { toast } from '@/components/ui/use-toast'
import { getMessages } from '@/lib/tips'
import { Loader2 } from 'lucide-react'
import { Textarea } from '@/components/ui/textarea'
import { UpdateProfile, TUpdateProfile } from '@/lib/validator'
import { Label } from './ui/label'
import { Input } from './ui/input'
import { useUserStore } from '@/store/userStore'
function Settings() {
const [userInfo, updateUser] = useUserStore((state) => [
state.user,
state.updateUser,
])
const {
register,
handleSubmit,
formState: { errors },
setValue,
} = useForm<TUpdateProfile>({
defaultValues: {},
resolver: zodResolver(UpdateProfile),
})
useEffect(() => {
if (userInfo) {
setValue('bio', userInfo.bio ?? undefined)
setValue('name', userInfo.name ?? undefined)
}
}, [userInfo, setValue])
const { mutate: startUpdateProfile, isLoading: updateProfileLoading } =
trpc.updateProfile.useMutation({
onSuccess: (data) => {
if (data) {
updateUser(data)
}
toast({
title: getMessages('10036'),
description: getMessages('10037'),
variant: 'default',
})
},
onError: (error) => {
toast({
title: getMessages('10036'),
description: error.message,
variant: 'destructive',
})
},
})
const handlePageSubmit = useCallback(
async ({ bio, name }: TUpdateProfile) => {
startUpdateProfile({ bio, name })
},
[startUpdateProfile]
)
return (...)
}
export default Settings
需要updateProfile来支持返回更新的数据
// src/trpc/index.ts
updateProfile: privateProcedure
.input(UpdateProfile)
.mutation(async ({ input, ctx }) => {
try {
const { userId } = ctx
const { bio, name } = input
const userInfo = await db.user.update({
where: {
id: userId,
},
data: {
bio,
name,
},
})
// 不能给出全部的值
return {
id: userInfo.id,
bio: userInfo.bio,
name: userInfo.name,
}
} catch (error) {
handleErrorforInitiative(error)
}
}),
头像组件
// src/components/UploadAvatar.tsx
const [userInfo, updateUser, userLoading] = useUserStore((state) => [
state.user,
state.updateUser,
state.loading,
])
// 删除头像回调
updateUser({ image: undefined })
// 更换头像回调
updateUser({ image: data.image })
// 直接使用组件
<ImageAvatar
userInfo={userInfo}
userLoading={userLoading}
animateSize="w-64 h-64"
showTips={true}
tipsSize="w-64 h-16"
/>
// 原先更新头像到state也不需要,直接使用userInfo?.image