refactor: scope providers by route and use XState selectors

This commit is contained in:
2026-07-13 19:07:53 +08:00
parent f9b5f22ee9
commit 37ae152abb
24 changed files with 512 additions and 336 deletions
@@ -0,0 +1,66 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import {
AuthProvider,
useAuthDispatch,
useAuthSelector,
} from "@/stores/auth/auth-context";
let container: HTMLDivElement | null = null;
let root: ReturnType<typeof createRoot> | null = null;
afterEach(() => {
if (root) {
act(() => root?.unmount());
}
container?.remove();
container = null;
root = null;
});
describe("AuthProvider selectors", () => {
it("does not rerender a login-status subscriber for panel-only changes", () => {
let renderCount = 0;
function Probe() {
const loginStatus = useAuthSelector(
(state) => state.context.loginStatus,
);
const dispatch = useAuthDispatch();
renderCount += 1;
return (
<button
type="button"
onClick={() =>
dispatch({ type: "AuthPanelModeChanged", mode: "email" })
}
>
{loginStatus}
</button>
);
}
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
act(() => {
root?.render(
<AuthProvider>
<Probe />
</AuthProvider>,
);
});
expect(renderCount).toBe(1);
act(() => {
container?.querySelector("button")?.click();
});
expect(renderCount).toBe(1);
expect(container.textContent).toBe("notLoggedIn");
});
});
+41 -53
View File
@@ -1,21 +1,10 @@
"use client";
/**
* AuthContext:基于 XState v5 的 React Context Provider
*
* 业务事件(`Auth*Submitted`)通过 `useMachine(authMachine).send()` 派发。
* XState 自动处理异步副作用(invoke + fromPromise actors)。
*
* 兼容性:保持原 `useAuthState()` / `useAuthDispatch()` API
* 内部将 XState 状态机快照映射回原 `AuthState` 形状。
* AuthContext:基于 XState actor context 和 selector 的 React Provider
*/
import {
type Dispatch,
type ReactNode,
createContext,
useContext,
useMemo,
} from "react";
import { useMachine } from "@xstate/react";
import type { Dispatch, ReactNode } from "react";
import { createActorContext, shallowEqual } from "@xstate/react";
import type { SnapshotFrom } from "xstate";
import { authMachine } from "./auth-machine";
import type { AuthEvent, AuthState as MachineContext } from "./auth-machine";
@@ -33,54 +22,53 @@ interface AuthState {
hasInitialized: MachineContext["hasInitialized"];
}
const AuthStateCtx = createContext<AuthState | null>(null);
const AuthDispatchCtx = createContext<Dispatch<AuthEvent> | null>(null);
type AuthSnapshot = SnapshotFrom<typeof authMachine>;
type AuthSelector<T> = (snapshot: AuthSnapshot) => T;
const AuthActorContext = createActorContext(authMachine);
export interface AuthProviderProps {
children: ReactNode;
}
export function AuthProvider({ children }: AuthProviderProps) {
const [state, send] = useMachine(authMachine);
// 映射 XState 状态机快照 → 原 AuthState 形状
const authState = useMemo<AuthState>(
() => ({
authPanelMode: state.context.authPanelMode,
// isLoading 覆盖邮箱登录 / 邮箱注册 / OAuth 跳转(NextAuth 重定向期间)/
// OAuth 回调后端 sync / 显式游客登录
isLoading:
state.matches("loadingEmailLogin") ||
state.matches("loadingEmailRegister") ||
state.matches("loadingOAuth") ||
state.matches("initializing") ||
state.matches("loggingOut") ||
state.matches("syncingGoogleBackend") ||
state.matches("syncingFacebookBackend") ||
state.matches("loadingGuestLogin"),
errorMessage: state.context.errorMessage,
loginStatus: state.context.loginStatus,
hasInitialized: state.context.hasInitialized,
}),
[state],
);
return (
<AuthStateCtx.Provider value={authState}>
<AuthDispatchCtx.Provider value={send}>{children}</AuthDispatchCtx.Provider>
</AuthStateCtx.Provider>
);
return <AuthActorContext.Provider>{children}</AuthActorContext.Provider>;
}
export function useAuthState(): AuthState {
const ctx = useContext(AuthStateCtx);
if (!ctx) throw new Error("useAuthState must be used inside <AuthProvider>");
return ctx;
return useAuthSelector(selectAuthState, shallowEqual);
}
export function useAuthDispatch(): Dispatch<AuthEvent> {
const ctx = useContext(AuthDispatchCtx);
if (!ctx)
throw new Error("useAuthDispatch must be used inside <AuthProvider>");
return ctx;
return AuthActorContext.useActorRef().send;
}
export function useAuthSelector<T>(
selector: AuthSelector<T>,
compare?: (previous: T, next: T) => boolean,
): T {
return AuthActorContext.useSelector(selector, compare);
}
function selectAuthState(state: AuthSnapshot): AuthState {
return {
authPanelMode: state.context.authPanelMode,
isLoading: selectAuthIsLoading(state),
errorMessage: state.context.errorMessage,
loginStatus: state.context.loginStatus,
hasInitialized: state.context.hasInitialized,
};
}
export function selectAuthIsLoading(state: AuthSnapshot): boolean {
return (
state.matches("loadingEmailLogin") ||
state.matches("loadingEmailRegister") ||
state.matches("loadingOAuth") ||
state.matches("initializing") ||
state.matches("loggingOut") ||
state.matches("syncingGoogleBackend") ||
state.matches("syncingFacebookBackend") ||
state.matches("loadingGuestLogin")
);
}
+43 -49
View File
@@ -1,13 +1,8 @@
"use client";
import {
type Dispatch,
type ReactNode,
createContext,
useContext,
useMemo,
} from "react";
import { useMachine } from "@xstate/react";
import { type Dispatch, type ReactNode, useMemo } from "react";
import { createActorContext, shallowEqual } from "@xstate/react";
import type { SnapshotFrom } from "xstate";
import { chatMachine } from "./chat-machine";
import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
@@ -38,57 +33,56 @@ interface ChatState {
unlockPaywallRequest: MachineContext["unlockPaywallRequest"];
}
const ChatStateCtx = createContext<ChatState | null>(null);
const ChatDispatchCtx = createContext<Dispatch<ChatEvent> | null>(null);
type ChatSnapshot = SnapshotFrom<typeof chatMachine>;
type ChatSelector<T> = (snapshot: ChatSnapshot) => T;
const ChatActorContext = createActorContext(chatMachine);
export interface ChatProviderProps {
children: ReactNode;
}
export function ChatProvider({ children }: ChatProviderProps) {
const [state, send] = useMachine(chatMachine, {});
// 映射 XState 状态机快照 → 原 ChatState 形状
const chatState = useMemo<ChatState>(
() => ({
messages: appendPromotionMessage(
state.context.messages,
state.context.promotion,
),
historyMessages: state.context.messages,
promotion: state.context.promotion,
isReplyingAI: state.context.isReplyingAI,
upgradePromptVisible: state.context.upgradePromptVisible,
upgradeReason: state.context.upgradeReason,
historyLoaded: state.context.historyLoaded,
unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible,
lockedHistoryCount: state.context.lockedHistoryCount,
isUnlockingHistory: state.context.isUnlockingHistory,
unlockHistoryError: state.context.unlockHistoryError,
isUnlockingMessage: state.context.isUnlockingMessage,
unlockingMessageId: state.context.unlockingMessageId,
unlockMessageError: state.context.unlockMessageError,
unlockPaywallRequest: state.context.unlockPaywallRequest,
}),
[state],
);
return (
<ChatStateCtx.Provider value={chatState}>
<ChatDispatchCtx.Provider value={send}>{children}</ChatDispatchCtx.Provider>
</ChatStateCtx.Provider>
);
return <ChatActorContext.Provider>{children}</ChatActorContext.Provider>;
}
export function useChatState(): ChatState {
const ctx = useContext(ChatStateCtx);
if (!ctx) throw new Error("useChatState must be used inside <ChatProvider>");
return ctx;
const selected = useChatSelector(selectChatState, shallowEqual);
const messages = useMemo(
() => appendPromotionMessage(selected.historyMessages, selected.promotion),
[selected.historyMessages, selected.promotion],
);
return useMemo(() => ({ ...selected, messages }), [messages, selected]);
}
export function useChatDispatch(): Dispatch<ChatEvent> {
const ctx = useContext(ChatDispatchCtx);
if (!ctx)
throw new Error("useChatDispatch must be used inside <ChatProvider>");
return ctx;
return ChatActorContext.useActorRef().send;
}
export function useChatSelector<T>(
selector: ChatSelector<T>,
compare?: (previous: T, next: T) => boolean,
): T {
return ChatActorContext.useSelector(selector, compare);
}
type SelectedChatState = Omit<ChatState, "messages">;
function selectChatState(state: ChatSnapshot): SelectedChatState {
return {
historyMessages: state.context.messages,
promotion: state.context.promotion,
isReplyingAI: state.context.isReplyingAI,
upgradePromptVisible: state.context.upgradePromptVisible,
upgradeReason: state.context.upgradeReason,
historyLoaded: state.context.historyLoaded,
unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible,
lockedHistoryCount: state.context.lockedHistoryCount,
isUnlockingHistory: state.context.isUnlockingHistory,
unlockHistoryError: state.context.unlockHistoryError,
isUnlockingMessage: state.context.isUnlockingMessage,
unlockingMessageId: state.context.unlockingMessageId,
unlockMessageError: state.context.unlockMessageError,
unlockPaywallRequest: state.context.unlockPaywallRequest,
};
}
+42 -53
View File
@@ -3,14 +3,9 @@
/**
* PaymentContext:基于 XState v5 的 React Context Provider
*/
import {
type Dispatch,
type ReactNode,
createContext,
useContext,
useMemo,
} from "react";
import { useMachine } from "@xstate/react";
import type { Dispatch, ReactNode } from "react";
import { createActorContext, shallowEqual } from "@xstate/react";
import type { SnapshotFrom } from "xstate";
import { paymentMachine } from "./payment-machine";
import type {
@@ -37,61 +32,55 @@ export interface PaymentContextState {
isPaid: boolean;
}
const PaymentStateCtx = createContext<PaymentContextState | null>(null);
const PaymentDispatchCtx = createContext<Dispatch<PaymentEvent> | null>(null);
type PaymentSnapshot = SnapshotFrom<typeof paymentMachine>;
type PaymentSelector<T> = (snapshot: PaymentSnapshot) => T;
const PaymentActorContext = createActorContext(paymentMachine);
export interface PaymentProviderProps {
children: ReactNode;
}
export function PaymentProvider({ children }: PaymentProviderProps) {
const [state, send] = useMachine(paymentMachine);
const paymentState = useMemo<PaymentContextState>(
() => ({
status: String(state.value),
plans: state.context.plans,
isFirstRecharge: state.context.isFirstRecharge,
selectedPlanId: state.context.selectedPlanId,
payChannel: state.context.payChannel,
autoRenew: state.context.autoRenew,
agreed: state.context.agreed,
currentOrderId: state.context.currentOrderId,
payParams: state.context.payParams,
orderStatus: state.context.orderStatus,
errorMessage: state.context.errorMessage,
launchNonce: state.context.launchNonce,
isLoadingPlans:
state.matches("loadingCachedPlans") ||
state.matches("loadingPlans") ||
state.matches("refreshingPlans"),
isCreatingOrder: state.matches("creatingOrder"),
isPollingOrder:
state.matches("pollingOrder") || state.matches("waitingForPayment"),
isPaid: state.matches("paid"),
}),
[state],
);
return (
<PaymentStateCtx.Provider value={paymentState}>
<PaymentDispatchCtx.Provider value={send}>
{children}
</PaymentDispatchCtx.Provider>
</PaymentStateCtx.Provider>
);
return <PaymentActorContext.Provider>{children}</PaymentActorContext.Provider>;
}
export function usePaymentState(): PaymentContextState {
const ctx = useContext(PaymentStateCtx);
if (!ctx)
throw new Error("usePaymentState must be used inside <PaymentProvider>");
return ctx;
return usePaymentSelector(selectPaymentState, shallowEqual);
}
export function usePaymentDispatch(): Dispatch<PaymentEvent> {
const ctx = useContext(PaymentDispatchCtx);
if (!ctx)
throw new Error("usePaymentDispatch must be used inside <PaymentProvider>");
return ctx;
return PaymentActorContext.useActorRef().send;
}
export function usePaymentSelector<T>(
selector: PaymentSelector<T>,
compare?: (previous: T, next: T) => boolean,
): T {
return PaymentActorContext.useSelector(selector, compare);
}
function selectPaymentState(state: PaymentSnapshot): PaymentContextState {
return {
status: String(state.value),
plans: state.context.plans,
isFirstRecharge: state.context.isFirstRecharge,
selectedPlanId: state.context.selectedPlanId,
payChannel: state.context.payChannel,
autoRenew: state.context.autoRenew,
agreed: state.context.agreed,
currentOrderId: state.context.currentOrderId,
payParams: state.context.payParams,
orderStatus: state.context.orderStatus,
errorMessage: state.context.errorMessage,
launchNonce: state.context.launchNonce,
isLoadingPlans:
state.matches("loadingCachedPlans") ||
state.matches("loadingPlans") ||
state.matches("refreshingPlans"),
isCreatingOrder: state.matches("creatingOrder"),
isPollingOrder:
state.matches("pollingOrder") || state.matches("waitingForPayment"),
isPaid: state.matches("paid"),
};
}
@@ -1,13 +1,8 @@
"use client";
import {
type Dispatch,
type ReactNode,
createContext,
useContext,
useMemo,
} from "react";
import { useMachine } from "@xstate/react";
import type { Dispatch, ReactNode } from "react";
import { createActorContext, shallowEqual } from "@xstate/react";
import type { SnapshotFrom } from "xstate";
import { privateRoomMachine } from "./private-room-machine";
import type {
@@ -33,64 +28,54 @@ export interface PrivateRoomContextState {
isUnlocking: boolean;
}
const PrivateRoomStateCtx = createContext<PrivateRoomContextState | null>(null);
const PrivateRoomDispatchCtx = createContext<Dispatch<PrivateRoomEvent> | null>(
null,
);
type PrivateRoomSnapshot = SnapshotFrom<typeof privateRoomMachine>;
type PrivateRoomSelector<T> = (snapshot: PrivateRoomSnapshot) => T;
const PrivateRoomActorContext = createActorContext(privateRoomMachine);
export interface PrivateRoomProviderProps {
children: ReactNode;
}
export function PrivateRoomProvider({ children }: PrivateRoomProviderProps) {
const [state, send] = useMachine(privateRoomMachine);
const privateRoomState = useMemo<PrivateRoomContextState>(
() => ({
status: String(state.value),
profile: state.context.profile,
items: state.context.items,
nextCursor: state.context.nextCursor,
hasMore: state.context.hasMore,
creditBalance: state.context.creditBalance,
errorMessage: state.context.errorMessage,
unlockingMomentId: state.context.unlockingMomentId,
unlockErrorMessage: state.context.unlockErrorMessage,
pendingConfirmMomentId: state.context.pendingConfirmMomentId,
unlockPaywallRequest: state.context.unlockPaywallRequest,
unlockSuccessNonce: state.context.unlockSuccessNonce,
isLoading: state.matches("loading"),
isLoadingMore: state.matches("loadingMore"),
isUnlocking: state.matches("unlocking"),
}),
[state],
);
return (
<PrivateRoomStateCtx.Provider value={privateRoomState}>
<PrivateRoomDispatchCtx.Provider value={send}>
{children}
</PrivateRoomDispatchCtx.Provider>
</PrivateRoomStateCtx.Provider>
<PrivateRoomActorContext.Provider>{children}</PrivateRoomActorContext.Provider>
);
}
export function usePrivateRoomState(): PrivateRoomContextState {
const ctx = useContext(PrivateRoomStateCtx);
if (!ctx) {
throw new Error(
"usePrivateRoomState must be used inside <PrivateRoomProvider>",
);
}
return ctx;
return usePrivateRoomSelector(selectPrivateRoomState, shallowEqual);
}
export function usePrivateRoomDispatch(): Dispatch<PrivateRoomEvent> {
const ctx = useContext(PrivateRoomDispatchCtx);
if (!ctx) {
throw new Error(
"usePrivateRoomDispatch must be used inside <PrivateRoomProvider>",
);
}
return ctx;
return PrivateRoomActorContext.useActorRef().send;
}
export function usePrivateRoomSelector<T>(
selector: PrivateRoomSelector<T>,
compare?: (previous: T, next: T) => boolean,
): T {
return PrivateRoomActorContext.useSelector(selector, compare);
}
function selectPrivateRoomState(
state: PrivateRoomSnapshot,
): PrivateRoomContextState {
return {
status: String(state.value),
profile: state.context.profile,
items: state.context.items,
nextCursor: state.context.nextCursor,
hasMore: state.context.hasMore,
creditBalance: state.context.creditBalance,
errorMessage: state.context.errorMessage,
unlockingMomentId: state.context.unlockingMomentId,
unlockErrorMessage: state.context.unlockErrorMessage,
pendingConfirmMomentId: state.context.pendingConfirmMomentId,
unlockPaywallRequest: state.context.unlockPaywallRequest,
unlockSuccessNonce: state.context.unlockSuccessNonce,
isLoading: state.matches("loading"),
isLoadingMore: state.matches("loadingMore"),
isUnlocking: state.matches("unlocking"),
};
}
+13 -2
View File
@@ -8,13 +8,24 @@
* 不再承担全局状态同步职责。
*/
import { useEffect, useRef } from "react";
import { shallowEqual } from "@xstate/react";
import { AuthStorage } from "@/data/storage/auth";
import { useAuthState } from "@/stores/auth/auth-context";
import {
selectAuthIsLoading,
useAuthSelector,
} from "@/stores/auth/auth-context";
import { useChatDispatch } from "@/stores/chat/chat-context";
export function ChatAuthSync() {
const authState = useAuthState();
const authState = useAuthSelector(
(state) => ({
hasInitialized: state.context.hasInitialized,
isLoading: selectAuthIsLoading(state),
loginStatus: state.context.loginStatus,
}),
shallowEqual,
);
const chatDispatch = useChatDispatch();
const prevSessionKeyRef = useRef<string | null>(null);
+51 -16
View File
@@ -1,28 +1,33 @@
"use client";
/**
* Payment → User / Chat 同步器
* Payment → User 同步器
*
* 支付成功是跨模块事件:
* - User 需要重新拉取 profile + entitlements,刷新 isVip / creditBalance。
* - Chat 需要根据历史锁定消息数量决定直接解锁或弹窗确认。
* 支付成功后刷新用户权益、清理套餐缓存,并消费首充标记。
*/
import { useEffect, useRef } from "react";
import { shallowEqual } from "@xstate/react";
import { useChatDispatch } from "@/stores/chat/chat-context";
import { useUserDispatch } from "@/stores/user/user-context";
import { hasPendingChatUnlock } from "@/lib/navigation/chat_unlock_session";
import {
usePaymentDispatch,
usePaymentState,
usePaymentSelector,
} from "@/stores/payment/payment-context";
import { getPaymentRepository } from "@/data/repositories/payment_repository";
export function PaymentSuccessSync() {
const paymentState = usePaymentState();
const paymentState = usePaymentSelector(
(state) => ({
currentOrderId: state.context.currentOrderId,
isPaid: state.matches("paid"),
orderStatus: state.context.orderStatus,
}),
shallowEqual,
);
const paymentDispatch = usePaymentDispatch();
const userDispatch = useUserDispatch();
const chatDispatch = useChatDispatch();
const lastPaidKeyRef = useRef<string | null>(null);
useEffect(() => {
@@ -33,22 +38,13 @@ export function PaymentSuccessSync() {
lastPaidKeyRef.current = paidKey;
paymentDispatch({ type: "PaymentFirstRechargeConsumed" });
let cancelled = false;
const syncPaymentSuccess = async () => {
await getPaymentRepository().clearCachedPlans();
userDispatch({ type: "UserFetch" });
if (await hasPendingChatUnlock()) return;
if (cancelled) return;
chatDispatch({ type: "ChatPaymentSucceeded" });
};
void syncPaymentSuccess();
return () => {
cancelled = true;
};
}, [
chatDispatch,
paymentState.currentOrderId,
paymentState.isPaid,
paymentState.orderStatus,
@@ -58,3 +54,42 @@ export function PaymentSuccessSync() {
return null;
}
/** Payment → Chat bridge, mounted only while the chat route is active. */
export function ChatPaymentSuccessSync() {
const paymentState = usePaymentSelector(
(state) => ({
currentOrderId: state.context.currentOrderId,
isPaid: state.matches("paid"),
orderStatus: state.context.orderStatus,
}),
shallowEqual,
);
const chatDispatch = useChatDispatch();
const lastPaidKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!paymentState.isPaid || !paymentState.currentOrderId) return;
const paidKey = `${paymentState.currentOrderId}:${paymentState.orderStatus}`;
if (lastPaidKeyRef.current === paidKey) return;
lastPaidKeyRef.current = paidKey;
let cancelled = false;
void (async () => {
if (await hasPendingChatUnlock()) return;
if (!cancelled) chatDispatch({ type: "ChatPaymentSucceeded" });
})();
return () => {
cancelled = true;
};
}, [
chatDispatch,
paymentState.currentOrderId,
paymentState.isPaid,
paymentState.orderStatus,
]);
return null;
}
+13 -2
View File
@@ -9,13 +9,24 @@
* - guest / email / OAuth → 重新 hydrate user store
*/
import { useEffect, useRef } from "react";
import { shallowEqual } from "@xstate/react";
import type { LoginStatus } from "@/data/dto/auth";
import { useAuthState } from "@/stores/auth/auth-context";
import {
selectAuthIsLoading,
useAuthSelector,
} from "@/stores/auth/auth-context";
import { useUserDispatch } from "@/stores/user/user-context";
export function UserAuthSync() {
const { hasInitialized, isLoading, loginStatus } = useAuthState();
const { hasInitialized, isLoading, loginStatus } = useAuthSelector(
(state) => ({
hasInitialized: state.context.hasInitialized,
isLoading: selectAuthIsLoading(state),
loginStatus: state.context.loginStatus,
}),
shallowEqual,
);
const userDispatch = useUserDispatch();
const prevLoginStatusRef = useRef<LoginStatus | null>(null);
+32 -47
View File
@@ -1,21 +1,10 @@
"use client";
/**
* UserContext:基于 XState v5 的 React Context Provider
*
* 业务事件通过 `useMachine(userMachine).send()` 派发。
* XState 自动处理异步副作用(invoke + fromPromise actors)。
*
* 兼容性:保持原 `useUserState()` / `useUserDispatch()` API。
* 内部将 XState 状态机快照映射回原 `UserState` 形状。
* UserContext:基于 XState actor context 和 selector 的 React Provider
*/
import {
type Dispatch,
type ReactNode,
createContext,
useContext,
useMemo,
} from "react";
import { useMachine } from "@xstate/react";
import type { Dispatch, ReactNode } from "react";
import { createActorContext, shallowEqual } from "@xstate/react";
import type { SnapshotFrom } from "xstate";
import { userMachine } from "./user-machine";
import type { UserState as MachineContext, UserEvent } from "./user-machine";
@@ -31,48 +20,44 @@ interface UserState {
creditBalance: number;
}
const UserStateCtx = createContext<UserState | null>(null);
const UserDispatchCtx = createContext<Dispatch<UserEvent> | null>(null);
type UserSnapshot = SnapshotFrom<typeof userMachine>;
type UserSelector<T> = (snapshot: UserSnapshot) => T;
const UserActorContext = createActorContext(userMachine);
export interface UserProviderProps {
children: ReactNode;
}
export function UserProvider({ children }: UserProviderProps) {
const [state, send] = useMachine(userMachine);
// 映射 XState 状态机快照 → 原 UserState 形状
const userState = useMemo<UserState>(
() => ({
currentUser: state.context.currentUser,
isLoading:
state.matches("initializing") ||
state.matches("fetching") ||
state.matches("loggingOut") ||
state.context.isLoading,
avatarUrl: state.context.avatarUrl,
isVip: state.context.isVip,
creditBalance: state.context.creditBalance,
}),
[state],
);
return (
<UserStateCtx.Provider value={userState}>
<UserDispatchCtx.Provider value={send}>{children}</UserDispatchCtx.Provider>
</UserStateCtx.Provider>
);
return <UserActorContext.Provider>{children}</UserActorContext.Provider>;
}
export function useUserState(): UserState {
const ctx = useContext(UserStateCtx);
if (!ctx) throw new Error("useUserState must be used inside <UserProvider>");
return ctx;
return useUserSelector(selectUserState, shallowEqual);
}
export function useUserDispatch(): Dispatch<UserEvent> {
const ctx = useContext(UserDispatchCtx);
if (!ctx)
throw new Error("useUserDispatch must be used inside <UserProvider>");
return ctx;
return UserActorContext.useActorRef().send;
}
export function useUserSelector<T>(
selector: UserSelector<T>,
compare?: (previous: T, next: T) => boolean,
): T {
return UserActorContext.useSelector(selector, compare);
}
function selectUserState(state: UserSnapshot): UserState {
return {
currentUser: state.context.currentUser,
isLoading:
state.matches("initializing") ||
state.matches("fetching") ||
state.matches("loggingOut") ||
state.context.isLoading,
avatarUrl: state.context.avatarUrl,
isVip: state.context.isVip,
creditBalance: state.context.creditBalance,
};
}