← Назад к вопросам

Как оптимизируешь рендер на React частичной выдачи карточек пользователю?

2.0 Middle🔥 201 комментариев
#React#Архитектура и паттерны

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI3 апр. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Оптимизация рендера карточек в React

Привет! Вопрос про оптимизацию рендера при выводе большого количества карточек (списки пользователей, товаров, постов). Это классическая задача, где наивный подход быстро приведет к lag.

Проблема: рендер 1000+ карточек

Если рендерить всё сразу:

// Плохо - рендерит все 1000 карточек одновременно
function UsersList({ users }) {
  return (
    <div>
      {users.map(user => (
        <UserCard key={user.id} user={user} />
      ))}
    </div>
  );
}

Что происходит:

  • React создает 1000 компонентов в памяти
  • DOM операции для всех 1000 элементов
  • Браузер зависает при прокрутке
  • Первое отображение может занять секунды

Решение 1: Виртуализация (Virtual Scrolling)

Рендерим только видимые элементы + буфер:

// Вариант 1: Использование react-window (популярная библиотека)
import { FixedSizeList as List } from 'react-window';

function VirtualizedUsersList({ users }) {
  const Row = ({ index, style }) => (
    <div style={style}>
      <UserCard user={users[index]} />
    </div>
  );

  return (
    <List
      height={600}
      itemCount={users.length}
      itemSize={100} // высота одной карточки
      width="100%"
    >
      {Row}
    </List>
  );
}

// Вариант 2: Собственная реализация с Intersection Observer
function CustomVirtualList({ users, itemHeight = 100 }) {
  const [visibleRange, setVisibleRange] = useState({ start: 0, end: 10 });
  const containerRef = useRef(null);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const handleScroll = () => {
      const scrollTop = container.scrollTop;
      const start = Math.floor(scrollTop / itemHeight);
      const end = start + Math.ceil(container.clientHeight / itemHeight) + 5;
      setVisibleRange({ start, end });
    };

    container.addEventListener('scroll', handleScroll);
    return () => container.removeEventListener('scroll', handleScroll);
  }, [itemHeight]);

  const visibleUsers = users.slice(visibleRange.start, visibleRange.end);
  const offsetY = visibleRange.start * itemHeight;

  return (
    <div
      ref={containerRef}
      style={{ height: '600px', overflowY: 'auto', position: 'relative' }}
    >
      <div style={{ height: users.length * itemHeight }}>
        <div style={{ transform: `translateY(${offsetY}px)` }}>
          {visibleUsers.map((user) => (
            <UserCard key={user.id} user={user} />
          ))}
        </div>
      </div>
    </div>
  );
}

Решение 2: Ленивая загрузка (Lazy Loading)

Загружаем данные по мере скролла:

function LazyLoadingList() {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const observerTarget = useRef(null);

  const loadMore = useCallback(async () => {
    if (loading || !hasMore) return;
    
    setLoading(true);
    try {
      const response = await fetch(`/api/users?page=${page}`);
      const newItems = await response.json();
      
      setItems(prev => [...prev, ...newItems]);
      setPage(prev => prev + 1);
      setHasMore(newItems.length > 0);
    } catch (error) {
      console.error('Failed to load:', error);
    } finally {
      setLoading(false);
    }
  }, [page, loading, hasMore]);

  // Intersection Observer для срабатывания при скролле
  useEffect(() => {
    const observer = new IntersectionObserver(
      entries => {
        if (entries[0].isIntersecting && hasMore && !loading) {
          loadMore();
        }
      },
      { threshold: 0.1 }
    );

    if (observerTarget.current) {
      observer.observe(observerTarget.current);
    }

    return () => observer.disconnect();
  }, [loadMore, hasMore, loading]);

  return (
    <div>
      <div className="user-list">
        {items.map(user => (
          <UserCard key={user.id} user={user} />
        ))}
      </div>
      {hasMore && <div ref={observerTarget}>Loading...</div>}
    </div>
  );
}

Решение 3: Оптимизация рендера компонентов

Минимизируй перерендеры карточек:

// Используй React.memo для предотвращения ненужных перерендеров
const UserCard = React.memo(({ user, onClick }) => {
  console.log('Rendering card:', user.id); // зависнет при скролле, если не мемоизировано
  
  return (
    <div className="card" onClick={onClick}>
      <h3>{user.name}</h3>
      <p>{user.email}</p>
    </div>
  );
}, (prevProps, nextProps) => {
  // Кастомная проверка равенства
  return (
    prevProps.user.id === nextProps.user.id &&
    prevProps.onClick === nextProps.onClick
  );
});

// Родитель должен мемоизировать callbacks
function UsersList({ users }) {
  const handleCardClick = useCallback((userId) => {
    console.log('Clicked:', userId);
  }, []);

  return (
    <div>
      {users.map(user => (
        <UserCard
          key={user.id}
          user={user}
          onClick={() => handleCardClick(user.id)}
        />
      ))}
    </div>
  );
}

Решение 4: Комбинированный подход (Лучше всего)

function OptimizedUsersList({ initialUsers }) {
  const [items, setItems] = useState(initialUsers);
  const [visibleRange, setVisibleRange] = useState({ start: 0, end: 20 });
  const [isLoading, setIsLoading] = useState(false);
  const containerRef = useRef(null);

  const itemHeight = 120;
  const visibleUsers = items.slice(visibleRange.start, visibleRange.end);
  const offsetY = visibleRange.start * itemHeight;

  const handleScroll = useCallback(() => {
    const container = containerRef.current;
    if (!container) return;

    const scrollTop = container.scrollTop;
    const start = Math.floor(scrollTop / itemHeight);
    const end = start + Math.ceil(container.clientHeight / itemHeight) + 10;
    
    setVisibleRange({ start, end });

    // Загрузить больше, если приближаемся к концу
    if (end >= items.length - 20 && !isLoading) {
      loadMoreItems();
    }
  }, [items.length, isLoading]);

  const loadMoreItems = useCallback(async () => {
    setIsLoading(true);
    try {
      const response = await fetch(`/api/users?offset=${items.length}`);
      const newItems = await response.json();
      setItems(prev => [...prev, ...newItems]);
    } finally {
      setIsLoading(false);
    }
  }, [items.length]);

  return (
    <div
      ref={containerRef}
      style={{
        height: '600px',
        overflowY: 'auto',
        border: '1px solid #ddd'
      }}
      onScroll={handleScroll}
    >
      <div style={{ height: items.length * itemHeight }}>
        <div style={{ transform: `translateY(${offsetY}px)` }}>
          {visibleUsers.map(user => (
            <UserCard key={user.id} user={user} />
          ))}
        </div>
      </div>
      {isLoading && <div style={{ padding: '20px' }}>Loading...</div>}
    </div>
  );
}

Решение 5: Pagination (для продуктовых сайтов)

Просто показываем страницы по 20-50 элементов:

function PaginatedUsersList() {
  const [page, setPage] = useState(1);
  const [users, setUsers] = useState([]);
  const [totalPages, setTotalPages] = useState(0);

  useEffect(() => {
    const fetchUsers = async () => {
      const response = await fetch(`/api/users?page=${page}&limit=20`);
      const data = await response.json();
      setUsers(data.items);
      setTotalPages(data.totalPages);
    };
    fetchUsers();
  }, [page]);

  return (
    <div>
      <div className="users-grid">
        {users.map(user => <UserCard key={user.id} user={user} />)}
      </div>
      <div className="pagination">
        {Array.from({ length: totalPages }, (_, i) => i + 1).map(p => (
          <button
            key={p}
            onClick={() => setPage(p)}
            className={p === page ? 'active' : ''}
          >
            {p}
          </button>
        ))}
      </div>
    </div>
  );
}

Сравнение подходов

ПодходПлюсыМинусыКогда использовать
Virtual ScrollingПлавный скроллСложнее реализовать5000+ элементов
Lazy LoadingЭкономит трафикНужен бэкендМного элементов
PaginationПростоНеудобна на мобилкеСредние списки
МемоизацияБыстроТребует вниманияВсе подходы

Best Practices

  1. Всегда используй key={id}, не index
  2. Мемоизируй callback'и через useCallback
  3. Не передавай объекты как props без мемоизации
  4. Измеряй производительность через DevTools
  5. Комбинируй подходы: virtual scrolling + lazy loading
  6. Кэшируй загруженные данные (useMemo, Redux, TanStack Query)
  7. Показывай скелетоны при загрузке

Итого

Для 1000+ карточек используй виртуализацию (react-window или собственная реализация). Комбинируй с ленивой загрузкой данных и мемоизацией компонентов. Не забывай про правильный key и useCallback. Это даст плавный скролл и быструю загрузку.