Модуль 9. Реакции, эмодзи, стикеры и виртуализация списков

Цель модуля: реализовать реакции и пикеры эмодзи/стикеров, освоить виртуализацию списков (единая техника для сетки эмодзи и списка сообщений).

К концу модуля у вас:

  • Reaction, ReactionBar, EmojiPicker с анимацией пульсации.
  • Утилита виртуализации списков (работает и для 1000 эмодзи, и для 10k сообщений).
  • Route Handler /api/emojis с mock-данными.

Перед началом: Модули 1-8. Инструменты: react-window, CSS animation. Время: ~5 ч.


🔥 Инцидент. Чат на 10 000 сообщений. React рендерит все 10 000 DOM-узлов одновременно — страница зависает на 8 секунд. Профайлер показывает: 99% времени уходит на layout и paint невидимых элементов. Решение — виртуализация: рендерим только ~20 видимых строк, остальное подменяется при скролле.


📖 Теория

9.1 Reaction — кнопка реакции с анимацией

tsx
// ui-kit/src/components/Reaction/Reaction.tsx
import styles from './Reaction.module.css'

interface ReactionProps {
  emoji: string
  count: number
  selected?: boolean
  onClick?: () => void
}

export function Reaction({ emoji, count, selected, onClick }: ReactionProps) {
  return (
    <button
      className={[styles.reaction, selected && styles.selected].filter(Boolean).join(' ')}
      onClick={onClick}
      aria-pressed={selected}
    >
      <span className={styles.emoji}>{emoji}</span>
      <span className={styles.count}>{count}</span>
    </button>
  )
}
css
/* ui-kit/src/components/Reaction/Reaction.module.css */
.reaction {
  display: inline-flex; align-items: center; gap: var(--s-xs);
  padding: var(--s-xs) var(--s-sm);
  background: var(--c-surface); border: 1px solid var(--c-border);
  border-radius: var(--radius-full);
  cursor: pointer; transition: all 0.15s;
}
.reaction:hover { background: var(--c-surface-2); }
.reaction.selected {
  background: var(--c-surface-accent, rgba(37, 99, 235, 0.1));
  border-color: var(--c-primary);
  animation: pulse 0.3s ease-out;
}
.emoji { font-size: 1rem; }
.count { font-size: var(--font-size-sm); color: var(--c-text-muted); }
.selected .count { color: var(--c-primary); font-weight: 600; }

@keyframes pulse {
  0% { transform: scale(1); }
  50% { transform: scale(1.15); }
  100% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) { .reaction.selected { animation: none; } }

ReactionBar — панель реакций под сообщением

tsx
// ui-kit/src/components/ReactionBar/ReactionBar.tsx
import { Reaction } from '../Reaction'
import { Box } from '../Box'
import styles from './ReactionBar.module.css'

interface ReactionItem { emoji: string; count: number; selected?: boolean }
interface ReactionBarProps {
  reactions: ReactionItem[]
  onReact?: (emoji: string) => void
  maxReactions?: number
}

export function ReactionBar({ reactions, onReact, maxReactions = 5 }: ReactionBarProps) {
  const visible = reactions.slice(0, maxReactions)
  return (
    <Box gap="xs" className={styles.bar}>
      {visible.map((r) => (
        <Reaction key={r.emoji} {...r} onClick={() => onReact?.(r.emoji)} />
      ))}
    </Box>
  )
}

9.2 Виртуализация списков — обобщённо

И сетка эмодзи, и список сообщений — одна и та же задача: рендерить только видимую часть.

tsx
// ui-kit/src/components/VirtualList/VirtualList.tsx
/* eslint-disable react-hooks/incompatible-library -- TanStack Virtual uses non-memoizable API by design */
import { useRef, type ReactNode, type UIEvent } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import styles from './VirtualList.module.css'

interface VirtualListProps<T> {
  items: T[]
  renderItem: (item: T, index: number) => ReactNode
  estimateSize?: number
  overscan?: number
  height?: number | string
  onScroll?: (e: UIEvent<HTMLDivElement>) => void
}

export function VirtualList<T>({
  items, renderItem, estimateSize = 48, overscan = 5, height = 400, onScroll,
}: VirtualListProps<T>) {
  const parentRef = useRef<HTMLDivElement>(null)

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => estimateSize,
    overscan,
  })

  return (
    <div
      ref={parentRef}
      className={styles.container}
      style={{ height, overflow: 'auto' }}
      onScroll={onScroll}
    >
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              transform: `translateY(${virtualItem.start}px)`,
            }}
          >
            {renderItem(items[virtualItem.index], virtualItem.index)}
          </div>
        ))}
      </div>
    </div>
  )
}

Установите @tanstack/react-virtual: npm install @tanstack/react-virtual в app/package.json.

Использование для EmojiPicker

tsx
// ui-kit/src/components/EmojiPicker/EmojiPicker.tsx
import { useState } from 'react'
import { VirtualList } from '../VirtualList'
import { Input } from '../Input'
import styles from './EmojiPicker.module.css'

const EMOJIS = ['😀', '😂', '😍', '🤔', '👍', '👎', '❤️', '🔥', '🎉', '😢', /* ... 1000+ */]

export function EmojiPicker({ onSelect }: { onSelect: (emoji: string) => void }) {
  const [search, setSearch] = useState('')
  const filtered = EMOJIS.filter((e) => e.includes(search))

  return (
    <div className={styles.picker}>
      <Input id="emoji-search" placeholder="Search emoji..." value={search} onChange={(e) => setSearch(e.target.value)} />
      <VirtualList
        items={filtered}
        renderItem={(emoji) => (
          <button className={styles.emojiBtn} onClick={() => onSelect(emoji)}>
            <span style={{ fontSize: '1.5rem' }}>{emoji}</span>
          </button>
        )}
        estimateSize={44}
        height={300}
      />
    </div>
  )
}

Story «1000 эмодзи»: в Storybook добавьте story с 1000 элементами — без виртуализации это лагает, с VirtualList — плавный скролл.


9.3 Route Handler /api/emojis

ts
// app/app/api/emojis/route.ts
import { NextResponse } from 'next/server'

const EMOJI_CATEGORIES = {
  Smileys: ['😀', '😂', '😍', '🤔', '😎', '😴'],
  Gestures: ['👍', '👎', '👏', '🙏', '💪', '🤝'],
  Hearts: ['❤️', '🧡', '💛', '💚', '💙', '💜'],
  Objects: ['🔥', '🎉', '⭐', '💎', '🎁', '🏆'],
}

export async function GET() {
  const emojis = Object.entries(EMOJI_CATEGORIES).map(([category, items]) => ({ category, items }))
  return NextResponse.json({ emojis })
}

Компромиссы (Trade-offs)

РешениеПлюсыМинусы
VirtualList (windowing)O(visible) DOM-узлов, плавный скролл на 10k+Сложнее sticky-headers и динамические высоты
Прямой .map()Простота, достаточно для <100 элементовТормозит на больших списках, блокирует скролл
FixedSizeListO(1) расчёт позиции — быстроТолько строки одинаковой высоты
VariableSizeListПоддержка разных высот сообщенийНужен measure, медленнее и сложнее
CSS-анимация (pulse)Дёшево, GPU-ускореноОтключается при prefers-reduced-motion

💻 9.4 Практика

  1. Установите @tanstack/react-virtual.
  2. Реализуйте Reaction, ReactionBar, VirtualList, EmojiPicker.
  3. Добавьте stories: Reaction с states, EmojiPicker с story «1000 эмодзи».
  4. Добавьте /api/emojis Route Handler.
  5. Добавьте ReactionBar под MessageBubble в MessageRow.
bash
npm run verify

9.5 Артефакты сдачи М9

АртефактКритерий
verify зелёныйВсе тесты проходят
Скриншот StorybookPrimitives/Reaction (default + selected + pulsation), Primitives/EmojiPicker
Профайлер ReactСкриншот Profiler: 100 эмодзи с VirtualList — < 16ms на рендер
GET /api/emojiscurl /api/emojis → категории с эмодзи

Вперёд → Модуль 10: Core Web Vitals.


🧩 Проверь себя

1. Overscan в виртуализации — для чего?
2. Sticky header в виртуализированном списке — техника?
3. Реакции/эмодзи на сообщения — зачем виртуализация?
4. react-window FixedSizeList vs VariableSizeList — разница?
5. Виртуализация списков (react-window) — зачем?

🔗 Мостик к Модулю 10

Списки виртуализированы — профайлер доволен. Но Lighthouse всё ещё красный: Core Web Vitals (LCP, CLS, INP) плохие. В Модуле 10 измерим метрики и приведём их в норму.