my-web

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

React Web Development Standards

React Web开发规范

When writing or reviewing React web application code, follow these principles.
编写或评审React Web应用代码时,请遵循以下准则。

Stack

技术栈

  • UI framework: React with TypeScript (strict mode, no
    any
    )
  • Styling: Tailwind CSS + shadcn/ui primitives
  • Routing: TanStack Router (file-based route tree, loaders, auth guards)
  • State: Zustand for client state, router loaders for server state
  • Build: Vite
  • UI框架: React with TypeScript(严格模式,禁止使用
    any
  • 样式方案: Tailwind CSS + shadcn/ui 基础组件
  • 路由方案: TanStack Router(基于文件的路由树、加载器、权限守卫)
  • 状态管理: Zustand 用于客户端状态管理,路由加载器用于服务端状态
  • 构建工具: Vite

Component Guidelines

组件设计准则

One Component Per File

每个文件对应一个组件

Each file exports one React component. File name matches the component name:
MessageRow.tsx
exports
MessageRow
.
When a component has sub-components used only by it, group them in a folder:
Sidebar/
  Sidebar.tsx          -> main component
  SidebarItem.tsx      -> internal, used only by Sidebar
  SidebarSection.tsx   -> internal, used only by Sidebar
Simple components with no sub-components stay as standalone files — no folder needed. Do not put unrelated components in one file.
每个文件仅导出一个React组件,文件名与组件名保持一致:例如
MessageRow.tsx
导出
MessageRow
组件。
当某个组件包含仅自身使用的子组件时,将它们归类到同一个文件夹中:
Sidebar/
  Sidebar.tsx          -> 主组件
  SidebarItem.tsx      -> 内部组件,仅Sidebar使用
  SidebarSection.tsx   -> 内部组件,仅Sidebar使用
无附属子组件的简单组件保持为独立文件,无需创建文件夹。禁止将无关组件放在同一个文件中。

Prefix-Style Naming

前缀式命名规则

Component names use domain-prefix style — the domain comes first, then the specific part:
  • Sidebar
    ,
    SidebarItem
    ,
    SidebarSection
    — not
    Item
    ,
    Section
  • Message
    ,
    MessageRow
    ,
    MessageActions
    — not
    Row
    ,
    Actions
  • Channel
    ,
    ChannelHeader
    ,
    ChannelSettings
    — not
    Header
    ,
    Settings
The prefix makes it immediately clear which domain a component belongs to, even outside its folder. Grep-friendly and unambiguous.
Shared UI primitives (
components/ui/
) are the exception —
Button
,
Dialog
,
Avatar
need no domain prefix since they are domain-agnostic.
组件名采用领域前缀风格——先写领域名称,再写具体部分:
  • Sidebar
    ,
    SidebarItem
    ,
    SidebarSection
    — 而非
    Item
    ,
    Section
  • Message
    ,
    MessageRow
    ,
    MessageActions
    — 而非
    Row
    ,
    Actions
  • Channel
    ,
    ChannelHeader
    ,
    ChannelSettings
    — 而非
    Header
    ,
    Settings
前缀能让开发者立刻明确组件所属的领域,即使脱离其所在文件夹也能清晰识别,同时便于搜索且无歧义。
共享UI基础组件(
components/ui/
)是例外——
Button
,
Dialog
,
Avatar
无需领域前缀,因为它们与具体领域无关。

Fragments

片段(Fragments)

Fragments are large, self-contained UI regions — the biggest building blocks below a page. Think of them like Android Fragments or Atomic Design's organisms: a chat conversation, a profile editor, a settings form. Each fragment owns its own layout, internal state, and child components.
Fragments are not modals, panels, or wrappers — they are the main content regions that a route composes together. A page might render one fragment (full-screen chat) or several side by side (sidebar + chat + thread panel).
  • Name fragments after what they are, not how they're displayed:
    ChatConversation
    ,
    ProfileEditor
    ,
    ChannelSettings
    — not
    ChatModal
    ,
    ProfilePanel
    .
  • A fragment manages its own internal component tree but receives its key data (IDs, config) as props from the route.
  • Fragments live in a top-level
    fragments/
    folder. Each fragment gets its own folder, and fragment-specific components go in a
    components/
    subfolder within it:
    fragments/
      ChatConversation/
        ChatConversation.tsx          -> the fragment
        components/
          ChatMessageList.tsx         -> specific to this fragment
          ChatComposer.tsx            -> specific to this fragment
      ProfileEditor/
        ProfileEditor.tsx             -> the fragment
        components/
          ProfileAvatarUpload.tsx
          ProfileFormFields.tsx
  • The separate top-level
    components/
    folder is for shared, reusable components (UI primitives, domain components used across multiple fragments). Fragment-specific components stay inside the fragment's own
    components/
    subfolder.
  • Route files compose fragments — they don't build UI themselves:
    tsx
    function WorkspaceRoute() {
      const { channelId, threadId } = Route.useParams()
      return (
        <WorkspaceLayout>
          <Sidebar />
          <ChatConversation channelId={channelId} />
          {threadId && <ThreadPanel threadId={threadId} />}
        </WorkspaceLayout>
      )
    }
Fragments是大型、独立的UI区域,是页面之下最大的构建块,可以类比Android Fragments或原子设计中的Organisms:比如聊天对话区、资料编辑器、设置表单。每个Fragment管理自己的布局、内部状态和子组件。
Fragments不是模态框、面板或包装器——它们是路由组合起来的主要内容区域。一个页面可能渲染一个Fragment(全屏聊天)或多个并排的Fragment(侧边栏 + 聊天区 + 线程面板)。
  • Fragment命名应体现其功能本质,而非展示形式:
    ChatConversation
    ,
    ProfileEditor
    ,
    ChannelSettings
    — 而非
    ChatModal
    ,
    ProfilePanel
  • Fragment管理自身的内部组件树,但从路由接收关键数据(ID、配置)作为props。
  • Fragments存放在顶层
    fragments/
    文件夹中,每个Fragment拥有独立文件夹,其专属组件放在内部的
    components/
    子文件夹:
    fragments/
      ChatConversation/
        ChatConversation.tsx          -> 主Fragment
        components/
          ChatMessageList.tsx         -> 该Fragment专属组件
          ChatComposer.tsx            -> 该Fragment专属组件
      ProfileEditor/
        ProfileEditor.tsx             -> 主Fragment
        components/
          ProfileAvatarUpload.tsx
          ProfileFormFields.tsx
  • 独立的顶层
    components/
    文件夹用于存放共享可复用组件(UI基础组件、跨多个Fragment使用的领域组件)。Fragment专属组件需放在自身的
    components/
    子文件夹中。
  • 路由文件仅负责组合Fragments——不直接构建UI:
    tsx
    function WorkspaceRoute() {
      const { channelId, threadId } = Route.useParams()
      return (
        <WorkspaceLayout>
          <Sidebar />
          <ChatConversation channelId={channelId} />
          {threadId && <ThreadPanel threadId={threadId} />}
        </WorkspaceLayout>
      )
    }

Route Files Stay Thin

路由文件保持轻量化

Route files handle routing concerns only: declaring the route, params, loaders, error boundaries, and rendering the top-level page component.
Move all business logic, layout, data transformation, and UI orchestration into dedicated components imported by the route.
tsx
// Good: thin shell
function ChannelRoute() {
  const { channelId } = Route.useParams()
  return <ChannelView channelId={channelId} />
}

// Bad: entire page in the route file
function ChannelRoute() {
  const { channelId } = Route.useParams()
  const messages = useMessages(channelId)
  const members = useMembers(channelId)
  // ... 200 lines of UI, handlers, and logic
}
路由文件仅处理路由相关逻辑:声明路由、参数、加载器、错误边界,以及渲染顶层页面组件。
所有业务逻辑、布局、数据转换和UI编排都应移至路由导入的专用组件中。
tsx
// 推荐:轻量化外壳
function ChannelRoute() {
  const { channelId } = Route.useParams()
  return <ChannelView channelId={channelId} />
}

// 不推荐:路由文件包含整个页面逻辑
function ChannelRoute() {
  const { channelId } = Route.useParams()
  const messages = useMessages(channelId)
  const members = useMembers(channelId)
  // ... 200行UI、处理器和逻辑代码
}

Layout Stability with Loading States

加载状态下的布局稳定性

Components with loading/placeholder states must never change their size when transitioning between loading and loaded. Reserve the exact dimensions so the layout stays stable.
  • List headers and footers: if a list footer shows a "Load more" spinner or an "End of list" message, it must occupy the same height in both states. Use a fixed-height container or render an invisible placeholder of the same size.
  • Skeleton screens: skeletons must match the dimensions of the real content they replace.
  • Pagination indicators: when a paginated list reaches the end, swap the spinner for a static placeholder of the same size — never collapse to zero height.
  • General rule: if a region can be in a loading state, wrap it in a container with explicit dimensions or
    min-height
    so surrounding content never shifts.
tsx
// Good: fixed-height footer — no jump when loading ends
<div className="h-10 flex items-center justify-center">
  {isLoading ? <Spinner /> : hasMore ? null : <span className="text-muted">No more items</span>}
</div>

// Bad: footer collapses when loading ends, list jumps
{isLoading && <Spinner />}
带有加载/占位状态的组件在加载状态与已加载状态切换时,绝对不能改变尺寸。需预留精确的尺寸以保持布局稳定。
  • 列表头部和底部:如果列表底部显示“加载更多”加载器或“已加载全部”提示,两种状态下必须占据相同高度。使用固定高度容器或渲染相同尺寸的不可见占位符。
  • 骨架屏:骨架屏必须与所替代的真实内容尺寸完全匹配。
  • 分页指示器:当分页列表到达末尾时,将加载器替换为相同尺寸的静态占位符——绝对不能折叠为零高度。
  • 通用规则:如果某个区域可能处于加载状态,将其包裹在具有明确尺寸或
    min-height
    的容器中,避免周围内容移位。
tsx
// 推荐:固定高度底部——加载结束后无跳动
<div className="h-10 flex items-center justify-center">
  {isLoading ? <Spinner /> : hasMore ? null : <span className="text-muted">没有更多内容</span>}
</div>

// 不推荐:加载结束后底部折叠,列表跳动
{isLoading && <Spinner />}

Hover Interactions in Lists

列表中的悬停交互

When list rows have hover states (highlight, action buttons, menus), getting the behavior right is critical. Flickering, ghost hovers, or disappearing menus feel broken.
当列表行具有悬停状态(高亮、操作按钮、菜单)时,正确实现交互至关重要。闪烁、幽灵悬停或菜单消失都会让用户体验变差。

Rules

规则

  1. One hovered row at a time. Track
    hoveredId
    in state — only the row matching
    hoveredId
    renders its hover appearance. Do not rely on CSS
    :hover
    alone for anything that shows interactive controls, because CSS hover cannot coordinate with menu-open state.
  2. Menu open locks the hover. When a row's context menu or dropdown is open, that row stays hovered regardless of where the mouse moves. The menu and hover highlight must remain until the menu is explicitly closed (click outside, Escape, or selecting an item).
  3. No flicker on transitions. Moving the mouse between the row content and its action buttons (or the open menu popover) must not cause the hover to blink off and back on. Hover state is driven by the row container's
    mouseenter
    /
    mouseleave
    , not by individual child elements.
  1. 同一时间仅一行处于悬停状态。在状态中跟踪
    hoveredId
    ——只有匹配
    hoveredId
    的行才渲染悬停样式。对于显示交互控件的场景,不要仅依赖CSS
    :hover
    ,因为CSS悬停无法与菜单打开状态协同工作。
  2. 菜单打开时锁定悬停状态。当行的上下文菜单或下拉菜单打开时,无论鼠标移动到何处,该行都保持悬停状态。菜单和悬停高亮必须保持到菜单被显式关闭(点击外部、按Esc键或选择菜单项)。
  3. 过渡时无闪烁。在行内容与其操作按钮(或打开的菜单弹出层)之间移动鼠标时,悬停状态不得闪烁。悬停状态由行容器的
    mouseenter
    /
    mouseleave
    驱动,而非单个子元素。

Performance: Only Re-render the Affected Rows

性能优化:仅重新渲染受影响的行

Hover changes happen on every mouse move — re-rendering the entire list on each hover is unacceptable. Use a React context to broadcast hover/menu state so that only the row entering hover and the row leaving hover re-render, not the whole list.
tsx
// --- HoverContext.tsx ---

type HoverState = {
  hoveredId: string | null
  menuOpenId: string | null
  onMouseEnter: (id: string) => void
  onMouseLeave: () => void
  onMenuOpen: (id: string) => void
  onMenuClose: () => void
}

const HoverContext = createContext<HoverState>(null!)

function HoverProvider({ children }: { children: ReactNode }) {
  const [hoveredId, setHoveredId] = useState<string | null>(null)
  const [menuOpenId, setMenuOpenId] = useState<string | null>(null)

  const onMouseEnter = useCallback((id: string) => {
    setHoveredId((prev) => prev === id ? prev : id)
  }, [])

  const onMouseLeave = useCallback(() => {
    setHoveredId(null)
  }, [])

  const onMenuOpen = useCallback((id: string) => {
    setMenuOpenId(id)
    setHoveredId(id)
  }, [])

  const onMenuClose = useCallback(() => {
    setMenuOpenId(null)
  }, [])

  const value = useMemo(
    () => ({ hoveredId, menuOpenId, onMouseEnter, onMouseLeave, onMenuOpen, onMenuClose }),
    [hoveredId, menuOpenId, onMouseEnter, onMouseLeave, onMenuOpen, onMenuClose],
  )

  return <HoverContext.Provider value={value}>{children}</HoverContext.Provider>
}

// --- useRowHover.ts ---
// Each row subscribes to context but only re-renders when *its own* hover status changes.

function useRowHover(id: string) {
  const { hoveredId, menuOpenId, onMouseEnter, onMouseLeave, onMenuOpen, onMenuClose } =
    useContext(HoverContext)

  const isHovered = hoveredId === id
  const isMenuOpen = menuOpenId === id

  // Stable callbacks bound to this row's id
  const handlers = useMemo(() => ({
    onMouseEnter: () => onMouseEnter(id),
    onMouseLeave: menuOpenId ? undefined : onMouseLeave,
    onMenuOpen: () => onMenuOpen(id),
    onMenuClose,
  }), [id, menuOpenId, onMouseEnter, onMouseLeave, onMenuOpen, onMenuClose])

  return { isHovered, isMenuOpen, handlers }
}
The list component itself never re-renders on hover — it wraps children in
HoverProvider
and each
memo
'd row reads its own hover state via
useRowHover
:
tsx
const MessageRow = memo(function MessageRow({ message }: { message: Message }) {
  const { isHovered, isMenuOpen, handlers } = useRowHover(message.id)

  return (
    <div onMouseEnter={handlers.onMouseEnter} onMouseLeave={handlers.onMouseLeave}>
      <MessageContent message={message} />
      {(isHovered || isMenuOpen) && (
        <MessageActions onMenuOpen={handlers.onMenuOpen} onMenuClose={handlers.onMenuClose} />
      )}
    </div>
  )
})

function MessageList({ messages }: { messages: Message[] }) {
  return (
    <HoverProvider>
      {messages.map((msg) => (
        <MessageRow key={msg.id} message={msg} />
      ))}
    </HoverProvider>
  )
}
When hover moves from row A to row B: context value changes → only row A and row B re-render (their
isHovered
changed). All other rows stay untouched because
memo
sees the same props and
useRowHover
returns the same
false
/
false
.
Important caveat: the context approach above re-renders all rows when
hoveredId
changes because every row consumes the full context. To truly limit re-renders to only the two affected rows, use a ref + subscription pattern or a Zustand store instead of context — rows subscribe and only re-render when their own derived
isHovered
value flips. The context pattern shown is the simple starting point; if profiling shows too many re-renders in large lists, switch to a store-based approach.
悬停变化随每次鼠标移动发生——每次悬停时重新渲染整个列表是不可接受的。使用React上下文广播悬停/菜单状态,这样只有进入和离开悬停状态的行才会重新渲染,而非整个列表。
tsx
// --- HoverContext.tsx ---

type HoverState = {
  hoveredId: string | null
  menuOpenId: string | null
  onMouseEnter: (id: string) => void
  onMouseLeave: () => void
  onMenuOpen: (id: string) => void
  onMenuClose: () => void
}

const HoverContext = createContext<HoverState>(null!)

function HoverProvider({ children }: { children: ReactNode }) {
  const [hoveredId, setHoveredId] = useState<string | null>(null)
  const [menuOpenId, setMenuOpenId] = useState<string | null>(null)

  const onMouseEnter = useCallback((id: string) => {
    setHoveredId((prev) => prev === id ? prev : id)
  }, [])

  const onMouseLeave = useCallback(() => {
    setHoveredId(null)
  }, [])

  const onMenuOpen = useCallback((id: string) => {
    setMenuOpenId(id)
    setHoveredId(id)
  }, [])

  const onMenuClose = useCallback(() => {
    setMenuOpenId(null)
  }, [])

  const value = useMemo(
    () => ({ hoveredId, menuOpenId, onMouseEnter, onMouseLeave, onMenuOpen, onMenuClose }),
    [hoveredId, menuOpenId, onMouseEnter, onMouseLeave, onMenuOpen, onMenuClose],
  )

  return <HoverContext.Provider value={value}>{children}</HoverContext.Provider>
}

// --- useRowHover.ts ---
// 每行订阅上下文,但仅当*自身*悬停状态变化时重新渲染。

function useRowHover(id: string) {
  const { hoveredId, menuOpenId, onMouseEnter, onMouseLeave, onMenuOpen, onMenuClose } =
    useContext(HoverContext)

  const isHovered = hoveredId === id
  const isMenuOpen = menuOpenId === id

  // 绑定到当前行id的稳定回调
  const handlers = useMemo(() => ({
    onMouseEnter: () => onMouseEnter(id),
    onMouseLeave: menuOpenId ? undefined : onMouseLeave,
    onMenuOpen: () => onMenuOpen(id),
    onMenuClose,
  }), [id, menuOpenId, onMouseEnter, onMouseLeave, onMenuOpen, onMenuClose])

  return { isHovered, isMenuOpen, handlers }
}
列表组件本身在悬停时从不重新渲染——它将子组件包裹在
HoverProvider
中,每个经过
memo
处理的行通过
useRowHover
读取自身的悬停状态:
tsx
const MessageRow = memo(function MessageRow({ message }: { message: Message }) {
  const { isHovered, isMenuOpen, handlers } = useRowHover(message.id)

  return (
    <div onMouseEnter={handlers.onMouseEnter} onMouseLeave={handlers.onMouseLeave}>
      <MessageContent message={message} />
      {(isHovered || isMenuOpen) && (
        <MessageActions onMenuOpen={handlers.onMenuOpen} onMenuClose={handlers.onMenuClose} />
      )}
    </div>
  )
})

function MessageList({ messages }: { messages: Message[] }) {
  return (
    <HoverProvider>
      {messages.map((msg) => (
        <MessageRow key={msg.id} message={msg} />
      ))}
    </HoverProvider>
  )
}
当悬停从行A移至行B时:上下文值变化 → 仅行A和行B重新渲染(它们的
isHovered
状态改变)。所有其他行保持不变,因为
memo
检测到props未变,且
useRowHover
返回的
false
/
false
状态也未变。
重要提示:上述上下文方法在
hoveredId
变化时会重新渲染所有行,因为每行都消费完整的上下文。要真正将重新渲染限制在受影响的两行,可使用ref + 订阅模式或Zustand存储替代上下文——行订阅后仅在自身派生的
isHovered
值变化时重新渲染。所示的上下文模式是简单的起点;如果性能分析显示大型列表中重新渲染过多,可切换为基于存储的方案。

Testing Hover Behavior

悬停行为测试

Use
agent-browser
to verify hover interactions. These are the specific scenarios to test:
1. Basic hover highlighting:
  • Move mouse onto a row — row highlights.
  • Move mouse to a different row — first row un-highlights, second highlights.
  • Move mouse off all rows — no row highlighted.
2. Action buttons visibility:
  • Hover a row — action buttons appear.
  • Move to adjacent row — buttons disappear from first row, appear on second.
  • Move off list — all buttons hidden.
3. Menu open — hover lock:
  • Hover a row, click its menu button — menu opens.
  • Move mouse away from the row entirely (to another row, to empty space) — menu stays open, row stays highlighted. The other row does NOT highlight.
  • Move mouse back to the original row — still highlighted, no flicker.
4. Menu open — no blink on click:
  • Hover a row, click the menu button — verify the transition from hover-state to menu-open-state produces no visual blink (the highlight must not flash off then back on).
5. Menu close — return to normal:
  • With menu open, click outside the menu but still on the same row — menu closes, row stays highlighted (mouse is still over it), no blink. This is the hardest case — popup portals/overlays can cause spurious
    mouseleave
    events when the overlay disappears, leading to a hover blink between menu close and the browser re-firing
    mouseenter
    . The implementation must account for this.
  • With menu open, click outside the menu onto a different row — menu closes, hover moves to the clicked row.
  • With menu open, press Escape — menu closes, hover follows current mouse position.
6. Edge cases:
  • Rapidly move mouse across multiple rows — exactly one row highlighted at any moment, no double-highlights.
  • Open menu, scroll the list — menu should close (scroll dismisses popover), hover resets.
使用
agent-browser
验证悬停交互。需测试以下特定场景:
1. 基础悬停高亮:
  • 鼠标移至某行——行高亮。
  • 鼠标移至另一行——第一行取消高亮,第二行高亮。
  • 鼠标移至列表外——无行高亮。
2. 操作按钮可见性:
  • 悬停某行——操作按钮显示。
  • 移至相邻行——第一行按钮消失,第二行按钮显示。
  • 移至列表外——所有按钮隐藏。
3. 菜单打开时锁定悬停:
  • 悬停某行,点击菜单按钮——菜单打开。
  • 将鼠标完全移出行(到另一行或空白区域)——菜单保持打开,行保持高亮。另一行不会高亮。
  • 将鼠标移回原行——仍保持高亮,无闪烁。
4. 菜单打开时点击无闪烁:
  • 悬停某行,点击菜单按钮——验证从悬停状态到菜单打开状态的过渡无视觉闪烁(高亮不得先消失再恢复)。
5. 菜单关闭后恢复正常:
  • 菜单打开时,点击菜单外但仍在同一行的区域——菜单关闭,行保持高亮(鼠标仍在其上),无闪烁。这是最复杂的场景——弹出层/覆盖层消失时可能触发虚假的
    mouseleave
    事件,导致菜单关闭与浏览器重新触发
    mouseenter
    之间出现悬停闪烁。实现时必须考虑这种情况。
  • 菜单打开时,点击菜单外的另一行——菜单关闭,悬停状态移至点击的行。
  • 菜单打开时,按Esc键——菜单关闭,悬停状态跟随当前鼠标位置。
6. 边缘情况:
  • 快速在多行之间移动鼠标——任何时刻仅一行高亮,无双重高亮。
  • 打开菜单后滚动列表——菜单应关闭(滚动关闭弹出层),悬停状态重置。

Avoid
useEffect

避免使用
useEffect

useEffect
is a code smell in most cases. Before reaching for it, consider alternatives:
  • Derived state: compute inline during render or use
    useMemo
    . Never
    useEffect
    +
    setState
    to mirror a prop — just derive it.
  • Event handlers: if something should happen in response to a user action, do it in the handler, not in an effect that watches for state changes.
  • Refs for imperative APIs: use
    useRef
    +
    useLayoutEffect
    only when truly necessary (focus, scroll, measure).
  • Data fetching: use router loaders or a dedicated fetching hook, not
    useEffect
    +
    fetch
    +
    setState
    .
  • Subscriptions: use
    useSyncExternalStore
    or the store's own hook, not
    useEffect
    with manual subscribe/unsubscribe.
Legitimate uses are rare: setting up/tearing down non-React subscriptions with no hook abstraction, or one-time initialization with no better home. When you do use one, leave a comment explaining why.
在大多数情况下,
useEffect
是代码异味。使用前请考虑替代方案:
  • 派生状态:在渲染期间内联计算或使用
    useMemo
    。绝不要用
    useEffect
    +
    setState
    来镜像props——直接派生即可。
  • 事件处理器:如果需要响应用户操作,在处理器中执行,而非监听状态变化的effect。
  • 命令式API使用Ref:仅在真正必要时使用
    useRef
    +
    useLayoutEffect
    (聚焦、滚动、测量)。
  • 数据获取:使用路由加载器或专用的获取hook,而非
    useEffect
    +
    fetch
    +
    setState
  • 订阅:使用
    useSyncExternalStore
    或存储自身的hook,而非手动订阅/取消订阅的
    useEffect
合理的使用场景很少:设置/销毁无hook封装的非React订阅,或无更好位置的一次性初始化。使用时请留下注释说明原因。

State Management

状态管理

Zustand Stores

Zustand存储

  • Separate stores by concern:
    uiStore
    (modals, drafts, sidebar),
    connectionStore
    (online/offline),
    toastStore
    .
  • Keep stores small and focused — one responsibility per store.
  • For selectors that return derived arrays or objects, use
    useShallow
    to keep snapshots stable and avoid render loops.
tsx
// Good: stable selector with useShallow
const channels = useStore(useShallow((s) => s.channels.filter((c) => !c.archived)))

// Bad: creates new array reference every render
const channels = useStore((s) => s.channels.filter((c) => !c.archived))
  • 按关注点拆分存储:
    uiStore
    (模态框、草稿、侧边栏)、
    connectionStore
    (在线/离线)、
    toastStore
  • 保持存储小巧且聚焦——每个存储负责单一职责。
  • 对于返回派生数组或对象的选择器,使用
    useShallow
    保持快照稳定,避免渲染循环。
tsx
// 推荐:使用useShallow的稳定选择器
const channels = useStore(useShallow((s) => s.channels.filter((c) => !c.archived)))

// 不推荐:每次渲染创建新数组引用
const channels = useStore((s) => s.channels.filter((c) => !c.archived))

Data Fetching

数据获取

  • Use router loaders for data needed at route level.
  • REST for mutations, SSE or WebSockets for real-time updates.
  • Auth via bearer token in
    Authorization
    header.
  • Keep API clients typed — one function per endpoint, return typed responses.
  • 使用路由加载器获取路由级所需数据。
  • 突变操作使用REST,实时更新使用SSE或WebSockets。
  • 认证通过
    Authorization
    头中的Bearer token实现。
  • 保持API客户端类型化——每个端点对应一个函数,返回类型化响应。

File Organization

文件组织结构

app/
  routes/          -> route files (thin shells only)
  fragments/       -> large self-contained UI regions (ChatConversation/, ProfileEditor/, ...)
    [Fragment]/
      Fragment.tsx
      components/  -> components specific to this fragment
  components/
    ui/            -> shadcn/ui primitives (button, input, dialog, avatar, etc.)
    [domain]/      -> shared domain components reused across fragments
  stores/          -> Zustand stores (one per concern)
  lib/             -> utilities, hooks, session management, route guards
  api/             -> typed API client, HTTP helpers, SSE subscriber
  types.ts         -> shared TypeScript types
app/
  routes/          -> 路由文件(仅轻量化外壳)
  fragments/       -> 大型独立UI区域(ChatConversation/, ProfileEditor/, ...)
    [Fragment]/
      Fragment.tsx
      components/  -> 该Fragment专属组件
  components/
    ui/            -> shadcn/ui基础组件(button, input, dialog, avatar等)
    [domain]/      -> 跨Fragment复用的共享领域组件
  stores/          -> Zustand存储(按关注点拆分)
  lib/             -> 工具函数、自定义hook、会话管理、路由守卫
  api/             -> 类型化API客户端、HTTP工具、SSE订阅器
  types.ts         -> 共享TypeScript类型

File Naming

文件命名

  • One public function/component per file. File name matches export name.
  • Prefix notation for non-component files:
    channelCreate
    not
    createChannel
    ,
    messageSend
    not
    sendMessage
    .
  • domainVerb.ts
    +
    domainVerb.spec.ts
    side by side.
  • Tests use
    *.spec.ts
    , live next to the file under test.
  • Do not use barrel
    index.ts
    files.
  • 每个文件对应一个公开函数/组件。文件名与导出名称一致。
  • 非组件文件使用前缀表示法:
    channelCreate
    而非
    createChannel
    messageSend
    而非
    sendMessage
  • domainVerb.ts
    domainVerb.spec.ts
    放在一起。
  • 测试文件使用
    *.spec.ts
    ,与被测文件放在同一目录。
  • 不要使用桶式
    index.ts
    文件。

Conventions

通用约定

  • TypeScript only, ESM output.
  • Keep files under ~700 LOC; split when it improves clarity.
  • Brief comments for tricky or non-obvious logic only.
  • Unix timestamps (milliseconds) for time values.
    Date
    only at boundaries for parsing/formatting.
  • Prefer strict typing; avoid
    any
    .
  • 仅使用TypeScript,输出ESM格式。
  • 文件行数控制在约700行以内;当拆分能提升清晰度时进行拆分。
  • 仅对复杂或非直观逻辑添加简短注释。
  • 时间值使用Unix时间戳(毫秒)。仅在边界处使用
    Date
    进行解析/格式化。
  • 优先使用严格类型;避免
    any

Dev Page

开发测试页面

For non-trivial UI components, add a section on a dev route (
/dev
) for visual testing in isolation:
  • Render the component with representative props covering key states (empty, loading, populated, error, long text, missing data).
  • Each section: heading with component name, hardcoded/mock data, no dependency on real API state.
  • Trivial components (styled wrappers, single-line formatters) don't need dev page entries.
对于非 trivial 的UI组件,在开发路由(
/dev
)中添加单独区域进行隔离视觉测试:
  • 使用代表性props渲染组件,覆盖关键状态(空、加载、已填充、错误、长文本、缺失数据)。
  • 每个区域:包含组件名称的标题、硬编码/模拟数据,不依赖真实API状态。
  • Trivial组件(样式包装器、单行格式化器)无需在开发测试页面中添加条目。

Visual Verification with Browser

浏览器视觉验证

Use the
agent-browser
skill to visually verify UI changes. After building or modifying components, launch the dev server and use agent-browser to navigate to the page, take screenshots, and confirm the result matches expectations.
  • After styling changes: screenshot the affected page/component to verify layout and colors.
  • After adding new routes or pages: navigate to the route and screenshot to confirm rendering.
  • Responsive checks: resize the viewport and screenshot at different breakpoints.
  • Pixel-perfect comparison: when matching a reference design, set the browser viewport to match the reference image dimensions exactly (account for
    deviceScaleFactor
    on Retina displays — a 1440x900 viewport at 2x produces a 2880x1800 screenshot).
  • Interactive flows: fill forms, click buttons, and verify state transitions render correctly.
This replaces manual "open the browser and check" steps — let the agent verify visually.
使用
agent-browser
技能验证UI变更。构建或修改组件后,启动开发服务器,使用agent-browser导航到页面、截图,并确认结果符合预期。
  • 样式变更后:截图受影响的页面/组件,验证布局和颜色。
  • 添加新路由或页面后:导航到该路由并截图,确认渲染正常。
  • 响应式检查:调整视口大小,在不同断点截图。
  • 像素级对比:当匹配参考设计时,将浏览器视口设置为与参考图片完全一致的尺寸(考虑Retina显示器的
    deviceScaleFactor
    ——1440x900视口在2x缩放时生成2880x1800截图)。
  • 交互式流程:填写表单、点击按钮,验证状态转换渲染正确。
这替代了手动“打开浏览器检查”的步骤——让代理完成视觉验证。

Summary

总结

  1. One component per file, domain-prefix names, colocate sub-components in folders
  2. Route files are thin shells — logic lives in components
  3. Avoid
    useEffect
    — derive state, handle events, use proper hooks
  4. Zustand for client state with
    useShallow
    for derived selectors
  5. Router loaders for data fetching, typed API clients for mutations
  6. Prefix notation for files (
    domainVerb.ts
    ), tests next to source
  7. shadcn/ui + Tailwind for styling, keep the design system consistent
  1. 每个文件对应一个组件,使用领域前缀命名,子组件与父组件放在同一文件夹
  2. 路由文件为轻量化外壳——逻辑存放在组件中
  3. 避免使用
    useEffect
    ——派生状态、处理事件、使用合适的hook
  4. 使用Zustand管理客户端状态,对派生选择器使用
    useShallow
  5. 路由加载器用于数据获取,类型化API客户端用于突变操作
  6. 文件使用前缀表示法(
    domainVerb.ts
    ),测试文件与源文件放在一起
  7. 使用shadcn/ui + Tailwind进行样式开发,保持设计系统一致性