Перейти к основному содержимому

Собеседование FRONTEND разработчика на стажировку. Вопросы + live conding

· 154 мин. чтения

Сегодня мы разберем собеседование на позицию фулстек-разработчика с акцентом на фронтенд-знания. Кандидат продемонстрировал глубокое понимание React, TypeScript и архитектурных подходов, успешно справившись с вопросами о мемоизации, состоянии и серверных компонентах. Однако алгоритмическая задача вызвала трудности, что подчеркивает разрыв между практическими навыками и теоретической подготовкой в области структур данных.

Вопрос 1. Расскажи о своём опыте разработки и проектах, которые ты делал.

Таймкод: 00:02:15

Ответ собеседника: Правильный. Разработкой занимается около пяти лет, но серьёзно — с третьего курса. Коммерческого опыта нет. Реально увлёкся 7-8 месяцев назад. Реализовал полную ротацию JWT-токенов через Redis с использованием OAuth и OpenID Connect на NestJS с микросервисной архитектурой. Также разрабатывал киноплатформу на микросервисах с использованием Drizzle ORM вместо Prisma. Использовал ConfigModule с Redis для хранения конфигураций и Dependency Injection для их внедрения. Создал кастомный фильтр исключений для корректной обработки ошибок на уровне микросервисов.

Правильный ответ:

Ответ собеседника в целом структурирован и демонстрирует практический опыт, хотя и без коммерческого контекста. Для позиции Go-разработчика стоит обратить внимание на несколько аспектов, которые можно было бы усилить или переформулировать с привязкой к Go-экосистеме.

Ротация JWT-токенов через Redis

Это важный и нетривиальный паттерн безопасности. Полная ротация токенов подразумевает не просто выпуск access/refresh пар, но и детектирование повторного использования refresh-токена (reuse detection), что является ключевым механизмом для предотвращения кражи токенов.

В контексте Go-разработки аналогичная реализация выглядела бы так:

package token

import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"time"

"github.com/redis/go-redis/v9"
)

type RotationService struct {
redis *redis.Client
accessTTL time.Duration
refreshTTL time.Duration
}

type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}

func (s *RotationService) Rotate(ctx context.Context, oldRefreshToken string) (*TokenPair, error) {
// Проверяем, не был ли токен уже использован (reuse detection)
exists, err := s.redis.Exists(ctx, "refresh:"+oldRefreshToken).Result()
if err != nil {
return nil, fmt.Errorf("redis check failed: %w", err)
}

if exists == 0 {
// Токен уже использован или истёк — возможная кража
// Инвалидируем всю семейство токенов для данного пользователя
userID, _ := s.redis.Get(ctx, "refresh_owner:"+oldRefreshToken).Result()
if userID != "" {
s.redis.Del(ctx, "token_family:"+userID)
}
return nil, fmt.Errorf("refresh token reuse detected — possible theft")
}

// Удаляем старый refresh-токен (single use)
s.redis.Del(ctx, "refresh:"+oldRefreshToken)

// Генерируем новую пару
newPair := &TokenPair{
AccessToken: generateSecureToken(32),
RefreshToken: generateSecureToken(64),
}

// Сохраняем новый refresh-токен с TTL
pipe := s.redis.Pipeline()
pipe.Set(ctx, "refresh:"+newPair.RefreshToken, "valid", s.refreshTTL)
pipe.Set(ctx, "refresh_owner:"+newPair.RefreshToken, extractUserID(oldRefreshToken), s.refreshTTL)
_, err = pipe.Exec(ctx)
if err != nil {
return nil, fmt.Errorf("failed to store new tokens: %w", err)
}

return newPair, nil
}

func generateSecureToken(length int) string {
bytes := make([]byte, length)
rand.Read(bytes)
return hex.EncodeToString(bytes)
}

Ключевые моменты реализации:

  • Reuse detection — если refresh-токен использован повторно, это сигнализирует о компрометации, и вся цепочка токенов пользователя инвалидируется.
  • Single use refresh tokens — каждый refresh-токен валиден только для однократного использования.
  • Token family — все токены, выданные в рамках одной сессии, связаны в цепочку, что позволяет обнаружить параллельное использование.

Микросервисная архитектура и обработка ошибок

Создание кастомного фильтра исключений — правильный подход. В Go аналогом является middleware для централизованной обработки ошибок:

package middleware

import (
"encoding/json"
"log"
"net/http"
"time"
)

type ErrorResponse struct {
Status int `json:"status"`
Error string `json:"error"`
Message string `json:"message"`
Timestamp time.Time `json:"timestamp"`
TraceID string `json:"trace_id,omitempty"`
}

type AppError struct {
StatusCode int
Message string
Internal error
}

func (e *AppError) Error() string {
return e.Message
}

func ErrorHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
traceID := r.Context().Value("trace_id").(string)
log.Printf("[PANIC] trace_id=%s error=%v", traceID, rec)

respondWithError(w, http.StatusInternalServerError, "Internal Server Error", traceID)
}
}()

next.ServeHTTP(w, r)
})
}

func respondWithError(w http.ResponseWriter, status int, message string, traceID string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(ErrorResponse{
Status: status,
Error: http.StatusText(status),
Message: message,
Timestamp: time.Now().UTC(),
TraceID: traceID,
})
}

Хранение конфигураций в Redis

Использование внешнего хранилища конфигураций — это паттерн, хорошо известный в распределённых системах. В Go-экосистеме для этого часто применяют etcd, Consul или Redis с механизмом pub/sub для уведомления сервисов об изменениях конфигурации в реальном времени.

Рекомендации для развития в Go

Для перехода на Go с Node.js/NestJS стоит обратить внимание на:

  • Встроенную конкурентность Go (goroutines, channels) как основной инструмент для параллельной обработки запросов в микросервисах.
  • Стандартную библиотеку net/http и фреймворки вроде chi, fiber или echo для построения HTTP-сервисов.
  • Паттерны graceful shutdown, health checks и circuit breaker, которые критически важны для микросервисной архитектуры.
  • Использование context.Context для управления временем жизни запросов, отмены операций и проброса метаданных (trace_id, span_id) между сервисами.

Вопрос 2. Как устроена микросервисная архитектура в твоём проекте и как сервисы общаются между собой?

Таймкод: 00:06:48

Ответ собеседника: Правильный. Реализовал отдельный config-сервис с ConfigModule. Порты и настройки хранились в Redis (порт 6379) — признал, что это плохая практика, но это был эксперимент. Через ConfigService получал опции для каждого микросервиса и внедрял их через Dependency Injection. Все микросервисы общались через общий инстанс Redis. Также создал фильтр исключений для микросервисов, чтобы ошибки были видны на клиенте, а не возвращались как непрозрачные ответы.

Правильный ответ:

Ответ демонстрирует понимание базовых принципов, но содержит архитектурные решения, которые действительно являются антипаттернами в продакшене. Разберём подробно.

Хранение конфигураций в Redis — почему это плохая практика

Использование Redis для хранения портов и настроек сервисов — это антипаттерн по нескольким причинам:

  • Redis — это in-memory хранилище, и при его падении все сервисы теряют конфигурацию.
  • Нет версионирования конфигураций — невозможно откатиться к предыдущему состоянию.
  • Нет валидации схемы конфигурации при записи.
  • Смешение ответственностей: Redis для кэширования и сессий, а не для конфигурации.

Правильные подходы для управления конфигурацией в микросервисах:

  • Consul / etcd / ZooKeeper — распределённые хранилища ключ-значение с механизмами watch, обеспечивающие согласованность и высокую доступность.
  • HashiCorp Vault — для секретов и чувствительных данных с ротацией и аудитом.
  • Kubernetes ConfigMaps и Secrets — если инфраструктура работает в K8s.
  • Spring Cloud Config / Consul Config — в Java-экосистеме.
  • Файлы окружения + переменные среды — самый простой и надёжный подход, рекомендованный манифестом 12-factor app.

В Go-экосистеме типичный подход с Consul:

package config

import (
"context"
"fmt"
"log"
"time"

"github.com/hashicorp/consul/api"
)

type ConsulConfig struct {
client *api.Client
prefix string
}

func NewConsulConfig(address, prefix string) (*ConsulConfig, error) {
cfg := api.DefaultConfig()
cfg.Address = address

client, err := api.NewClient(cfg)
if err != nil {
return nil, fmt.Errorf("failed to create consul client: %w", err)
}

return &ConsulConfig{
client: client,
prefix: prefix,
}, nil
}

func (c *ConsulConfig) GetString(ctx context.Context, key string) (string, error) {
pair, _, err := c.client.KV().Get(c.prefix+"/"+key, nil)
if err != nil {
return "", fmt.Errorf("failed to get config key %s: %w", key, err)
}
if pair == nil {
return "", fmt.Errorf("config key %s not found", key)
}
return string(pair.Value), nil
}

func (c *ConsulConfig) Watch(ctx context.Context, key string, onChange func(string)) {
var lastIndex uint64

for {
select {
case <-ctx.Done():
return
default:
}

pair, meta, err := c.client.KV().Get(c.prefix+"/"+key, &api.QueryOptions{
WaitIndex: lastIndex,
WaitTime: 30 * time.Second,
})
if err != nil {
log.Printf("consul watch error: %v", err)
time.Sleep(5 * time.Second)
continue
}

if meta.LastIndex != lastIndex && pair != nil {
lastIndex = meta.LastIndex
onChange(string(pair.Value))
}
}
}

Общение сервисов через общий Redis — проблемы

Использование общего Redis для межсервисного взаимодействия — это антипаттерн, который создаёт:

  • Точку отказа (SPOF) — при падении Redis все сервисы теряют связность.
  • Отсутствие контрактов — нет строгого API между сервисами, формат сообщений не стандартизирован.
  • Проблемы с трассировкой — невозможно отследить цепочку вызовов через общий кэш.
  • Смешение паттернов — Redis используется одновременно как кэш, брокер сообщений и хранилище конфигураций.

Правильные паттерны межсервисного взаимодействия:

Синхронное взаимодействие:

package client

import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"

"github.com/sony/gobreaker"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)

type ServiceClient struct {
baseURL string
httpClient *http.Client
breaker *gobreaker.CircuitBreaker
}

func NewServiceClient(baseURL string) *ServiceClient {
settings := gobreaker.Settings{
Name: "user-service",
MaxRequests: 3,
Interval: 10 * time.Second,
Timeout: 30 * time.Second,
ReadyToTrip: func(counts gobreaker.Counts) bool {
failureRatio := float64(counts.TotalFailures) / float64(counts.Requests)
return counts.Requests >= 3 && failureRatio >= 0.6
},
}

return &ServiceClient{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 5 * time.Second,
Transport: otelhttp.NewTransport(http.DefaultTransport),
},
breaker: gobreaker.NewCircuitBreaker(settings),
}
}

func (c *ServiceClient) GetUser(ctx context.Context, userID string) (*User, error) {
result, err := c.breaker.Execute(func() (interface{}, error) {
url := fmt.Sprintf("%s/api/users/%s", c.baseURL, userID)

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
}

var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}

return &user, nil
})

if err != nil {
return nil, err
}

return result.(*User), nil
}

Асинхронное взаимодействие через брокер сообщений:

package messaging

import (
"context"
"encoding/json"
"fmt"
"time"

"github.com/nats-io/nats.go"
)

type EventBus struct {
conn *nats.Conn
js nats.JetStreamContext
subjectPrefix string
}

type Event struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
Timestamp time.Time `json:"timestamp"`
TraceID string `json:"trace_id"`
}

func NewEventBus(url, prefix string) (*EventBus, error) {
nc, err := nats.Connect(url,
nats.RetryOnFailedConnect(true),
nats.MaxReconnects(10),
nats.ReconnectWait(time.Second),
)
if err != nil {
return nil, fmt.Errorf("failed to connect to NATS: %w", err)
}

js, err := nc.JetStream()
if err != nil {
return nil, fmt.Errorf("failed to create JetStream context: %w", err)
}

// Создаём поток событий
_, err = js.AddStream(&nats.StreamConfig{
Name: "EVENTS",
Subjects: []string{prefix + ".>"},
Retention: nats.WorkQueuePolicy,
MaxMsgs: 100000,
})
if err != nil {
return nil, fmt.Errorf("failed to create stream: %w", err)
}

return &EventBus{
conn: nc,
js: js,
subjectPrefix: prefix,
}, nil
}

func (eb *EventBus) Publish(ctx context.Context, eventType string, payload interface{}) error {
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}

event := Event{
Type: eventType,
Payload: data,
Timestamp: time.Now().UTC(),
TraceID: getTraceID(ctx),
}

eventData, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("failed to marshal event: %w", err)
}

subject := fmt.Sprintf("%s.%s", eb.subjectPrefix, eventType)

_, err = eb.js.Publish(subject, eventData, nats.Context(ctx))
if err != nil {
return fmt.Errorf("failed to publish event: %w", err)
}

return nil
}

func (eb *EventBus) Subscribe(eventType string, handler func(Event) error) error {
subject := fmt.Sprintf("%s.%s", eb.subjectPrefix, eventType)
consumerName := fmt.Sprintf("consumer-%s", eventType)

_, err := eb.js.AddConsumer("EVENTS", &nats.ConsumerConfig{
Durable: consumerName,
AckPolicy: nats.AckExplicitPolicy,
MaxDeliver: 3,
})
if err != nil {
return fmt.Errorf("failed to create consumer: %w", err)
}

_, err = eb.js.PullSubscribe(subject, consumerName, nats.Bind("EVENTS", consumerName))
if err != nil {
return fmt.Errorf("failed to subscribe: %w", err)
}

go func() {
for {
msgs, _ := eb.js.PullSubscribe(subject, consumerName).Fetch(10)
for _, msg := range msgs {
var event Event
if err := json.Unmarshal(msg.Data, &event); err != nil {
msg.Nak()
continue
}

if err := handler(event); err != nil {
msg.Nak()
continue
}

msg.Ack()
}
}
}()

return nil
}

Централизованная обработка ошибок в микросервисах

Создание единого формата ошибок — это правильный подход. В Go это реализуется через интерфейс error и стандартизированные HTTP-ответы:

package errors

import (
"encoding/json"
"fmt"
"net/http"
)

type ErrorCode string

const (
ErrCodeValidation ErrorCode = "VALIDATION_ERROR"
ErrCodeNotFound ErrorCode = "NOT_FOUND"
ErrCodeUnauthorized ErrorCode = "UNAUTHORIZED"
ErrCodeInternal ErrorCode = "INTERNAL_ERROR"
ErrCodeUnavailable ErrorCode = "SERVICE_UNAVAILABLE"
)

type AppError struct {
Code ErrorCode `json:"code"`
Message string `json:"message"`
Details string `json:"details,omitempty"`
StatusCode int `json:"-"`
Internal error `json:"-"`
}

func (e *AppError) Error() string {
if e.Internal != nil {
return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Internal)
}
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}

func (e *AppError) Unwrap() error {
return e.Internal
}

func NewValidationError(details string) *AppError {
return &AppError{
Code: ErrCodeValidation,
Message: "Validation failed",
Details: details,
StatusCode: http.StatusBadRequest,
}
}

func NewNotFoundError(resource string) *AppError {
return &AppError{
Code: ErrCodeNotFound,
Message: fmt.Sprintf("%s not found", resource),
StatusCode: http.StatusNotFound,
}
}

func NewInternalError(err error) *AppError {
return &AppError{
Code: ErrCodeInternal,
Message: "Internal server error",
StatusCode: http.StatusInternalServerError,
Internal: err,
}
}

type ErrorResponse struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
Details string `json:"details,omitempty"`
} `json:"error"`
}

func WriteError(w http.ResponseWriter, err error) {
var appErr *AppError

if errors.As(err, &appErr) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(appErr.StatusCode)

response := ErrorResponse{}
response.Error.Code = string(appErr.Code)
response.Error.Message = appErr.Message
response.Error.Details = appErr.Details

json.NewEncoder(w).Encode(response)
return
}

// Неизвестная ошибка — не раскрываем детали клиенту
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)

response := ErrorResponse{}
response.Error.Code = string(ErrCodeInternal)
response.Error.Message = "Internal server error"

json.NewEncoder(w).Encode(response)
}

Рекомендуемая архитектура взаимодействия

Для микросервисной архитектуры рекомендуется следующий подход:

  • Синхронное взаимодействие — gRPC для внутренней коммуникации (высокая производительность, строгие контракты через Protocol Buffers), REST для внешних API.
  • Асинхронное взаимодействие — NATS JetStream или Apache Kafka для event-driven архитектуры.
  • Service mesh — Istio или Linkerd для управления трафиком, mTLS, трассировки без изменения кода сервисов.
  • Конфигурация — Consul/etcd для динамической конфигурации, Vault для секретов.
  • Observability — OpenTelemetry для трассировки, Prometheus для метрик, Grafana для визуализации, ELK/Loki для логов.

Вопрос 3. Какая самая сложная задача, с которой ты сталкивался?

Таймкод: 00:08:48

Ответ собеседника: Правильный. Самой сложной оказалась задача с ротацией JWT-токенов — при обновлении страницы на фронтенде не обновлялся истёкший токен. Проблема заключалась в том, что интерцепторы обрабатывали 401-ответ и кастомные ошибки от JWT на неправильном уровне. Также отметил, что работа с OAuth была достаточно сложной.

Правильный ответ:

Проблема с ротацией JWT-токенов при обновлении страницы — это классическая и действительно нетривиальная задача. Разберём её глубже и покажем, как она решается на уровне архитектуры.

Суть проблемы

При обновлении страницы (F5 / Ctrl+R) фронтер теряет состояние в памяти, включая текущий access-токен. Если access-токен истёк, а refresh-токен ещё валиден, нужно выполнить тихий рефреш до того, как приложение начнёт рендериться. Проблема усугубляется, когда несколько параллельных запросов одновременно получают 401 — все они пытаются обновить токен, что приводит к race condition.

Архитектура решения на фронтенде

Правильный подход — разделение ответственностей между слоями:

  • HTTP-клиент — перехватывает 401, ставит запрос в очередь, инициирует рефреш.
  • Auth-сервис — управляет состоянием токенов, выполняет ротацию.
  • Хранилище — персистентное хранение refresh-токена (httpOnly cookie или secure storage).

Реализация на Go (backend-часть с полной ротацией)

package auth

import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"

"github.com/redis/go-redis/v9"
"golang.org/x/crypto/argon2"
)

// TokenManager управляет жизненным циклом JWT-токенов
type TokenManager struct {
redis *redis.Client
accessTTL time.Duration
refreshTTL time.Duration
issuer string
mu sync.Mutex
}

type TokenFamily struct {
UserID string `json:"user_id"`
FamilyID string `json:"family_id"`
CreatedAt time.Time `json:"created_at"`
Revoked bool `json:"revoked"`
}

type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}

// GenerateTokenPair создаёт новую пару токенов для пользователя
func (tm *TokenManager) GenerateTokenPair(ctx context.Context, userID string) (*TokenPair, error) {
familyID := generateSecureID(16)
refreshToken := generateSecureID(64)

family := TokenFamily{
UserID: userID,
FamilyID: familyID,
CreatedAt: time.Now().UTC(),
Revoked: false,
}

familyData, err := json.Marshal(family)
if err != nil {
return nil, fmt.Errorf("failed to marshal token family: %w", err)
}

pipe := tm.redis.Pipeline()

// Сохраняем семейство токенов
pipe.Set(ctx,
fmt.Sprintf("token_family:%s", familyID),
familyData,
tm.refreshTTL,
)

// Связываем refresh-токен с семейством
pipe.Set(ctx,
fmt.Sprintf("refresh:%s", refreshToken),
familyID,
tm.refreshTTL,
)

// Связываем пользователя с активным семейством
pipe.Set(ctx,
fmt.Sprintf("user_family:%s", userID),
familyID,
tm.refreshTTL,
)

_, err = pipe.Exec(ctx)
if err != nil {
return nil, fmt.Errorf("failed to store tokens: %w", err)
}

// Генерируем access-токен (в реальности — подпись JWT)
accessToken := generateSecureID(32)

// Сохраняем access-токен с коротким TTL
pipe2 := tm.redis.Pipeline()
pipe2.Set(ctx,
fmt.Sprintf("access:%s", accessToken),
userID,
tm.accessTTL,
)
pipe2.Set(ctx,
fmt.Sprintf("access_family:%s", accessToken),
familyID,
tm.accessTTL,
)
_, err = pipe2.Exec(ctx)
if err != nil {
return nil, fmt.Errorf("failed to store access token: %w", err)
}

return &TokenPair{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: int(tm.accessTTL.Seconds()),
TokenType: "Bearer",
}, nil
}

// RotateTokens выполняет ротацию токенов с обнаружением повторного использования
func (tm *TokenManager) RotateTokens(ctx context.Context, oldRefreshToken string) (*TokenPair, error) {
tm.mu.Lock()
defer tm.mu.Unlock()

// Получаем семейство, связанное с refresh-токеном
familyID, err := tm.redis.Get(ctx, fmt.Sprintf("refresh:%s", oldRefreshToken)).Result()
if err == redis.Nil {
// Токен не найден — возможно, уже использован или истёк
return nil, &AuthError{
Code: "TOKEN_REUSE_DETECTED",
Message: "Refresh token has been used or expired. Possible token theft.",
Status: http.StatusUnauthorized,
}
}
if err != nil {
return nil, fmt.Errorf("redis error: %w", err)
}

// Проверяем статус семейства
familyData, err := tm.redis.Get(ctx, fmt.Sprintf("token_family:%s", familyID)).Result()
if err != nil {
return nil, fmt.Errorf("failed to get token family: %w", err)
}

var family TokenFamily
if err := json.Unmarshal([]byte(familyData), &family); err != nil {
return nil, fmt.Errorf("failed to unmarshal token family: %w", err)
}

if family.Revoked {
return nil, &AuthError{
Code: "FAMILY_REVOKED",
Message: "Token family has been revoked due to suspected compromise.",
Status: http.StatusUnauthorized,
}
}

// Удаляем старый refresh-токен (single use)
tm.redis.Del(ctx, fmt.Sprintf("refresh:%s", oldRefreshToken))

// Генерируем новую пару в том же семействе
newRefreshToken := generateSecureID(64)

pipe := tm.redis.Pipeline()

// Связываем новый refresh-токен с тем же семейством
pipe.Set(ctx,
fmt.Sprintf("refresh:%s", newRefreshToken),
familyID,
tm.refreshTTL,
)

// Обновляем время последней активности семейства
family.CreatedAt = time.Now().UTC()
updatedFamilyData, _ := json.Marshal(family)
pipe.Set(ctx,
fmt.Sprintf("token_family:%s", familyID),
updatedFamilyData,
tm.refreshTTL,
)

_, err = pipe.Exec(ctx)
if err != nil {
return nil, fmt.Errorf("failed to store new tokens: %w", err)
}

// Генерируем новый access-токен
newAccessToken := generateSecureID(32)

pipe2 := tm.redis.Pipeline()
pipe2.Set(ctx,
fmt.Sprintf("access:%s", newAccessToken),
family.UserID,
tm.accessTTL,
)
pipe2.Set(ctx,
fmt.Sprintf("access_family:%s", newAccessToken),
familyID,
tm.accessTTL,
)
_, err = pipe2.Exec(ctx)
if err != nil {
return nil, fmt.Errorf("failed to store new access token: %w", err)
}

return &TokenPair{
AccessToken: newAccessToken,
RefreshToken: newRefreshToken,
ExpiresIn: int(tm.accessTTL.Seconds()),
TokenType: "Bearer",
}, nil
}

// RevokeFamily отзывает всё семейство токенов (при обнаружении компрометации)
func (tm *TokenManager) RevokeFamily(ctx context.Context, familyID string) error {
familyData, err := tm.redis.Get(ctx, fmt.Sprintf("token_family:%s", familyID)).Result()
if err != nil {
return fmt.Errorf("failed to get token family: %w", err)
}

var family TokenFamily
if err := json.Unmarshal([]byte(familyData), &family); err != nil {
return fmt.Errorf("failed to unmarshal token family: %w", err)
}

family.Revoked = true
updatedData, _ := json.Marshal(family)

// Помечаем семейство как отозванное
tm.redis.Set(ctx,
fmt.Sprintf("token_family:%s", familyID),
updatedData,
tm.refreshTTL,
)

// Удаляем связь пользователя с семейством
tm.redis.Del(ctx, fmt.Sprintf("user_family:%s", family.UserID))

return nil
}

// ValidateAccessToken проверяет валидность access-токена
func (tm *TokenManager) ValidateAccessToken(ctx context.Context, accessToken string) (string, error) {
userID, err := tm.redis.Get(ctx, fmt.Sprintf("access:%s", accessToken)).Result()
if err == redis.Nil {
return "", &AuthError{
Code: "TOKEN_EXPIRED",
Message: "Access token has expired",
Status: http.StatusUnauthorized,
}
}
if err != nil {
return "", fmt.Errorf("redis error: %w", err)
}

// Проверяем, не отозвано ли семейство
familyID, err := tm.redis.Get(ctx, fmt.Sprintf("access_family:%s", accessToken)).Result()
if err == nil {
familyData, err := tm.redis.Get(ctx, fmt.Sprintf("token_family:%s", familyID)).Result()
if err == nil {
var family TokenFamily
if json.Unmarshal([]byte(familyData), &family) == nil && family.Revoked {
return "", &AuthError{
Code: "TOKEN_REVOKED",
Message: "Token has been revoked",
Status: http.StatusUnauthorized,
}
}
}
}

return userID, nil
}

// AuthError — типизированная ошибка аутентификации
type AuthError struct {
Code string
Message string
Status int
}

func (e *AuthError) Error() string {
return e.Message
}

func generateSecureID(length int) string {
bytes := make([]byte, length)
rand.Read(bytes)
return base64.RawURLEncoding.EncodeToString(bytes)
}

Middleware для проверки токенов в Go

package middleware

import (
"context"
"net/http"
"strings"

"yourapp/auth"
)

type contextKey string

const UserIDKey contextKey = "user_id"

func AuthMiddleware(tm *auth.TokenManager) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized)
return
}

parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
http.Error(w, `{"error":"invalid authorization format"}`, http.StatusUnauthorized)
return
}

accessToken := parts[1]

userID, err := tm.ValidateAccessToken(r.Context(), accessToken)
if err != nil {
if authErr, ok := err.(*auth.AuthError); ok {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(authErr.Status)
w.Write([]byte(fmt.Sprintf(`{"error":"%s","code":"%s"}`, authErr.Message, authErr.Code)))
return
}
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
return
}

ctx := context.WithValue(r.Context(), UserIDKey, userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

Проблема с интерцепторами и её решение

Проблема, описанная собеседником, типична: когда интерцепторы обрабатывают 401 на неправильном уровне, возникает бесконечный цикл или потеря контекста ошибки. Правильная архитектура обработки:

  • Уровень HTTP-клиента — перехват 401, постановка запросов в очередь, вызов рефреша.
  • Уровень Auth-сервиса — управление состоянием, ротация, обработка ошибок.
  • Уровень UI — редирект на логин при фатальных ошибках.

Ключевые принципы:

  • Очередь запросов — при получении 401 все последующие запросы ставятся в очередь до завершения рефреша.
  • Единый рефреш — только один запрос на обновление токенов в любой момент времени.
  • Обработка конкурентных 401 — если несколько запросов получили 401 одновременно, все кроме первого ждут результата.
  • Graceful degradation — при невозможности рефреша — редирект на страницу логина с сохранением исходного URL.

OAuth 2.0 и OpenID Connect — почему это сложно

Сложность OAuth/OIDC заключается в:

  • Множестве grant types — authorization code, implicit, client credentials, device code, PKCE — каждый со своими нюансами безопасности.
  • State management — корректная обработка state и nonce параметров для предотвращения CSRF и replay-атак.
  • Token introspection — проверка токенов у провайдера, кэширование результатов.
  • JWKS (JSON Web Key Set) — ротация ключей подписи, кэширование, обработка kid.
  • Session management — корректный logout, back-channel logout, front-channel logout.

Вопрос 4. Как ты работаешь с ошибками в коде, какие инструменты отладки используешь?

Таймкод: 00:10:02

Ответ собеседника: Неполный. На бэкенде использует try-catch и конфигурации, но признал, что это долго. Для более быстрой отладки использует тесты. При этом отметил, что является новичком в написании тестов и имеет небольшой опыт в этой области.

Правильный ответ:

Ответ собеседника поверхностный и не раскрывает системный подход к отладке. Для Go-разработчика важно знать полный набор инструментов и практик. Разберём подробно.

Философия обработки ошибок в Go

В Go ошибки — это значения, а не исключения. Это принципиально отличается от try-catch в других языках. Ключевые принципы:

  • Явная проверка ошибок на каждом вызове, который может вернуть ошибку.
  • Оборачивание ошибок с контекстом через fmt.Errorf с глаголом %w.
  • Использование errors.Is и errors.As для проверки типов ошибок.
  • Паника только для неисправимых ошибок (programmer errors), а не для ожидаемых сбоев.

Системный подход к обработке ошибок

package errors

import (
"errors"
"fmt"
)

// Определяем типизированные ошибки для разных слоёв приложения

// Domain errors
var (
ErrNotFound = errors.New("resource not found")
ErrUnauthorized = errors.New("unauthorized")
ErrValidation = errors.New("validation failed")
ErrConflict = errors.New("resource conflict")
)

// AppError — структурированная ошибка приложения
type AppError struct {
Code string `json:"code"`
Message string `json:"message"`
Op string `json:"op,omitempty"` // операция, в которой произошла ошибка
Err error `json:"-"` // внутренняя ошибка
StatusCode int `json:"-"`
}

func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %s: %v", e.Op, e.Code, e.Err)
}
return fmt.Sprintf("%s: %s", e.Op, e.Code)
}

func (e *AppError) Unwrap() error {
return e.Err
}

// Конструкторы ошибок с контекстом
func E(op string, code string, err error, statusCode int) *AppError {
return &AppError{
Op: op,
Code: code,
Err: err,
StatusCode: statusCode,
}
}

// Проверка типа ошибки
func IsNotFound(err error) bool {
var appErr *AppError
if errors.As(err, &appErr) {
return errors.Is(appErr.Err, ErrNotFound)
}
return errors.Is(err, ErrNotFound)
}

func IsValidation(err error) bool {
var appErr *AppError
if errors.As(err, &appErr) {
return errors.Is(appErr.Err, ErrValidation)
}
return errors.Is(err, ErrValidation)
}

Использование в слоях приложения

package service

import (
"context"
"database/sql"
"fmt"

"yourapp/errors"
"yourapp/models"
"yourapp/repository"
)

type UserService struct {
repo repository.UserRepository
}

func NewUserService(repo repository.UserRepository) *UserService {
return &UserService{repo: repo}
}

func (s *UserService) GetUser(ctx context.Context, id string) (*models.User, error) {
user, err := s.repo.FindByID(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// Оборачиваем ошибку с контекстом операции
return nil, errors.E(
"UserService.GetUser",
"USER_NOT_FOUND",
fmt.Errorf("user with id %s not found", id),
404,
)
}
// Оборачиваем неизвестную ошибку
return nil, errors.E(
"UserService.GetUser",
"INTERNAL_ERROR",
fmt.Errorf("failed to fetch user: %w", err),
500,
)
}
return user, nil
}

func (s *UserService) CreateUser(ctx context.Context, input CreateUserInput) (*models.User, error) {
// Валидация входных данных
if err := input.Validate(); err != nil {
return nil, errors.E(
"UserService.CreateUser",
"VALIDATION_ERROR",
err,
400,
)
}

// Проверка уникальности
existing, err := s.repo.FindByEmail(ctx, input.Email)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, errors.E(
"UserService.CreateUser",
"INTERNAL_ERROR",
fmt.Errorf("failed to check email uniqueness: %w", err),
500,
)
}
if existing != nil {
return nil, errors.E(
"UserService.CreateUser",
"DUPLICATE_EMAIL",
fmt.Errorf("email %s already registered", input.Email),
409,
)
}

user := &models.User{
Email: input.Email,
Name: input.Name,
}

if err := s.repo.Create(ctx, user); err != nil {
return nil, errors.E(
"UserService.CreateUser",
"INTERNAL_ERROR",
fmt.Errorf("failed to create user: %w", err),
500,
)
}

return user, nil
}

Инструменты отладки в Go

1. Delve — основной отладчик Go

# Установка
go install github.com/go-delve/delve/cmd/dlv@latest

# Отладка программы
dlv debug ./cmd/server

# Отладка тестов
dlv test ./internal/service

# Присоединение к запущенному процессу
dlv attach <pid>

# Отладка с бинарника (для продакшен-подобной среды)
dlv exec ./bin/server

Основные команды Delve:

# Установка брейкпоинтов
(dlv) break main.go:42
(dlv) break UserService.GetUser
(dlv) break repository.FindByID if userID == "123"

# Навигация
(dlv) continue # продолжить выполнение
(dlv) next # следующая строка (step over)
(dlv) step # шаг внутрь функции (step in)
(dlv) stepout # выйти из текущей функции
(dlv) restart # перезапустить программу

# Инспекция
(dlv) print userID # значение переменной
(dlv) locals # все локальные переменные
(dlv) args # аргументы функции
(dlv) goroutines # список горутин
(dlv) stack # стек вызовов
(dlv) vars # глобальные переменные

# Условные брейкпоинты
(dlv) condition 1 err != nil

2. pprof — профилирование

package main

import (
"net/http"
_ "net/http/pprof"
"runtime"
)

func main() {
// Настройка лимитов для профилирования
runtime.SetBlockProfileRate(1)
runtime.SetMutexProfileFraction(1)

// pprof доступен на /debug/pprof/
go func() {
http.ListenAndServe("localhost:6060", nil)
}()

// ... основной код приложения
}

Использование pprof:

# Профиль CPU (30 секунд)
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30

# Профиль кучи
go tool pprof http://localhost:6060/debug/pprof/heap

# Профиль горутин
go tool pprof http://localhost:6060/debug/pprof/goroutine

# Профиль блокировок
go tool pprof http://localhost:6060/debug/pprof/block

# Интерактивный режим pprof
(pprof) top # топ функций по потреблению
(pprof) list UserService.GetUser # исходный код функции
(pprof) web # граф вызовов в браузере
(pprof) png # экспорт в PNG

3. trace — трассировка выполнения

package main

import (
"os"
"runtime/trace"
)

func main() {
f, err := os.Create("trace.out")
if err != nil {
panic(err)
}
defer f.Close()

if err := trace.Start(f); err != nil {
panic(err)
}
defer trace.Stop()

// ... основной код
}
# Анализ трассировки
go tool trace trace.out

4. Structured logging с zap

package logger

import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)

var log *zap.Logger

func Init(env string) error {
var config zap.Config

if env == "production" {
config = zap.NewProductionConfig()
config.EncoderConfig.TimeKey = "timestamp"
config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
} else {
config = zap.NewDevelopmentConfig()
config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
}

var err error
log, err = config.Build(
zap.AddCaller(),
zap.AddStacktrace(zapcore.ErrorLevel),
)
return err
}

func Get() *zap.Logger {
return log
}

// Использование в сервисах
func (s *UserService) GetUser(ctx context.Context, id string) (*models.User, error) {
logger := Get().With(
zap.String("operation", "GetUser"),
zap.String("user_id", id),
zap.String("trace_id", getTraceID(ctx)),
)

logger.Info("fetching user")

user, err := s.repo.FindByID(ctx, id)
if err != nil {
logger.Error("failed to fetch user",
zap.Error(err),
zap.String("error_type", fmt.Sprintf("%T", err)),
)
return nil, err
}

logger.Info("user fetched successfully",
zap.String("user_email", user.Email),
)
return user, nil
}

5. Тестирование как инструмент отладки

package service

import (
"context"
"database/sql"
"errors"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)

// Мок репозитория
type MockUserRepository struct {
mock.Mock
}

func (m *MockUserRepository) FindByID(ctx context.Context, id string) (*models.User, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.User), args.Error(1)
}

func (m *MockUserRepository) FindByEmail(ctx context.Context, email string) (*models.User, error) {
args := m.Called(ctx, email)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.User), args.Error(1)
}

func (m *MockUserRepository) Create(ctx context.Context, user *models.User) error {
args := m.Called(ctx, user)
return args.Error(0)
}

func TestUserService_GetUser_Success(t *testing.T) {
mockRepo := new(MockUserRepository)
svc := NewUserService(mockRepo)

expectedUser := &models.User{
ID: "user-123",
Email: "test@example.com",
Name: "Test User",
}

mockRepo.On("FindByID", mock.Anything, "user-123").Return(expectedUser, nil)

user, err := svc.GetUser(context.Background(), "user-123")

require.NoError(t, err)
assert.Equal(t, expectedUser.ID, user.ID)
assert.Equal(t, expectedUser.Email, user.Email)
mockRepo.AssertExpectations(t)
}

func TestUserService_GetUser_NotFound(t *testing.T) {
mockRepo := new(MockUserRepository)
svc := NewUserService(mockRepo)

mockRepo.On("FindByID", mock.Anything, "nonexistent").Return(nil, sql.ErrNoRows)

user, err := svc.GetUser(context.Background(), "nonexistent")

require.Error(t, err)
assert.Nil(t, user)
assert.True(t, errors.Is(err, sql.ErrNoRows))
mockRepo.AssertExpectations(t)
}

func TestUserService_CreateUser_DuplicateEmail(t *testing.T) {
mockRepo := new(MockUserRepository)
svc := NewUserService(mockRepo)

existingUser := &models.User{
ID: "existing-123",
Email: "test@example.com",
}

mockRepo.On("FindByEmail", mock.Anything, "test@example.com").Return(existingUser, nil)

input := CreateUserInput{
Email: "test@example.com",
Name: "Test User",
}

user, err := svc.CreateUser(context.Background(), input)

require.Error(t, err)
assert.Nil(t, user)
assert.Contains(t, err.Error(), "DUPLICATE_EMAIL")
mockRepo.AssertExpectations(t)
}

// Бенчмарки для выявления проблем производительности
func BenchmarkUserService_GetUser(b *testing.B) {
mockRepo := new(MockUserRepository)
svc := NewUserService(mockRepo)

mockRepo.On("FindByID", mock.Anything, "user-123").Return(&models.User{
ID: "user-123",
Email: "test@example.com",
}, nil)

ctx := context.Background()

b.ResetTimer()
b.ReportAllocs()

for i := 0; i < b.N; i++ {
_, _ = svc.GetUser(ctx, "user-123")
}
}

6. Табличные тесты для исчерпывающей проверки

func TestUserService_CreateUser(t *testing.T) {
tests := []struct {
name string
input CreateUserInput
setupMock func(*MockUserRepository)
wantErr bool
errContains string
wantUser bool
}{
{
name: "success",
input: CreateUserInput{
Email: "new@example.com",
Name: "New User",
},
setupMock: func(m *MockUserRepository) {
m.On("FindByEmail", mock.Anything, "new@example.com").Return(nil, sql.ErrNoRows)
m.On("Create", mock.Anything, mock.AnythingOfType("*models.User")).Return(nil)
},
wantErr: false,
wantUser: true,
},
{
name: "duplicate email",
input: CreateUserInput{
Email: "existing@example.com",
Name: "Test User",
},
setupMock: func(m *MockUserRepository) {
m.On("FindByEmail", mock.Anything, "existing@example.com").Return(&models.User{ID: "123"}, nil)
},
wantErr: true,
errContains: "DUPLICATE_EMAIL",
},
{
name: "empty email",
input: CreateUserInput{
Email: "",
Name: "Test User",
},
setupMock: func(m *MockUserRepository) {},
wantErr: true,
errContains: "VALIDATION_ERROR",
},
{
name: "invalid email format",
input: CreateUserInput{
Email: "not-an-email",
Name: "Test User",
},
setupMock: func(m *MockUserRepository) {},
wantErr: true,
errContains: "VALIDATION_ERROR",
},
{
name: "database error on create",
input: CreateUserInput{
Email: "new@example.com",
Name: "New User",
},
setupMock: func(m *MockUserRepository) {
m.On("FindByEmail", mock.Anything, "new@example.com").Return(nil, sql.ErrNoRows)
m.On("Create", mock.Anything, mock.AnythingOfType("*models.User")).Return(errors.New("connection refused"))
},
wantErr: true,
errContains: "INTERNAL_ERROR",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockRepo := new(MockUserRepository)
tt.setupMock(mockRepo)
svc := NewUserService(mockRepo)

user, err := svc.CreateUser(context.Background(), tt.input)

if tt.wantErr {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errContains)
} else {
require.NoError(t, err)
}

if tt.wantUser {
assert.NotNil(t, user)
assert.Equal(t, tt.input.Email, user.Email)
} else {
assert.Nil(t, user)
}

mockRepo.AssertExpectations(t)
})
}
}

7. Интеграционные тесты с реальной БД

package integration

import (
"context"
"database/sql"
"testing"

_ "github.com/lib/pq"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
)

func TestUserRepository_Integration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}

ctx := context.Background()

// Запуск PostgreSQL в контейнере
pgContainer, err := postgres.RunContainer(ctx,
postgres.WithDatabase("testdb"),
postgres.WithUsername("testuser"),
postgres.WithPassword("testpass"),
postgres.WithInitScripts("testdata/init.sql"),
postgres.BasicWaitStrategies(),
)
require.NoError(t, err)
defer pgContainer.Terminate(ctx)

connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
require.NoError(t, err)

db, err := sql.Open("postgres", connStr)
require.NoError(t, err)
defer db.Close()

repo := repository.NewUserRepository(db)

t.Run("create and find user", func(t *testing.T) {
user := &models.User{
Email: "integration@test.com",
Name: "Integration Test",
}

err := repo.Create(ctx, user)
require.NoError(t, err)
require.NotEmpty(t, user.ID)

found, err := repo.FindByID(ctx, user.ID)
require.NoError(t, err)
require.Equal(t, user.Email, found.Email)
require.Equal(t, user.Name, found.Name)
})
}

Рекомендуемый набор инструментов для отладки

  • Delve — интерактивная отладка, брейкпоинты, инспекция состояния.
  • pprof — профилирование CPU, памяти, горутин, блокировок.
  • trace — визуализация выполнения, обнаружение contention.
  • zap/zerolog — структурированное логирование с контекстом.
  • OpenTelemetry — распределённая трассировка между сервисами.
  • testify + testcontainers — модульные и интеграционные тесты.
  • golangci-lint — статический анализ, обнаружение потенциальных багов.
  • go vet и go race — встроенные инструменты для поиска гонок данных.

Вопрос 5. Какие инструменты отладки используешь на фронтенде и бэкенде?

Таймкод: 00:10:39

Ответ собеседника: Правильный. На бэкенде использует try-catch и конфигурации, но признал, что это долго. Для более быстрой отладки применяет тесты, однако является новичком в написании тестов. На фронтенде использует дебаггер, DevTools для state management, React DevTools — отметил, что на фронтенде помощников значительно больше.

Правильный ответ:

Ответ собеседника слишком общий и не демонстрирует глубокого знания инструментов. Для Go-разработчика на позиции уровня middle+ и выше ожидается уверенное владение полным набором инструментов. Разберём подробно.

Инструменты отладки бэкенда на Go

1. Delve — основной отладчик

Delve — это полноценный отладчик Go, аналог GDB, но с пониманием специфики языка (горутины, каналы, интерфейсы).

# Установка
go install github.com/go-delve/delve/cmd/dlv@latest

# Отладка с текущей директории
dlv debug

# Отладка конкретного пакета с аргументами
dlv debug ./cmd/server -- --config=config.yaml

# Отладка тестов
dlv test ./internal/service/...

# Присоединение к запущенному процессу
dlv attach $(pgrep -f "myapp")

# Удалённая отладка (для Docker/Kubernetes)
dlv debug --headless --listen=:2345 --api-version=2 --accept-multiclient

Интерактивные команды Delve:

# Брейкпоинты
(dlv) break main.go:42 # по строке
(dlv) break handler.GetUser # по имени функции
(dlv) break repository.FindByID if id == "123" # условный
(dlv) cond 1 ctx.Value("trace_id") != "" # условие на существующий брейкпоинт
(dlv) clear 1 # удалить брейкпоинт
(dlv) breakpoints # список всех брейкпоинтов

# Навигация по коду
(dlv) continue # продолжить до следующего брейкпоинта
(dlv) next # следующая строка (step over — не заходить в функции)
(dlv) step # шаг внутрь функции (step in)
(dlv) stepout # выйти из текущей функции
(dlv) restart # перезапустить с начала

# Инспекция переменных
(dlv) print userID
(dlv) print req.Header
(dlv) print *user # разыменование указателя
(dlv) whatis err # тип переменной
(dlv) locals # все локальные переменные
(dlv) args # аргументы текущей функции

# Горутины и конкурентность
(dlv) goroutines # список всех горутин
(dlv) goroutine 15 # переключиться на горутину 15
(dlv) goroutine 15 stack # стек конкретной горутины
(dlv) stack # стек текущей горутины
(dlv) stack 20 # стек глубиной 20 фреймов

# Каналы
(dlv) print ch
(dlv) print len(ch)
(dlv) print cap(ch)

# Goroutine-specific брейкпоинты (для отладки гонок)
(dlv) break -g 15 handler.ProcessRequest

2. Логирование с контекстом через zap

package logger

import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)

var globalLogger *zap.SugaredLogger

func Init(env string) error {
var cfg zap.Config

if env == "production" {
cfg = zap.NewProductionConfig()
cfg.EncoderConfig.TimeKey = "ts"
cfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
cfg.OutputPaths = []string{"stdout"}
cfg.ErrorOutputPaths = []string{"stderr"}
} else {
cfg = zap.NewDevelopmentConfig()
cfg.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
}

baseLogger, err := cfg.Build(
zap.AddCallerSkip(1),
zap.AddStacktrace(zapcore.ErrorLevel),
)
if err != nil {
return err
}

globalLogger = baseLogger.Sugar()
return nil
}

func WithFields(fields ...zap.Field) *zap.SugaredLogger {
return globalLogger.Desugar().With(fields...).Sugar()
}

func WithContext(ctx context.Context) *zap.SugaredLogger {
traceID, _ := ctx.Value("trace_id").(spanID, _ := ctx.Value("span_id").(string)
return globalLogger.With(
zap.String("trace_id", traceID),
zap.String("span_id", spanID),
)
}

// Использование
func (s *UserService) GetUser(ctx context.Context, id string) (*models.User, error) {
log := logger.WithContext(ctx).With(zap.String("user_id", id))

log.Info("fetching user")

user, err := s.repo.FindByID(ctx, id)
if err != nil {
log.Error("failed to fetch user",
zap.Error(err),
zap.Duration("elapsed", time.Since(start)),
)
return nil, err
}

log.Info("user fetched", zap.String("email", user.Email))
return user, nil
}

3. pprof — профилирование в продакшене

package main

import (
"net/http"
_ "net/http/pprof"
"runtime"
"runtime/pprof"
"time"
)

func setupProfiling() {
// Включаем профилирование блокировок и мьютексов
runtime.SetBlockProfileRate(1) // профилировать все блокировки
runtime.SetMutexProfileFraction(10) // профилировать 10% contention-ов

// pprof endpoints доступны на /debug/pprof/
}

// Программное управление профилированием (для продакшена)
func startCPUProfile(duration time.Duration, outputPath string) error {
f, err := os.Create(outputPath)
if err != nil {
return err
}

if err := pprof.StartCPUProfile(f); err != nil {
f.Close()
return err
}

time.AfterFunc(duration, func() {
pprof.StopCPUProfile()
f.Close()
})

return nil
}

Анализ профилей:

# Захват профиля CPU на 30 секунд
curl -o cpu.prof http://localhost:6060/debug/pprof/profile?seconds=30

# Захват профиля кучи
curl -o heap.prof http://localhost:6060/debug/pprof/heap

# Захват профиля горутин
curl -o goroutines.prof http://localhost:6060/debug/pprof/goroutine

# Интерактивный анализ
go tool pprof -http=:8080 cpu.prof

# Команды в интерактивном режиме
(pprof) top20 # топ-20 функций по CPU
(pprof) top -cum # топ по кумулятивному времени
(pprof) list UserService.GetUser # исходный код с метриками
(pprof) web # SVG-граф в браузере
(pprof) traces # трейсы
(pprof) peek UserService # входящие/исходящие вызовы

# Сравнение двух профилей (до и после оптимизации)
go tool pprof -base before.prof after.prof

4. Трассировка выполнения

package main

import (
"context"
"fmt"
"runtime/trace"
)

func ProcessOrder(ctx context.Context, orderID string) error {
// Создаём трейс для конкретной операции
ctx, task := trace.NewTask(ctx, "ProcessOrder")
defer task.End()

// Логическая область внутри трейса
{
s := trace.StartSpan(ctx, "validate_order")
if err := validateOrder(orderID); err != nil {
trace.Logf(ctx, "error", "validation failed: %v", err)
s.End()
return err
}
s.End()
}

{
s := trace.StartSpan(ctx, "process_payment")
if err := processPayment(ctx, orderID); err != nil {
s.End()
return fmt.Errorf("payment failed: %w", err)
}
s.End()
}

return nil
}
# Анализ трейса
go tool trace trace.out

# В веб-интерфейсе trace:
# - View trace: временная шкала горутин, GC, системные вызовы
# - Goroutine analysis: распределение горутин по состоянияm
# - Network/Syscall blocking: блокировки на I/O
# - Scheduler latency: задержки планировщика

5. Обнаружение гонок данных

# Компиляция с детектором гонок
go run -race ./cmd/server
go test -race ./internal/...
go build -race -o myapp ./cmd/server

# Для интеграционных тестов
go test -race -count=100 ./integration/...

Пример гонки и её исправления:

// ПРОБЛЕМА: гонка данных
type Counter struct {
value int
}

func (c *Counter) Inc() {
c.value++ // DATA RACE — одновременная запись из нескольких горутин
}

func (c *Counter) Value() int {
return c.value // DATA RACE — одновременное чтение и запись
}

// РЕШЕНИЕ 1: sync.Mutex
type SafeCounter struct {
mu sync.Mutex
value int
}

func (c *SafeCounter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}

func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}

// РЕШЕНИЕ 2: sync/atomic (для простых типов)
type AtomicCounter struct {
value int64
}

func (c *AtomicCounter) Inc() {
atomic.AddInt64(&c.value, 1)
}

func (c *AtomicCounter) Value() int64 {
return atomic.LoadInt64(&c.value)
}

// РЕШЕНИЕ 3: sync.Map (для кэшей)
type Cache struct {
data sync.Map
}

func (c *Cache) Get(key string) (interface{}, bool) {
return c.data.Load(key)
}

func (c *Cache) Set(key string, value interface{}) {
c.data.Store(key, value)
}

6. Статический анализ

# golangci-lint — комплексный линтер
golangci-lint run ./...
golangci-lint run --enable-all --disable=lll ./...

# go vet — встроенный анализатор
go vet ./...
go vet -shadow ./... # обнаружение затенённых переменных

# Проверка на необработанные ошибки
go vet -unreachable ./...

# staticcheck — расширенный статический анализ
staticcheck ./...

Конфигурация .golangci.yml:

run:
timeout: 5m
go: "1.22"

linters:
enable:
- errcheck # непроверенные ошибки
- gosimple # упрощение кода
- govet # встроенный vet
- ineffassign # неиспользуемые присваивания
- staticcheck # расширенный анализ
- unused # неиспользуемый код
- gocyclo # цикломатическая сложность
- gosec # проблемы безопасности
- errorlint # правильное оборачивание ошибок
- gofmt # форматирование
- misspell # опечатки
- bodyclose # незакрытые body HTTP-ответов
- noctx # HTTP-запросы без context
- sqlclosecheck # незакрытые SQL-ресурсы

linters-settings:
gocyclo:
min-complexity: 15
gosec:
includes:
- G401 # слабая криптография
- G402 # TLS InsecureSkipVerify
- G404 # math/rand вместо crypto/rand

7. Тестовое покрытие

# Покрытие для всех пакетов
go test -cover ./...

# Детальный отчёт по покрытию
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html

# Покрытие с учётом интеграционных тестов
go test -cover -tags=integration -coverprofile=coverage.out ./...

# Проверка покрытия конкретных функций
go tool cover -func=coverage.out | grep "GetUser"

8. OpenTelemetry — распределённая трассировка

package telemetry

import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/jaeger"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
)

func InitTracer(serviceName, jaegerEndpoint string) (*sdktrace.TracerProvider, error) {
exp, err := jaeger.New(jaeger.WithCollectorEndpoint(
jaeger.WithEndpoint(jaegerEndpoint),
))
if err != nil {
return nil, err
}

tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exp),
sdktrace.WithResource(resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceNameKey.String(serviceName),
attribute.String("environment", "production"),
)),
sdktrace.WithSampler(sdktrace.TraceIDRatioBased(0.1)), // 10% сэмплирование
)

otel.SetTracerProvider(tp)
return tp, nil
}

// Использование в сервисах
func (s *UserService) GetUser(ctx context.Context, id string) (*models.User, error) {
tracer := otel.Tracer("user-service")
ctx, span := tracer.Start(ctx, "GetUser",
attribute.String("user.id", id),
)
defer span.End()

user, err := s.repo.FindByID(ctx, id)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}

span.SetAttributes(attribute.String("user.email", user.Email))
return user, nil
}

Сводная таблица инструментов

  • Delve — интерактивная отладка, брейкпоинты, инспекция горутин и каналов.
  • pprof — профилирование CPU, памяти, горутин, блокировок.
  • trace — визуализация выполнения, обнаружение contention и проблем планировщика.
  • go race — детектор гонок данных на этапе компиляции.
  • zap/zerolog — структурированное логирование с контекстом запроса.
  • golangci-lint — комплексный статический анализ кода.
  • OpenTelemetry — распределённая трассировка между микросервисами.
  • testify + testcontainers — модульные и интеграционные тесты с реальными зависимостями.

Вопрос 6. Как оцениваешь свой уровень владения React по 10-балльной шкале и что не хватает для более высокого уровня?

Таймкод: 00:12:53

Ответ собеседника: Правильный. Оценивает свой уровень на 5-6 из 10. Основная причина — нехватка практики из-за больших перерывов в разработке (иногда по полгода) из-за колледжа и жизненных обствятельств, несмотря на общий опыт около пяти лет.

Правильный ответ:

Ответ собеседника честный, но для позиции Go-разработчика вопрос о React вторичен. Тем не менее, самооценка 5-6 при пяти годах опыта с перерывами указывает на типичную проблему: знание базы без глубокого понимания продвинутых концепций. Разберём, что отличает разные уровни.

Шкала уровней владения React

Уровень 1-3: Junior

  • Понимание JSX, компонентов, props, state.
  • Использование useState, useEffect.
  • Простые формы и обработка событий.
  • Базовый роутинг с React Router.
  • Не понимает, когда и почему происходит ре-рендер.

Уровень 4-5: Junior+/Middle-

  • Кастомные хуки, контекст.
  • Базовая оптимизация (React.memo, useMemo, useCallback).
  • Работа с API через fetch/axios.
  • Простые анимации.
  • Не всегда понимает зависимости useEffect, допускает лишние ре-рендеры.

Уровень 6-7: Middle

  • Глубокое понимание рендер-цикла и механизма reconciliation.
  • Продвинутая оптимизация: профилирование с React DevTools Profiler.
  • Состояние: когда useState, когда useReducer, когда внешний стор (Zustand, Jotai, Redux Toolkit).
  • Error boundaries, Suspense, lazy loading.
  • Паттерны: compound components, render props, HOC (и когда они не нужны).
  • Тестирование: React Testing Library, Jest, моки, снапшоты.

Уровень 8-9: Middle+/Senior

  • Архитектурные решения: разделение на слои, feature-sliced design.
  • Server Components (Next.js App Router), streaming SSR.
  • Понимание fiber architecture, priority scheduling.
  • Написание собственных хуков с правильной мемоизацией.
  • Оптимизация бандла: code splitting, tree shaking, dynamic imports.
  • Accessibility (a11y), семантический HTML, ARIA.
  • Производительность: virtualization (react-window), debounce/throttle, web workers.

Уровень 10: Expert

  • Вклад в экосистему React (библиотеки, инструменты).
  • Понимание исходного кода React.
  • Архитектурные паттерны для крупных проектов (монорепозитории, микрофронтенды).
  • Продвинутая работа с конкурентными фичами (useTransition, useDeferredValue, SuspenseList).

Что не хватает для перехода на уровень 7-8

1. Глубокое понимание рендер-механики

// Понимание того, когда происходит ре-рендер
// и как его предотвратить

// ❌ Плохо: создаётся новый объект при каждом рендере
<ChildComponent config={{ theme: "dark", locale: "en" }} />

// ✅ Хорошо: мемоизированный объект
const config = useMemo(() => ({ theme: "dark", locale: "en" }), []);
<ChildComponent config={config} />

// ❌ Плохо: новая функция при каждом рендере
<ChildComponent onClick={() => handleClick(id)} />

// ✅ Хорошо: мемоизированная функция
const handleClickMemo = useCallback(() => handleClick(id), [id]);
<ChildComponent onClick={handleClickMemo} />

// ✅ Ещё лучше: передача данных и коллбэка отдельно
// чтобы избежать ре-рендера при изменении только коллбэка

2. Правильное управление эффектами

// ❌ Плохо: бесконечный цикл из-за неправильных зависимостей
useEffect(() => {
fetchUser(userId).then(setUser);
}, [user]); // user изменяется → эффект снова запускается

// ✅ Хорошо: зависимость от внешнего параметра
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);

// ❌ Плохо: отсутствие cleanup
useEffect(() => {
const interval = setInterval(pollData, 5000);
}, []);

// ✅ Хорошо: очистка при размонтировании
useEffect(() => {
const interval = setInterval(pollData, 5000);
return () => clearInterval(interval);
}, []);

// ❌ Плохо: игнорирование ESLint правила exhaustive-deps
useEffect(() => {
doSomething(a, b);
}, [a]); // b не в зависимостях — потенциальный баг

// ✅ Хорошо: все зависимости указаны
useEffect(() => {
doSomething(a, b);
}, [a, b]);

3. Продвинутая оптимизация

// React.memo с кастомным сравнем
const UserCard = React.memo(({ user, onSelect }: UserCardProps) => {
return (
<div onClick={() => onSelect(user.id)}>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
);
}, (prevProps, nextProps) => {
// Кастомное сравнение: ре-рендер только если изменились данные пользователя
return prevProps.user.id === nextProps.user.id &&
prevProps.user.name === nextProps.user.name;
});

// Виртуализация длинных списков
import { FixedSizeList as List } from 'react-window';

const VirtualizedList = ({ items }: { items: Item[] }) => {
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
<div style={style}>
<UserCard user={items[index]} onSelect={handleSelect} />
</div>
);

return (
<List
height={600}
itemCount={items.length}
itemSize={80}
width="100%"
>
{Row}
</List>
);
};

// useDeferredValue для отложенного обновления при вводе
const SearchResults = ({ query }: { query: string }) => {
const deferredQuery = useDeferredValue(query);

// Пока deferredQuery не обновился, показываем старые результаты
// с индикатором загрузки
const isStale = query !== deferredQuery;

return (
<div style={{ opacity: isStale ? 0.5 : 1 }}>
<Results query={deferredQuery} />
</div>
);
};

4. Продвинутое управление состоянием

// Zustand — лёгкая альтернатива Redux
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

interface AuthState {
user: User | null;
token: string | null;
isLoading: boolean;
error: string | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
refreshToken: () => Promise<void>;
}

const useAuthStore = create<AuthState>()(
devtools(
persist(
immer((set, get) => ({
user: null,
token: null,
isLoading: false,
error: null,

login: async (email, password) => {
set((state) => { state.isLoading = true; state.error = null; });

try {
const { user, token } = await authApi.login(email, password);
set((state) => {
state.user = user;
state.token = token;
state.isLoading = false;
});
} catch (error) {
set((state) => {
state.error = error.message;
state.isLoading = false;
});
}
},

logout: () => {
set((state) => {
state.user = null;
state.token = null;
});
},

refreshToken: async () => {
const currentToken = get().token;
if (!currentToken) return;

try {
const { token } = await authApi.refresh(currentToken);
set((state) => { state.token = token; });
} catch {
get().logout();
}
},
})),
{ name: 'auth-storage', partialize: (state) => ({ token: state.token }) }
),
{ name: 'AuthStore' }
)
);

// Использование с селекторами для предотвращения лишних ре-рендеров
const UserName = () => {
// Только user.name — ре-рендер только при изменении имени
const userName = useAuthStore((state) => state.user?.name);
return <span>{userName}</span>;
};

5. Тестирование

// React Testing Library — правильный подход
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { rest } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer(
rest.get('/api/users/:id', (req, res, ctx) => {
return res(ctx.json({ id: req.params.id, name: 'Test User' }));
})
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

// Тест компонента с асинхронной загрузкой
test('loads and displays user', async () => {
render(<UserProfile userId="123" />);

// Состояние загрузки
expect(screen.getByText(/loading/i)).toBeInTheDocument();

// Ожидание появления данных
await waitFor(() => {
expect(screen.getByText('Test User')).toBeInTheDocument();
});

expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});

// Тест интерактивности
test('handles user interaction', async () => {
const onSelect = jest.fn();
render(<UserList users={mockUsers} onSelect={onSelect} />);

await userEvent.click(screen.getByText('Select User 1'));

expect(onSelect).toHaveBeenCalledWith('user-1');
});

// Тест ошибок
test('displays error state', async () => {
server.use(
rest.get('/api/users/:id', (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: 'Server error' }));
})
);

render(<UserProfile userId="123" />);

await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});

Что стоит изучить для роста

  • React 18+ конкурентные фичи — useTransition, useDeferredValue, Suspense, useId.
  • Server Components — понимание разницы между серверными и клиентскими компонентами.
  • Архитектурные паттерны — feature-sliced design, разделение на слои (ui/features/entities/shared).
  • Производительность — React DevTools Profiler, Lighthouse, Core Web Vitals.
  • Accessibility — семантика, ARIA, keyboard navigation, screen reader support.
  • TypeScript с React — generics для компонентов, discriminated unions для пропсов.

Рекомендация для собеседника

Поскольку позиция — Go-разработчик, фокус стоит сместить на глубокое изучение Go и его экосистемы. Знание React на уровне 5-6 достаточно для понимания фронтенд-части, но для backend-позиции критически важнее уверенное владение Go, конкурентностью, профилированием и архитектурой микросервисов.

Вопрос 7. Зачем нужны фреймворки типа React и в чём их преимущество перед чистым JavaScript?

Таймкод: 00:13:38

Ответ собеседника: Правильный. Главное преимущество — клиентский рендеринг с актуальными данными. Обеспечивается динамическое обновление HTML, клиентская маршрутизация без перезагрузки страниц. Управление состоянием приложения через хуки, без необходимости напрямую обращаться к DOM. Наличие виртуального DOM для оптимизации дорогих операций с DOM.

Правильный ответ:

Ответ собеседника верный по сути, но поверхностный. Для полного понимания стоит раскрыть тему глубже — не только что делает React, но почему именно такой подход победил и какие архитектурные проблемы он решает.

Проблемы чистого JavaScript при разработке UI

1. Ручное управление DOM — источник багов

Без фреймворка разработчик вручную отслеживает изменения данных и обновляет DOM:

// ❌ Vanilla JS — ручное управление DOM
function renderUserList(users) {
const container = document.getElementById('user-list');

// Полная пересоздание DOM при каждом обновлении
container.innerHTML = '';

users.forEach(user => {
const el = document.createElement('div');
el.className = 'user-card';
el.innerHTML = `
<h3>${user.name}</h3>
<p>${user.email}</p>
<button onclick="deleteUser('${user.id}')">Delete</button>
`;

// Ручная привязка обработчиков событий
el.querySelector('.edit-btn').addEventListener('click', () => {
editUser(user.id);
});

container.appendChild(el);
});

// Ручное управление состоянием: где хранить данные?
// Как синхронизировать данные между компонентами?
// Как избежать утечек памяти при удалении элементов?
}

// Проблемы этого подхода:
// 1. Полная пересоздание DOM — дорого и теряет фокус/выделение
// 2. XSS-уязвимость через innerHTML
// 3. Утечки памяти: обработчики событий не удаляются автоматически
// 4. Нет единого источника правды — состояние разбросано
// 5. Сложность масштабирования: каждый новый компонент — новый кук кода

2. Проблема синхронизации состояния

// ❌ Vanilla JS — ручная синхронизация между компонентами
let cart = [];
let cartCount = 0;
let cartTotal = 0;

function addToCart(product) {
cart.push(product);
updateCartUI();
updateCartBadge();
updateCartTotal();
saveToLocalStorage();
// Забыли обновить что-то? Баг.
}

function removeFromCart(productId) {
cart = cart.filter(p => p.id !== productId);
updateCartUI();
updateCartBadge();
updateCartTotal();
saveToLocalStorage();
// Дублирование кода, рассинхронизация состояния
}

// Каждый компонент должен знать обо всех остальных
// При добавлении нового компонента — меняем все существующие

Как React решает эти проблемы

1. Декларативный подход вместо императивного

// ✅ React — декларативное описание UI
interface UserListProps {
users: User[];
onDelete: (id: string) => void;
onEdit: (id: string) => void;
}

const UserList: React.FC<UserListProps> = ({ users, onDelete, onEdit }) => {
return (
<div className="user-list">
{users.map(user => (
<UserCard
key={user.id}
user={user}
onDelete={() => onDelete(user.id)}
onEdit={() => onEdit(user.id)}
/>
))}
</div>
);
};

// React сам решает, какие DOM-операции выполнить
// Мы описываем "как должен выглядеть UI при этих данных"
// А не "как обновить DOM при изменении данных"

2. Единый источник правды

// ✅ React — состояние в одном месте
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}

const ShoppingCart: React.FC = () => {
const [items, setItems] = useState<CartItem[]>([]);

// Вычисляемые значения автоматически пересчитываются
const totalItems = items.reduce((sum, item) => sum + item.quantity, 0);
const totalPrice = items.reduce((sum, item) => sum + item.price * item.quantity, 0);

const addItem = useCallback((product: Product) => {
setItems(prev => {
const existing = prev.find(item => item.id === product.id);
if (existing) {
return prev.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
}
return [...prev, { ...product, quantity: 1 }];
});
}, []);

const removeItem = useCallback((id: string) => {
setItems(prev => prev.filter(item => item.id !== id));
}, []);

return (
<div>
<CartBadge count={totalItems} />
<CartItems items={items} onRemove={removeItem} />
<CartTotal price={totalPrice} />
</div>
);
};

3. Virtual DOM и Reconciliation

Virtual DOM — это не просто «оптимизация DOM». Это абстрация, которая позволяет React решать, какие именно DOM-операции минимально необходимы.

// React внутренне работает так:
// 1. Создаёт виртуальное дерево элементов (React Element tree)
// 2. При изменении состояния — создаёт новое виртуальное дерево
// 3. Сравнивает старое и новое дерево (diffing algorithm)
// 4. Вычисляет минимальный набор изменений (reconciliation)
// 5. Применяет изменения к реальному DOM (batching)

// Пример того, что React оптимизирует автоматически:
const Counter: React.FC = () => {
const [count, setCount] = useState(0);
const [name, setName] = useState('');

// При setCount — React обновит только текст в span
// При setName — React обновит только значение input
// Никаких ручных манипуляций с DOM

return (
<div>
<span>Count: {count}</span>
<input value={name} onChange={e => setName(e.target.value)} />
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</div>
);
};

// Алгоритм reconciliation:
// - O(n) сложность вместо O(n^3) для общего случая
// - Предполагает, что элементы разных типов дают разные деревья
// - Использует key для определения перемещений элементов в списках

4. Композиция компонентов

// ✅ React — композиция вместо наследования

// Базовый компонент — переиспользуемый строительный блок
interface CardProps {
children: React.ReactNode;
className?: string;
onClick?: () => void;
}

const Card: React.FC<CardProps> = ({ children, className, onClick }) => (
<div className={`card ${className || ''}`} onClick={onClick}>
{children}
</div>
);

// Специализированные компоненты через композицию
interface UserCardProps {
user: User;
onEdit: () => void;
onDelete: () => void;
}

const UserCard: React.FC<UserCardProps> = ({ user, onEdit, onDelete }) => (
<Card className="user-card">
<Avatar src={user.avatar} name={user.name} />
<div className="user-info">
<h3>{user.name}</h3>
<p>{user.email}</p>
<Badge status={user.status} />
</div>
<CardActions>
<Button variant="primary" onClick={onEdit}>Edit</Button>
<Button variant="danger" onClick={onDelete}>Delete</Button>
</CardActions>
</Card>
);

// Сложные страницы собираются из простых компонентов
const UserDashboard: React.FC = () => {
const { users, isLoading, error } = useUsers();

if (isLoading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;

return (
<PageLayout
header={<PageHeader title="Users" actions={<CreateUserButton />} />}
sidebar={<Navigation />}
>
<UserList users={users} />
<Pagination total={users.length} />
</PageLayout>
);
};

5. Экосистема и инструменты

React — это не только библиотека, а целая экосистема:

// Роутинг
import { createBrowserRouter, RouterProvider } from 'react-router-dom';

const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
errorElement: <ErrorBoundary />,
children: [
{ index: true, element: <HomePage /> },
{
path: 'users',
element: <UsersPage />,
loader: usersLoader, // данные загружаются до рендера
action: usersAction, // обработка форм
children: [
{ path: ':id', element: <UserDetail />, loader: userLoader },
{ path: 'new', element: <CreateUserPage />, action: createUserAction },
]
},
]
}
]);

// Серверный рендеринг с потоковой передачей
import { Suspense } from 'react';

const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
// Suspense позволяет показать fallback пока данные загружаются
return (
<Suspense fallback={<ProfileSkeleton />}>
<UserDetails userId={userId} />
<Suspense fallback={<ReviewsSkeleton />}>
<UserReviews userId={userId} />
</Suspense>
</Suspense>
);
};

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

АспектVanilla JavaScriptReact
Обновление DOMРучное, императивноеАвтоматическое, декларативное
СостояниеРазбросано по переменнымЦентрализовано в useState/useReducer
КомпозицияКопипаста, функцииКомпоненты, JSX
ТестированиеРучной поиск элементовReact Testing Library
ПереиспользованиеФункции, IIFEКомпоненты, хуки
ПроизводительностьЗависит от разработчикаVirtual DOM, memo, автоматические оптимизации
МасштабированиеСложно — код разрастаетсяКомпонентная архитектура

Когда React не нужен

  • Простые лендинги с минимальной интерактивностью.
  • Статические сайты (лучше Hugo, Astro, 11ty).
  • Простые формы и валидация (достаточно Vanilla JS или Alpine.js).
  • Проекты с жёсткими требованиями к размеру бандла (Preact, Solid).
  • Когда нужен только один интерактивный элемент на странице (Web Components, Alpine.js).

Итог

React побеждает не потому что «модный», а потому что решает фундаментальную проблему: синхронизацию данных и UI в сложных приложениях. Декларативный подход, компонентная архитектура, Virtual DOM и богатая экосистема делают разработку сложных интерфейсов предсказуемой, тестируемой и масштабируемой.

Вопрос 8. Какие стейт-менеджеры использовал и зачем они нужны?

Таймкод: 00:14:50

Ответ собеседника: Правильный. Работал с Zustand (любимый — простой и в большинстве случаев достаточный) и Redux. Стейт-менеджеры нужны для управления глобальным состоянием приложения, чтобы избежать prop drilling — прокидывания пропсов через множество дочерних компонентов. Контекст также может использоваться, но стейт-менеджеры удобнее.

Правильный ответ:

Ответ собеседника хороший — он правильно определил основную проблему (prop drilling) и имеет практический опыт с двумя решениями. Разберём тему глубже: когда какой менеджер выбрать, какие у них архитектурные различия и какие паттерны применяются.

Проблема prop drilling — корень необходимости стейт-менеджеров

// ❌ Prop drilling — данные прокидываются через компоненты,
// которые их не используют, только для передачи дальше

// Глубокая иерархия: App → Layout → Sidebar → Nav → UserMenu → UserAvatar
// Нужно передать user и theme до UserAvatar

const App = () => {
const [user] = useState(currentUser);
const [theme, setTheme] = useState('dark');

return <Layout user={user} theme={theme} setTheme={setTheme} />;
};

const Layout = ({ user, theme, setTheme }) => {
// Layout не использует user, но обязан прокидывать
return (
<div>
<Header user={user} theme={theme} />
<Sidebar user={user} theme={theme} setTheme={setTheme} />
<Main />
</div>
);
};

const Sidebar = ({ user, theme, setTheme }) => {
// Sidebar тоже не использует user напрямую
return (
<aside>
<Navigation />
<UserMenu user={user} theme={theme} setTheme={setTheme} />
</aside>
);
};

const UserMenu = ({ user, theme, setTheme }) => {
// Наконец-то дошли до компонента, который реально использует user
return (
<div>
<UserAvatar user={user} />
<ThemeToggle theme={theme} setTheme={setTheme} />
</div>
);
};

// Проблемы:
// 1. Изменение интерфейса Layout требует изменения всех промежуточных компонентов
// 2. Тестирование: чтобы протестировать UserAvatar, нужно мокать всю цепочку
// 3. Ре-рендер: при изменении user ре-рендерятся все промежуточные компоненты
// 4. Переиспользование: Sidebar жёстко зависит от user в пропсах

Решение с Context — почему его недостаточно

// ⚠️ Context решает prop drilling, но создаёт проблему ре-рендеров
const UserContext = createContext(null);

const App = () => {
const [user, setUser] = useState(currentUser);

// Проблема: при ЛЮБОМ изменении value — ре-рендерятся ВСЕ потребители
// Даже если компонент использует только часть контекста
return (
<UserContext.Provider value={{ user, setUser }}>
<Layout />
</UserContext.Provider>
);
};

// Проблема ре-рендеров:
// При setUser — ре-рендерятся все компоненты, которые читают UserContext
// даже если они не используют поле, которое изменилось

Redux — предсказуемое состояние для сложных приложений

// ✅ Redux Toolkit — современный подход к Redux
import { configureStore, createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { useSelector, useDispatch } from 'react-redux';

// Асинхронные операции
export const fetchUsers = createAsyncThunk(
'users/fetchUsers',
async (_, { rejectWithValue }) => {
try {
const response = await api.get('/users');
return response.data;
} catch (error) {
return rejectWithValue(error.response.data);
}
}
);

export const updateUser = createAsyncThunk(
'users/updateUser',
async (userData: UpdateUserInput, { rejectWithValue }) => {
try {
const response = await api.put(`/users/${userData.id}`, userData);
return response.data;
} catch (error) {
return rejectWithValue(error.response.data);
}
}
);

// Slice — объединяет редьюсеры и экшены
const usersSlice = createSlice({
name: 'users',
initialState: {
items: [] as User[],
selectedId: null as string | null,
loading: false,
error: null as string | null,
},
reducers: {
selectUser: (state, action: PayloadAction<string>) => {
state.selectedId = action.payload;
},
clearError: (state) => {
state.error = null;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string;
})
.addCase(updateUser.fulfilled, (state, action) => {
const index = state.items.findIndex(u => u.id === action.payload.id);
if (index !== -1) {
state.items[index] = action.payload;
}
});
},
});

export const { selectUser, clearError } = usersSlice.actions;

// Селекторы с мемоизацией
export const selectAllUsers = (state: RootState) => state.users.items;
export const selectSelectedUser = (state: RootState) =>
state.users.items.find(u => u.id === state.users.selectedId);
export const selectUsersLoading = (state: RootState) => state.users.loading;

// Фабрика селекторов (для параметризованных селекторов)
import { createSelector } from '@reduxjs/toolkit';

export const selectUserById = createSelector(
[selectAllUsers, (state: RootState, userId: string) => userId],
(users, userId) => users.find(u => u.id === userId)
);

// Store
export const store = configureStore({
reducer: {
users: usersSlice.reducer,
auth: authSlice.reducer,
cart: cartSlice.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['persist/PERSIST'],
},
}),
devTools: process.env.NODE_ENV !== 'production',
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

// Типизированные хуки
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

// Использование в компонентах
const UserList: React.FC = () => {
const dispatch = useAppDispatch();
const users = useAppSelector(selectAllUsers);
const loading = useAppSelector(selectUsersLoading);

useEffect(() => {
dispatch(fetchUsers());
}, [dispatch]);

if (loading) return <LoadingSpinner />;

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

// Компонент подписан только на конкретного пользователя
// Ре-рендер только при изменении этого пользователя
const UserCard: React.FC<{ userId: string }> = React.memo(({ userId }) => {
const user = useAppSelector(state => selectUserById(state, userId));

if (!user) return null;

return (
<div>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
);
});

Когда Redux оправдан:

  • Большое приложение с множеством связанных сущностей.
  • Нужны middleware (логирование, аналитика, кэширование).
  • Важна предсказуемость и воспроизводимость (time-travel debugging).
  • Команда уже знает Redux и имеет устоявшиеся паттерны.
  • Нужна сложная логика обновления состояния.

Zustand — минималистичный и эффективный

// ✅ Zustand — простой, без boilerplate, с отличной производительностью
import { create } from 'zustand';
import { devtools, persist, subscribeWithSelector } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

interface AuthState {
user: User | null;
token: string | null;
isLoading: boolean;
error: string | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
refreshToken: () => Promise<void>;
clearError: () => void;
}

const useAuthStore = create<AuthState>()(
devtools(
persist(
subscribeWithSelector(
immer((set, get) => ({
user: null,
token: null,
isLoading: false,
error: null,

login: async (email, password) => {
set((state) => {
state.isLoading = true;
state.error = null;
});

try {
const { user, token } = await authApi.login(email, password);
set((state) => {
state.user = user;
state.token = token;
state.isLoading = false;
});
} catch (error) {
set((state) => {
state.error = error.message;
state.isLoading = false;
});
throw error;
}
},

logout: () => {
set((state) => {
state.user = null;
state.token = null;
});
},

refreshToken: async () => {
const { token } = get();
if (!token) throw new Error('No token');

try {
const { token: newToken } = await authApi.refresh(token);
set((state) => { state.token = newToken; });
} catch {
get().logout();
throw new Error('Token refresh failed');
}
},

clearError: () => {
set((state) => { state.error = null; });
},
}))
),
{
name: 'auth-storage',
partialize: (state) => ({ token: state.token }),
}
),
{ name: 'AuthStore' }
)
);

// Подписка на конкретное поле — ре-рендер только при его изменении
const UserName = () => {
const name = useAuthStore((state) => state.user?.name);
return <span>{name}</span>;
};

// Подписка на несколько полей
const AuthStatus = () => {
const { user, isLoading } = useAuthStore(
(state) => ({ user: state.user, isLoading: state.isLoading })
);

if (isLoading) return <Spinner />;
return <span>{user ? `Hello, ${user.name}` : 'Not logged in'}</span>;
};

// Использование вне компонентов (в утилитах, интерцепторах)
const setupAxiosInterceptors = (axiosInstance: AxiosInstance) => {
axiosInstance.interceptors.request.use((config) => {
const token = useAuthStore.getState().token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});

axiosInstance.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
try {
await useAuthStore.getState().refreshToken();
const token = useAuthStore.getState().token;
error.config.headers.Authorization = `Bearer ${token}`;
return axiosInstance.request(error.config);
} catch {
useAuthStore.getState().logout();
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
};

// Подписка на изменения (без ре-рендера компонента)
useAuthStore.subscribe(
(state) => state.token,
(token, prevToken) => {
if (token && !prevToken) {
analytics.track('user_logged_in');
}
if (!token && prevToken) {
analytics.track('user_logged_out');
}
}
);

Сравнение Zustand и Redux

КритерийRedux ToolkitZustand
BoilerplateСредний (slice, actions)Минимальный
Кривая обученияВысокая (паттерны, middleware)Низкая
DevToolsОтличные (time-travel)Хорошие (через middleware)
MiddlewareБогатая экосистемаМинимальные, но достаточные
ПроизводительностьХорошаяОтличная (меньше оверхеда)
TypeScriptХорошая поддержкаОтличная поддержка
Размер бандла~11KB~3KB
Персистенцияredux-persistВстроенный middleware
Подписка на поляЧерез селекторыВстроенная
Использование вне ReactСложноПросто (getState/setState)

Когда что выбрать

React.useState/useReducer достаточно для:

  • Локального состояния компонента (форма, открытый дропдаун).
  • Маленьких приложений без сложных связей между компонентами.
  • Состояния, которое не нужно разделять между компонентами.

React Context подходит для:

  • Темы, локализация, текущий язык.
  • Данные, которые меняются редко и используются многими компонентами.
  • Простые случаи без сложной логики обновления.

Zustand — оптимальный выбор для:

  • Средних и больших приложений.
  • Когда нужна простота без потери возможностей.
  • Когда важна производительность и минимальный размер бандла.
  • Когда нужно использовать состояние вне компонентов.

Redux — для сложных случаев:

  • Большие команды с устоявшимися паттернами.
  • Сложная логика обновления с множеством side effects.
  • Нужны продвинутые middleware (saga, observable).
  • Важна воспроизводимость состояния (undo/redo, time-travel).

Другие стейт-менеджеры

  • Jotai — атомарный подход, каждый кусочек состояния независим. Отличная гранулярность ре-рендеров.
  • Recoil — от Facebook, атомы и селекторы. Хорошая интеграция с Concurrent Mode.
  • Valtio — прокси-подход, мутации вместо иммутабельности. Минимальный boilerplate.
  • MobX — реактивный подход, автоматическое отслеживание зависимостей. Меньше бойлплейта, но менее предсказуемо.
  • TanStack Query — не стейт-менеджер в классическом смысле, а серверный стейт-менеджер. Кэширование, инвалидация, оптимистичные обновления для данных с сервера.
// TanStack Query — для серверного состояния
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

const UserList: React.FC = () => {
const { data: users, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => api.get('/users').then(r => r.data),
staleTime: 5 * 60 * 1000, // 5 минут — данные считаются свежими
gcTime: 10 * 60 * 1000, // 10 минут — время хранения в кэше
});

if (isLoading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;

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

const UpdateUser: React.FC<{ userId: string }> = ({ userId }) => {
const queryClient = useQueryClient();

const mutation = useMutation({
mutationFn: (data: UpdateUserInput) =>
api.put(`/users/${userId}`, data).then(r => r.data),
onMutate: async (newData) => {
// Оптимистичное обновление
await queryClient.cancelQueries({ queryKey: ['users'] });
const previousUsers = queryClient.getQueryData(['users']);

queryClient.setQueryData(['users'], (old: User[]) =>
old.map(u => u.id === userId ? { ...u, ...newData } : u)
);

return { previousUsers };
},
onError: (err, newData, context) => {
// Откат при ошибке
queryClient.setQueryData(['users'], context?.previousUsers);
},
onSettled: () => {
// Инвалидация после завершения
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});

return (
<button onClick={() => mutation.mutate({ name: 'New Name' })}>
Update
</button>
);
};

Итог

Выбор стейт-менеджера зависит от размера приложения, команды и требований. Для большинства современных проектов Zustand или TanStack Query покрывают 90% потребностей. Redux остаётся актуальным для крупных проектов с высокими требованиями к предсказуемости и отладке. Главное правило — не внедрять стейт-менеджер пока он реально не нужен.

Вопрос 9. Зачем нужна мемоизация в React (useMemo и useCallback)?

Таймкод: 00:15:48

Ответ собеседника: Правильный. useMemo запоминает результат тяжёлого вычисления при тех же аргументах и возвращает кэшированный результат без повторного запуска функции. useCallback создаёт стабильную ссылку на функцию, чтобы она не пересоздавалась при каждом рендере. Оба хука служат для оптимизации — предотвращения дорогих вычислений и лишних перерисовок.

Правильный ответ:

Ответ собеседника верный и хорошо сформулирован. Разберём тему глубже — когда мемоизация действительно нужна, когда она избыточна, и какие подводные камни существуют.

Фундаментальная проблема: референциальное равенство в JavaScript

// Каждый рендер создаёт НОВЫЕ объекты и функции
// Даже если содержимое идентично — ссылки разные

const obj1 = { name: 'John', age: 30 };
const obj2 = { name: 'John', age: 30 };
console.log(obj1 === obj2); // false — разные ссылки

const fn1 = () => console.log('hello');
const fn2 = () => console.log('hello');
console.log(fn1 === fn2); // false — разные ссылки

// Это значит, что при каждом рендере компонента:
// - Все объекты в пропсах — "новые"
// - Все функции в пропсах — "новые"
// - React.memo, useEffect, useMemo с зависимостями — срабатывают

useMemo — мемоизация значений

// ✅ useMemo для дорогих вычислений
interface DataTableProps {
rows: DataRow[];
sortKey: string;
filter: string;
}

const DataTable: React.FC<DataTableProps> = ({ rows, sortKey, filter }) => {
// Без useMemo: фильтрация и сортировка выполняются при КАЖДОМ рендере
// Даже если rows, sortKey, filter не изменились
// (например, при изменении несвязанного состояния родителя)

// С useMemo: вычисление выполняется только при изменении зависимостей
const processedRows = useMemo(() => {
console.log('Processing rows...'); // Лог покажет, когда реально выполняется

let result = rows;

// Фильтрация
if (filter) {
const lowerFilter = filter.toLowerCase();
result = result.filter(row =>
row.name.toLowerCase().includes(lowerFilter) ||
row.email.toLowerCase().includes(lowerFilter)
);
}

// Сортировка
result = [...result].sort((a, b) => {
const aVal = a[sortKey];
const bVal = b[sortKey];
if (typeof aVal === 'string') {
return aVal.localeCompare(bVal);
}
return aVal - bVal;
});

return result;
}, [rows, sortKey, filter]); // Пересчёт только при изменении зависимостей

return (
<table>
<tbody>
{processedRows.map(row => (
<tr key={row.id}>
<td>{row.name}</td>
<td>{row.email}</td>
</tr>
))}
</tbody>
</table>
);
};

// ✅ useMemo для сохранения ссылки на объект
interface ChartProps {
data: DataPoint[];
config: ChartConfig;
}

const Chart: React.FC<ChartProps> = ({ data, config }) => {
// Проблема: config создаётся в родителе при каждом рендере
// → Chart получает "новый" config каждый раз
// → useEffect внутри Chart срабатывает каждый раз

// Решение: мемоизируем config в родителе
// (см. пример ниже в секции "Типичные ошибки")

useEffect(() => {
// Построение графика — дорогая операция
renderChart(data, config);
}, [data, config]);

return <canvas ref={canvasRef} />;
};

// ✅ useMemo для вычисления производных данных
const ShoppingCart: React.FC = () => {
const [items, setItems] = useState<CartItem[]>([]);
const [discountCode, setDiscountCode] = useState<string | null>(null);

// Вычисляемые значения с мемоизацией
const subtotal = useMemo(
() => items.reduce((sum, item) => sum + item.price * item.quantity, 0),
[items]
);

const discount = useMemo(() => {
if (!discountCode) return 0;
// Дорогой запрос к API для проверки купона
return calculateDiscount(subtotal, discountCode);
}, [subtotal, discountCode]);

const tax = useMemo(() => subtotal * 0.2, [subtotal]);
const total = useMemo(() => subtotal - discount + tax, [subtotal, discount, tax]);

return (
<div>
<span>Subtotal: ${subtotal.toFixed(2)}</span>
<span>Discount: -${discount.toFixed(2)}</span>
<span>Tax: ${tax.toFixed(2)}</span>
<span>Total: ${total.toFixed(2)}</span>
</div>
);
};

useCallback — мемоизация функций

// ✅ useCallback для стабильных ссылок на функции
const TodoList: React.FC = () => {
const [todos, setTodos] = useState<Todo[]>([]);

// Без useCallback: handleToggle создаётся заново при каждом рендере
// → TodoItem, обёрнутый в React.memo, всё равно ре-рендерится
// потому что получает "новую" функцию в пропсах

// С useCallback: стабильная ссылка, пока зависимости не изменились
const handleToggle = useCallback((id: string) => {
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
}, []); // Пустые зависимости — функция создаётся один раз

const handleDelete = useCallback((id: string) => {
setTodos(prev => prev.filter(todo => todo.id !== id));
}, []);

const handleEdit = useCallback((id: string, newText: string) => {
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, text: newText } : todo
)
);
}, []);

return (
<ul>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle}
onDelete={handleDelete}
onEdit={handleEdit}
/>
))}
</ul>
);
};

// React.memo работает благодаря стабильным ссылкам на функции
const TodoItem = React.memo<{
todo: Todo;
onToggle: (id: string) => void;
onDelete: (id: string) => void;
onEdit: (id: string, text: string) => void;
}>(({ todo, onToggle, onDelete, onEdit }) => {
console.log(`Rendering TodoItem: ${todo.id}`);

return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span>{todo.text}</span>
<button onClick={() => onDelete(todo.id)}>Delete</button>
</li>
);
});

Типичные ошибки с мемоизацией

// ❌ Ошибка 1: Мемоизация без React.memo
const Parent = () => {
const [count, setCount] = useState(0);

// useCallback бесполезен, если Child не обёрнут в React.memo
const handleClick = useCallback(() => {
console.log('clicked');
}, []);

return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<Child onClick={handleClick} /> {/* Ре-рендерится при каждом setCount */}
</div>
);
};

// ✅ Правильно: React.memo + useCallback вместе
const Child = React.memo(({ onClick }: { onClick: () => void }) => {
console.log('Child render');
return <button onClick={onClick}>Click me</button>;
});

// ❌ Ошибка 2: Избыточная мемоизация
const SimpleComponent = ({ name }: { name: string }) => {
// Нет смысла мемоизировать простую конкатенацию строк
const greeting = useMemo(() => `Hello, ${name}!`, [name]);
// Проще и читаемее:
// const greeting = `Hello, ${name}!`;

// Нет смысла мемоизировать простой маппинг для рендера
const items = useMemo(() => data.map(item => item.name), [data]);
// Если data и так мемоизирован — повторная мемоизация избыточна

return <div>{greeting}</div>;
};

// ❌ Ошибка 3: Неправильные зависимости
const BadMemoization = ({ user }: { user: User }) => {
// Пропал user.email из зависимостей — баг!
const greeting = useMemo(
() => `Hello, ${user.name} (${user.email})`,
[user.name] // ❌ Забыли user.email
);

// Объект в зависимостях — всегда новая ссылка
const config = useMemo(() => ({ theme: 'dark', locale: 'en' }), []);
const result = useMemo(() => {
return processConfig(config);
}, [config]); // ❌ config — новый объект при каждом рендере родителя

return <div>{greeting}</div>;
};

// ✅ Правильно
const GoodMemoization = ({ user }: { user: User }) => {
const greeting = useMemo(
() => `Hello, ${user.name} (${user.email})`,
[user.name, user.email] // Все используемые поля в зависимостях
);

// Мемоизируем объект в родителе или используем примитивы
return <ChildComponent theme="dark" locale="en" />;
};

// ❌ Ошибка 4: useMemo для побочных эффектов
const BadSideEffect = ({ data }: { data: Data[] }) => {
// useMemo для побочных эффектов — антипаттерн
useMemo(() => {
analytics.track('data_processed', { count: data.length });
return data.filter(d => d.active);
}, [data]);

// ✅ Побочные эффекты — только в useEffect
const activeData = useMemo(() => data.filter(d => d.active), [data]);

useEffect(() => {
analytics.track('data_processed', { count: data.length });
}, [data.length]);

return <div>{activeData.length}</div>;
};

Когда мемоизация действительно нужна

// ✅ Случай 1: Дорогие вычисления
const ExpensiveComponent = ({ items }: { items: Item[] }) => {
// Алгоритм со сложностью O(n²) или выше
const sortedFiltered = useMemo(() => {
return items
.filter(item => item.category === 'active')
.sort((a, b) => b.priority - a.priority)
.slice(0, 100);
}, [items]);

return <ItemList items={sortedFiltered} />;
};

// ✅ Случай 2: Стабильные ссылки для дочерних компонентов с React.memo
const ParentWithMemoizedChild = () => {
const [count, setCount] = useState(0);

const stableCallback = useCallback(() => {
doSomething();
}, []);

const stableObject = useMemo(() => ({
theme: 'dark',
onAction: stableCallback,
}), [stableCallback]);

return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<MemoizedChild config={stableObject} />
</div>
);
};

const MemoizedChild = React.memo(({ config }: { config: Config }) => {
// Ре-рендер только при изменении config
return <div>{config.theme}</div>;
});

// ✅ Случай 3: Зависимости в useEffect
const DataFetcher = ({ filters }: { filters: Filters }) => {
// Без мемоизации: новый объект filters при каждом рендере
// → useEffect срабатывает бесконечно

const stableFilters = useMemo(() => ({
status: filters.status,
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
}), [filters.status, filters.dateFrom, filters.dateTo]);

useEffect(() => {
fetchData(stableFilters);
}, [stableFilters]);

return <DataView />;
};

Когда мемоизация НЕ нужна

// ✅ Не нужна: простые вычисления
const SimpleGreeting = ({ name }: { name: string }) => {
// Простая конкатенация — быстрее, чем накладные расходы useMemo
const greeting = `Hello, ${name}!`;
return <div>{greeting}</div>;
};

// ✅ Не нужна: компонент без React.memo
const Parent = () => {
const [count, setCount] = useState(0);

// Бесполезно: Child не обёрнут в React.memo
const handleClick = useCallback(() => {}, []);

return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
<Child onClick={handleClick} />
</div>
);
};

// ✅ Не нужна: функция используется только в текущем компоненте
const LocalHandler = () => {
const [value, setValue] = useState('');

// useCallback не нужен — функция не передаётся в дочерние компоненты
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
};

return <input value={value} onChange={handleChange} />;
};

Альтернативы ручной мемоизации

// React 19: use() хук и автоматическая мемоизация
// React автоматически мемоизирует значения внутри компонента
// при использовании новых конкурентных фич

// useDeferredValue — автоматическая мемоизация с отложенным обновлением
const SearchResults = ({ query }: { query: string }) => {
// deferredQuery обновляется с задержкой
// Пока обновляется — показываем старые результаты
const deferredQuery = useDeferredValue(query);

const results = useMemo(() => {
return expensiveSearch(deferredQuery);
}, [deferredQuery]);

return (
<div style={{ opacity: query !== deferredQuery ? 0.5 : 1 }}>
{results.map(r => <ResultItem key={r.id} item={r} />)}
</div>
);
};

// React Compiler (экспериментальный) — автоматическая мемоизация
// Компилятор сам определяет, что нужно мемоизировать
// и генерирует оптимизированный код
// В будущем ручной useMemo/useCallback может стать не нужен

Итог

Мемоизация — это оптимизация, а не необходимость по умолчанию. Применяйте её когда:

  • Вычисление действительно дорогое (O(n²) и выше, большие массивы).
  • Стабильная ссылка критична для React.memo в дочерних компонентах.
  • Объект/функция передаётся в зависимости useEffect и вызывает лишние срабатывания.

Не применяйте когда:

  • Вычисление тривиально (конкатенация строк, простые арифметические операции).
  • Компонент не обёрнут в React.memo и не передаётся в зависимости.
  • Накладные расходы на мемоизацию превышают выгоду.

Профилируйте с помощью React DevTools Profiler перед оптимизацией — не оптимизируйте «на всякий случай».

Вопрос 10. Когда React решает, что должен произойти рендер компонента?

Таймкод: 00:17:56

Ответ собеседника: Правильный. Рендер происходит при изменении контекста (если компонент на него подписан), при изменении стейта, при изменении пропсов, а также при перерендере родительского компонента — даже если пропсы не изменились, дочерний компонент всё равно перерисовывается.

Правильный ответ:

Ответ собеседника верный и охватывает все основные триггеры рендера. Разберём тему глубже — как именно React принимает решение о рендере, что происходит внутри, и как это влияет на производительность.

Триггеры рендера в React

1. Изменение состояния (useState, useReducer)

// Каждый вызов setState с новым значением запускает рендер
const Counter = () => {
const [count, setCount] = useState(0);
console.log('Counter render'); // Лог покажет каждый рендер

// Рендер происходит
const handleIncrement = () => setCount(count + 1);

// Рендер НЕ происходит — значение не изменилось
const handleNoop = () => setCount(count); // React сравнивает через Object.is

return (
<div>
<span>{count}</span>
<button onClick={handleIncrement}>+1</button>
<button onClick={handleNoop}>No-op</button>
</div>
);
};

// useReducer — аналогично, dispatch запускает рендер
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + 1 };
case 'reset':
return { ...state, count: 0 };
default:
return state; // Возврат того же state — рендер не произойдёт
}
};

const CounterWithReducer = () => {
const [state, dispatch] = useReducer(reducer, { count: 0 });

// dispatch с action, который возвращает тот же state — рендер не произойдёт
return <button onClick={() => dispatch({ type: 'noop' })}>+1</button>;
};

2. Изменение пропсов

// Родитель передаёт новые пропсы — дочерний компонент рендерится
const Parent = () => {
const [count, setCount] = useState(0);

return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
{/* Child получает count как пропс — рендерится при каждом изменении */}
<Child value={count} />
</div>
);
};

const Child = ({ value }: { value: number }) => {
console.log('Child render'); // Срабатывает при каждом изменении value
return <span>{value}</span>;
};

3. Ререндер родителя — даже если пропсы не изменились

// Это ключевое поведение React по умолчанию
const Parent = () => {
const [count, setCount] = useState(0);

return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
{/* StaticChild НЕ получает count, но ВСЁ РАВНО рендерится */}
<StaticChild />
</div>
);
};

const StaticChild = () => {
console.log('StaticChild render'); // Срабатывает при каждом рендере Parent!
return <div>I am static</div>;
};

// Это происходит потому что React не знает, зависит ли StaticChild
// от состояния родителя (контекст, глобальные переменные и т.д.)
// Поэтому перестраховывается и рендерит всё поддерево

4. Изменение контекста

// Компонент, подписанный на контекст, рендерится при изменении его значения
const ThemeContext = createContext<'light' | 'dark'>('light');

const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
const [theme, setTheme] = useState<'light' | 'dark'>('light');

return (
<ThemeContext.Provider value={theme}>
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
Toggle
</button>
{children}
</ThemeContext.Provider>
);
};

const ThemedButton = () => {
const theme = useContext(ThemeContext);
console.log('ThemedButton render'); // Срабатывает при каждом изменении theme

return <button className={`btn-${theme}`}>Themed</button>;
};

// Проблема: ВСЕ потребители контекста рендерятся при ЛЮБОМ изменении value
// Даже если компонент использует только часть контекста

Что происходит внутри React при рендере

// Рендер в React — это вызов функции компонента
// React вызывает функцию, получает JSX (React Element tree),
// сравнивает с предыдущим деревом (reconciliation)
// и применяет минимальные изменения к DOM

const RenderCycle = () => {
const [count, setCount] = useState(0);

// 1. Вызов функции компонента (render phase)
// React вызывает RenderCycle() и получает:
// {
// type: 'div',
// props: {
// children: [
// { type: 'span', props: { children: 0 } },
// { type: 'button', props: { onClick: fn, children: '+' } }
// ]
// }
// }

// 2. Reconciliation — сравнение с предыдущим деревом
// React видит, что изменился только текст в span (0 → 1)

// 3. Commit phase — применение изменений к DOM
// React обновляет только текстовый узел в span

return (
<div>
<span>{count}</span> {/* Это обновится */}
<button onClick={() => setCount(c => c + 1)}>+</button>
</div>
);
};

Как предотвратить лишние рендеры

1. React.memo — мемоизация компонента

// React.memo предотвращает рендер, если пропсы не изменились
const MemoizedChild = React.memo(({ value }: { value: number }) => {
console.log('MemoizedChild render');
return <span>{value}</span>;
});

const Parent = () => {
const [count, setCount] = useState(0);
const [other, setOther] = useState(0);

return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<button onClick={() => setOther(o => o + 1)}>Other: {other}</button>
{/* MemoizedChild НЕ рендерится при изменении other */}
<MemoizedChild value={count} />
</div>
);
};

// React.memo с кастомным сравнем
const CustomMemoChild = React.memo(
({ user, onSelect }: { user: User; onSelect: (id: string) => void }) => {
return <div onClick={() => onSelect(user.id)}>{user.name}</div>;
},
(prevProps, nextProps) => {
// Рендер только если изменились значимые поля
return prevProps.user.id === nextProps.user.id &&
prevProps.user.name === nextProps.user.name;
}
);

2. Разделение контекста — один контекст = одна ответственность

// ❌ Плохо: один контекст на всё — все потребители рендерятся при любом изменении
const AppContext = createContext({
theme: 'dark',
user: null,
locale: 'en',
});

// ✅ Хорошо: разделённые контексты
const ThemeContext = createContext<'light' | 'dark'>('light');
const UserContext = createContext<User | null>(null);
const LocaleContext = createContext('en');

// Теперь компонент, использующий только theme,
// НЕ рендерится при изменении user или locale
const ThemedButton = () => {
const theme = useContext(ThemeContext);
// Рендер только при изменении theme
return <button className={`btn-${theme}`}>Click</button>;
};

3. Подписка на часть контекста через селектор

// Создаём хук с селектором для гранулярных подписок
const useUserSelector = <T,>(selector: (user: User | null) => T): T => {
const user = useContext(UserContext);
return useMemo(() => selector(user), [user, selector]);
};

// Компонент подписан только на имя пользователя
const UserName = () => {
const name = useUserSelector(user => user?.name ?? 'Guest');
// Рендер только при изменении name, а не всего user
return <span>{name}</span>;
};

4. Поднятие состояния вниз (colocation)

// ❌ Плохо: состояние высоко — всё дерево рендерится
const Parent = () => {
const [search, setSearch] = useState('');

return (
<div>
<input value={search} onChange={e => setSearch(e.target.value)} />
<ExpensiveList /> {/* Рендерится при каждом вводе символа */}
<AnotherExpensive /> {/* Тоже рендерится */}
</div>
);
};

// ✅ Хорошо: состояние рядом с потребителем
const Parent = () => {
return (
<div>
<SearchInput /> {/* Состояние внутри — не влияет на братьев */}
<ExpensiveList />
<AnotherExpensive />
</div>
);
};

const SearchInput = () => {
const [search, setSearch] = useState('');
return <input value={search} onChange={e => setSearch(e.target.value)} />;
};

5. Использование children для изоляции

// children не рендерятся при изменении состояния родителя
const Parent = () => {
const [count, setCount] = useState(0);

return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
{/* StaticContent передаётся как children — не рендерится */}
<Layout>
<StaticContent />
</Layout>
</div>
);
};

const Layout = ({ children }: { children: React.ReactNode }) => {
// Layout может менять своё состояние — children не пострадают
return <div className="layout">{children}</div>;
};

Визуализация цикла рендера

setState / new props / context change


┌─────────────────────┐
│ Render Phase │
│ (может быть │
│ прерван React) │
│ │
│ 1. Вызов функции │
│ компонента │
│ 2. Получение JSX │
│ 3. Создание Fiber │
│ узлов │
└────────┬────────────┘


┌─────────────────────┐
│ Reconciliation │
│ (diffing) │
│ │
│ 1. Сравнение │
│ старого и │
│ нового дерева │
│ 2. Вычисление │
│ минимальных │
│ изменений │
└────────┬────────────┘


┌─────────────────────┐
│ Commit Phase │
│ (синхронный, │
│ не прерываемый) │
│ │
│ 1. Применение │
│ изменений к DOM │
│ 2. Вызов │
│ useLayoutEffect │
│ 3. Вызов useEffect │
│ (после paint) │
└─────────────────────┘

Профилирование рендеров

// React DevTools Profiler — основной инструмент для анализа рендеров

// Программное профилирование через onRender callback
import { Profiler } from 'react';

const App = () => (
<Profiler
id="App"
onRender={(id, phase, actualDuration, baseDuration, startTime, commitTime) => {
console.log({
id, // идентификатор профилируемого компонента
phase, // "mount" или "update"
actualDuration, // фактическое время рендера
baseDuration, // время рендера без мемоизации
startTime, // время начала рендера
commitTime, // время завершения коммита
});

if (actualDuration > 16) {
console.warn(`Slow render detected: ${id} took ${actualDuration}ms`);
// 16ms = 60fps, больше — видимая задержка
}
}}
>
<ExpensiveComponent />
</Profiler>
);

Ключевые принципы

  • React по умолчанию рендерит всё поддерево — это безопасное поведение, но может быть неэффективным.
  • React.memo — оптимизация, а не необходимость — применяйте только когда профилирование показывает проблему.
  • Разделение контекста — самый эффективный способ уменьшить количество рендеров в больших приложениях.
  • Colocation — размещайте состояние как можно ближе к потребителям.
  • Профилируйте перед оптимизацией — не оптимизируйте «на всякий случай», это усложняет код без пользы.

Вопрос 11. Как предотвратить перерендер дочернего компонента, если родитель перерендеривается, а пропсы не изменились?

Таймкод: 00:18:30

Ответ собеседника: Правильный. Использовать React.memo — высший порядок компонента (HOC), который оборачивает дочерний компонент. Можно также передать второй аргумент с кастомной логикой сравнения. Это аналог shouldComponentUpdate из классовых компонентов.

Правильный ответ:

Ответ собеседника верный и точный. React.memo — действительно основной инструмент для решения этой проблемы. Разберём тему глубже — различные подходы, их нюансы и когда каждый из них предпочтительнее.

React.memo — основной инструмент

// React.memo выполняет поверхностное сравнение пропсов
// Если пропсы не изменились — рендер пропускается

// Без React.memo: рендерится при каждом рендере родителя
const ChildWithoutMemo = ({ user }: { user: User }) => {
console.log('ChildWithoutMemo render');
return <div>{user.name}</div>;
};

// С React.memo: рендерится только при изменении пропсов
const ChildWithMemo = React.memo(({ user }: { user: User }) => {
console.log('ChildWithMemo render');
return <div>{user.name}</div>;
});

// Демонстрация разницы
const Parent = () => {
const [count, setCount] = useState(0);
const [user] = useState({ name: 'John', email: 'john@example.com' });

return (
<div>
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
{/* Рендерится при каждом нажатии — count изменился */}
<ChildWithoutMemo user={user} />

{/* НЕ рендерится — user не изменился */}
<ChildWithMemo user={user} />
</div>
);
};

Поверхностное сравнение в React.memo — подводные камни

// React.memo использует Object.is для сравнения каждого пропса
// Это работает для примитивов, но не для объектов и функций

// ❌ Проблема: объекты и функции — новые ссылки при каждом рендере
const Parent = () => {
const [count, setCount] = useState(0);

// Новый объект при каждом рендере
const config = { theme: 'dark', locale: 'en' };

// Новая функция при каждом рендере
const handleClick = () => console.log('clicked');

return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
{/* React.memo НЕ поможет — config и handleClick — новые ссылки */}
<MemoizedChild config={config} onClick={handleClick} />
</div>
);
};

const MemoizedChild = React.memo(({ config, onClick }) => {
console.log('MemoizedChild render'); // Всегда срабатывает!
return <div>{config.theme}</div>;
});

// ✅ Решение: мемоизировать объекты и функции в родителе
const ParentFixed = () => {
const [count, setCount] = useState(0);

// Мемоизированный объект
const config = useMemo(() => ({ theme: 'dark', locale: 'en' }), []);

// Мемоизированная функция
const handleClick = useCallback(() => console.log('clicked'), []);

return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
{/* Теперь React.memo работает — ссылки стабильны */}
<MemoizedChild config={config} onClick={handleClick} />
</div>
);
};

Кастомная функция сравнения

// Второй аргумент React.memo — функция сравнения
// Возвращает true если пропсы равны (рендер пропустить)
// Возвращает false если пропсы изменились (рендерить)

interface UserCardProps {
user: User;
isSelected: boolean;
onSelect: (id: string) => void;
}

const UserCard = React.memo(
({ user, isSelected, onSelect }: UserCardProps) => {
console.log(`UserCard render: ${user.id}`);
return (
<div
className={isSelected ? 'selected' : ''}
onClick={() => onSelect(user.id)}
>
<span>{user.name}</span>
<span>{user.email}</span>
<span>{user.role}</span>
</div>
);
},
(prevProps, nextProps) => {
// Кастомная логика сравнения
// true = пропсы равны, рендер не нужен
// false = пропсы изменились, нужен рендер

return (
prevProps.user.id === nextProps.user.id &&
prevProps.user.name === nextProps.user.name &&
prevProps.user.email === nextProps.user.email &&
prevProps.isSelected === nextProps.isSelected &&
// onSelect сравниваем по ссылке — если мемоизирован, ок
prevProps.onSelect === nextProps.onSelect
);
}
);

// Пример с глубоким сравением для сложных объектов
const DeepCompareMemo = React.memo(
({ data }: { data: ComplexData }) => {
return <DataVisualizer data={data} />;
},
(prevProps, nextProps) => {
// Глубокое сравнение для вложенных объектов
// В реальном проекте используйте lodash.isEqual или fast-deep-equal
return deepEqual(prevProps.data, nextProps.data);
}
);

Альтернативные подходы к React.memo

1. Использование children для изоляции

// children не перерендериваются при изменении состояния родителя
// Это самый простой способ изоляции без React.memo

const Parent = () => {
const [count, setCount] = useState(0);

return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
{/* StaticContent — как children, не рендерится */}
<LayoutComponent>
<StaticContent />
</LayoutComponent>
</div>
);
};

const LayoutComponent = ({ children }: { children: React.ReactNode }) => {
// LayoutComponent может иметь своё состояние
// children не пострадают
return <div className="layout">{children}</div>;
};

2. Поднятие состояния вниз (colocation)

// ❌ Плохо: состояние высоко — всё дерево страдает
const Parent = () => {
const [search, setSearch] = useState('');

return (
<div>
<input value={search} onChange={e => setSearch(e.target.value)} />
<ExpensiveList /> {/* Рендерится при каждом вводе */}
<AnotherComponent /> {/* Тоже рендерится */}
</div>
);
};

// ✅ Хорошо: состояние рядом с потребителем
const Parent = () => {
return (
<div>
<SearchInput /> {/* Состояние внутри — изолировано */}
<ExpensiveList />
<AnotherComponent />
</div>
);
};

const SearchInput = () => {
const [search, setSearch] = useState('');
return <input value={search} onChange={e => setSearch(e.target.value)} />;
};

3. Разделение контекста

// ❌ Плохо: один контекст — все потребители рендерятся
const AppContext = createContext({
theme: 'dark',
user: null,
locale: 'en',
});

// ✅ Хорошо: разделённые контексты для гранулярных обновлений
const ThemeContext = createContext<'light' | 'dark'>('light');
const UserContext = createContext<User | null>(null);
const LocaleContext = createContext('en');

// Компонент подписан только на тему — не рендерится при изменении пользователя
const ThemedButton = () => {
const theme = useContext(ThemeContext);
return <button className={`btn-${theme}`}>Click</button>;
};

// Компонент подписан только на пользователя — не рендерится при изменении темы
const UserName = () => {
const user = useContext(UserContext);
return <span>{user?.name}</span>;
};

4. Использование селекторов с контекстом

// Создаём хук с селектором для гранулярных подписок
function useStoreSelector<T>(selector: (state: StoreState) => T): T {
const state = useContext(StoreContext);
return useMemo(() => selector(state), [state, selector]);
}

// Компонент подписан только на конкретное поле
const UserName = () => {
const name = useStoreSelector(state => state.user?.name ?? 'Guest');
// Рендер только при изменении name, а не всего state
return <span>{name}</span>;
};

const CartCount = () => {
const count = useStoreSelector(state => state.cart.items.length);
// Рендер только при изменении количества товаров
return <span>{count}</span>;
};

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

// Подход 1: React.memo + useCallback/useMemo
// Когда: дочерний компонент получает объекты/функции в пропсах
// Плюсы: точный контроль, стандартный подход
// Минусы: нужно мемоизировать все нестабильные пропсы

const Approach1 = () => {
const config = useMemo(() => ({ theme: 'dark' }), []);
const handler = useCallback(() => {}, []);
return <MemoizedChild config={config} onAction={handler} />;
};

// Подход 2: children
// Когда: дочерний компонент не зависит от состояния родителя
// Плюсы: простота, не нужно React.memo
// Минусы: не всегда подходит по архитектуре

const Approach2 = () => {
return (
<Layout>
<StaticContent /> {/* Не рендерится при изменении состояния Layout */}
</Layout>
);
};

// Подход 3: Colocation
// Когда: состояние нужно только в одном месте
// Плюсы: естественная изоляция, простота
// Минусы: не подходит для глобального состояния

const Approach3 = () => {
return (
<div>
<SearchInput /> {/* Состояние внутри */}
<StaticList />
</div>
);
};

// Подход 4: Разделение контекста
// Когда: много компонентов подписаны на разные части состояния
// Плюсы: гранулярные обновления, масштабируемость
// Минусы: больше контекстов — сложнее структура

const Approach4 = () => {
return (
<ThemeContext.Provider value={theme}>
<UserContext.Provider value={user}>
<ThemedUserButton />
</UserContext.Provider>
</ThemeContext.Provider>
);
};

Когда React.memo не поможет

// 1. Компонент использует контекст
const ContextConsumer = React.memo(() => {
const theme = useContext(ThemeContext);
// Рендерится при изменении контекста, несмотря на React.memo
return <div>{theme}</div>;
});

// 2. Комponent использует children
const WithChildren = React.memo(({ children }: { children: React.ReactNode }) => {
// children — новые пропсы при каждом рендере родителя
return <div>{children}</div>;
});

// 3. Пропсы всегда новые (не мемоизированы)
const Parent = () => {
return (
<MemoizedChild
style={{ color: 'red' }} // Новый объект
onClick={() => {}} // Новая функция
data={[1, 2, 3]} // Новый массив
/>
);
};

Рекомендации по применению

  • React.memo — применяйте для компонентов с дорогим рендером (большие списки, сложные вычисления, графики).
  • children — используйте когда дочерний компонент статичен и не зависит от состояния родителя.
  • Colocation — первый выбор при проектировании: размещайте состояние ближе к потребителям.
  • Разделение контекста — когда у вас большой контекст с множеством независимых полей.
  • Профилируйте — используйте React DevTools Profiler для выявления реальных узких мест перед оптимизацией.

Главное правило: не оптимизируйте «на всякий случай». React достаточно быстр в большинстве случаев. Оптимизация без профилирования часто усложняет код без реальной пользы.

Вопрос 12. Что произойдёт, если родитель передаёт callback в дочерний компонент без обёртки в useCallback, и как это предотвратить?

Таймкод: 00:19:19

Ответ собеседника: Правильный. Дочерний компонент будет перерисовываться, потому что при каждом рендере родителя создаётся новая ссылка на функцию. Для предотвращения необходимо обернуть callback в useCallback, чтобы сохранить стабильную ссылку между рендерами. Без мемоизации функций React.memo становится бесполезным.

Правильный ответ:

Ответ собеседника точный и полный. Это один из самых распространённых источников проблем с производительностью в React-приложениях. Разберём тему глубже — почему это происходит на уровне JavaScript, какие есть решения и какие нюансы нужно учитывать.

Корень проблемы: референциальное равенство в JavaScript

// В JavaScript функции — это объекты
// Каждое объявление создаёт новый объект в памяти

const fn1 = () => console.log('hello');
const fn2 = () => console.log('hello');

console.log(fn1 === fn2); // false — разные ссылки
console.log(fn1 === fn1); // true — та же ссылка

// То же самое с объектами и массивами
const obj1 = { name: 'John' };
const obj2 = { name: 'John' };
console.log(obj1 === obj2); // false — разные ссылки

// React использует Object.is для сравнения пропсов
// Для функций и объектов это сравнение по ссылке

Проблема на практике

// ❌ ПРОБЛЕМА: React.memo бесполезен без useCallback
const Parent = () => {
const [count, setCount] = useState(0);
const [text, setText] = useState('');

// Новая функция при каждом рендере — новая ссылка
const handleClick = () => {
console.log('clicked', count);
};

// Новая функция при каждом рендере — новая ссылка
const handleChange = (value: string) => {
setText(value);
};

return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>

{/* ExpensiveChild рендерится при каждом изменении count,
даже если получает только handleChange, который не зависит от count */}
<ExpensiveChild onClick={handleClick} onChange={handleChange} />
</div>
);
};

const ExpensiveChild = React.memo(({ onClick, onChange }: {
onClick: () => void;
onChange: (value: string) => void;
}) => {
console.log('ExpensiveChild render'); // Срабатывает при КАЖДОМ рендере Parent!

return (
<div>
<button onClick={onClick}>Action</button>
<input onChange={e => onChange(e.target.value)} />
</div>
);
});

// Почему это происходит:
// 1. Parent рендерится (count изменился)
// 2. handleClick — новая функция (новая ссылка)
// 3. handleChange — новая функция (новая ссылка)
// 4. React.memo сравнивает пропсы: onClick (было) !== onClick (стало) → true
// 5. Рендер ExpensiveChild не пропускается

Решение с useCallback

// ✅ РЕШЕНИЕ: useCallback сохраняет стабильную ссылку
const Parent = () => {
const [count, setCount] = useState(0);
const [text, setText] = useState('');

// Стабильная ссылка — функция пересоздаётся только при изменении count
const handleClick = useCallback(() => {
console.log('clicked', count);
}, [count]); // count в зависимостях — он используется внутри

// Стабильная ссылка — функция создаётся один раз
const handleChange = useCallback((value: string) => {
setText(value);
}, []); // Пустые зависимости — setText стабилен

return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>

{/* ExpensiveChild НЕ рендерится при изменении count,
потому что handleChange имеет стабильную ссылку */}
<ExpensiveChild onClick={handleClick} onChange={handleChange} />
</div>
);
};

Важный нюанс: зависимости useCallback

// ❌ ОШИБКА: Забыли зависимость — замыкание на устаревшем значении
const BadCallback = () => {
const [count, setCount] = useState(0);

// count не в зависимостях — замыкание всегда на count = 0
const handleClick = useCallback(() => {
console.log(count); // Всегда выводит 0!
setCount(count + 1); // Всегда устанавливает 1!
}, []); // ❌ Забыли count в зависимостях

return <button onClick={handleClick}>Count: {count}</button>;
};

// ✅ ПРАВИЛЬНО: Все используемые переменные в зависимостях
const GoodCallback = () => {
const [count, setCount] = useState(0);

const handleClick = useCallback(() => {
console.log(count); // Актуальное значение
setCount(count + 1); // Корректное обновление
}, [count]); // ✅ count в зависимостях

return <button onClick={handleClick}>Count: {count}</button>;
};

// ✅ ЕЩЁ ЛУЧШЕ: Функциональное обновление — не нужна зависимость от count
const BestCallback = () => {
const [count, setCount] = useState(0);

// setCount с функцией — не зависит от текущего count
const handleClick = useCallback(() => {
setCount(prev => prev + 1); // Функциональное обновление
}, []); // ✅ Пустые зависимости — setCount стабилен

return <button onClick={handleClick}>Count: {count}</button>;
};

Когда useCallback с функциональным обновлением — лучший выбор

const Counter = () => {
const [count, setCount] = useState(0);

// Все эти функции имеют стабильные ссылки на всё время жизни компонента
const increment = useCallback(() => setCount(c => c + 1), []);
const decrement = useCallback(() => setCount(c => c - 1), []);
const reset = useCallback(() => setCount(0), []);
const double = useCallback(() => setCount(c => c * 2), []);

return (
<div>
<span>{count}</span>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
<button onClick={reset}>Reset</button>
<button onClick={double}>Double</button>
{/* ChildComponent никогда не перерендерится из-за этих коллбэков */}
<ChildComponent onIncrement={increment} onDecrement={decrement} />
</div>
);
};

Полный пример: список с оптимизацией

interface Todo {
id: string;
text: string;
completed: boolean;
}

const TodoList: React.FC = () => {
const [todos, setTodos] = useState<Todo[]>([]);
const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all');

// ✅ Все коллбэки мемоизированы с правильными зависимостями
const handleToggle = useCallback((id: string) => {
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
}, []); // setTodos стабилен — пустые зависимости

const handleDelete = useCallback((id: string) => {
setTodos(prev => prev.filter(todo => todo.id !== id));
}, []);

const handleEdit = useCallback((id: string, newText: string) => {
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, text: newText } : todo
)
);
}, []);

const handleAdd = useCallback((text: string) => {
setTodos(prev => [...prev, {
id: crypto.randomUUID(),
text,
completed: false,
}]);
}, []);

// Отфильтрованный список тоже мемоизирован
const filteredTodos = useMemo(() => {
switch (filter) {
case 'active':
return todos.filter(t => !t.completed);
case 'completed':
return todos.filter(t => t.completed);
default:
return todos;
}
}, [todos, filter]);

return (
<div>
<FilterTabs current={filter} onChange={setFilter} />
<TodoInput onAdd={handleAdd} />
<ul>
{filteredTodos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle}
onDelete={handleDelete}
onEdit={handleEdit}
/>
))}
</ul>
</div>
);
};

// TodoItem обёрнут в React.memo — рендерится только при изменении пропсов
const TodoItem = React.memo<{
todo: Todo;
onToggle: (id: string) => void;
onDelete: (id: string) => void;
onEdit: (id: string, text: string) => void;
}>(({ todo, onToggle, onDelete, onEdit }) => {
console.log(`TodoItem render: ${todo.id}`);

return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span>{todo.text}</span>
<button onClick={() => onDelete(todo.id)}>Delete</button>
</li>
);
});

Альтернатива useCallback: передача диспатч-функции

// Вместо мемоизации коллбэков — передаём dispatch
// Это работает с useReducer

type Action =
| { type: 'toggle'; id: string }
| { type: 'delete'; id: string }
| { type: 'edit'; id: string; text: string }
| { type: 'add'; text: string };

const todoReducer = (state: Todo[], action: Action): Todo[] => {
switch (action.type) {
case 'toggle':
return state.map(todo =>
todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
);
case 'delete':
return state.filter(todo => todo.id !== action.id);
case 'edit':
return state.map(todo =>
todo.id === action.id ? { ...todo, text: action.text } : todo
);
case 'add':
return [...state, {
id: crypto.randomUUID(),
text: action.text,
completed: false,
}];
default:
return state;
}
};

const TodoListWithReducer: React.FC = () => {
const [todos, dispatch] = useReducer(todoReducer, []);

// dispatch имеет стабильную ссылку — не нужен useCallback!
// Передаём dispatch напрямую — React.memo работает

return (
<ul>
{todos.map(todo => (
<TodoItemWithDispatch
key={todo.id}
todo={todo}
dispatch={dispatch} // dispatch стабилен!
/>
))}
</ul>
);
};

const TodoItemWithDispatch = React.memo<{
todo: Todo;
dispatch: React.Dispatch<Action>;
}>(({ todo, dispatch }) => {
// Компонент сам формирует action и вызывает dispatch
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={() => dispatch({ type: 'toggle', id: todo.id })}
/>
<span>{todo.text}</span>
<button onClick={() => dispatch({ type: 'delete', id: todo.id })}>
Delete
</button>
</li>
);
});

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

// Подход 1: useCallback для каждого коллбэка
// Плюсы: явный контроль, понятные зависимости
// Минусы: много бойлплейта, нужно следить за зависимостями

const handleToggle = useCallback((id: string) => {
setTodos(prev => prev.map(t =>
t.id === id ? { ...t, completed: !t.completed } : t
));
}, []);

// Подход 2: useReducer + dispatch
// Пlus: dispatch стабилен, меньше кода, централизованная логика
// Минусы: сложнее для простых случаев, нужно определять actions

dispatch({ type: 'toggle', id });

// Подход 3: Передача setState напрямую
// Пlus: минимум кода
// Минусы: дочерний компонент знает о структуре состояния

const Parent = () => {
const [todos, setTodos] = useState<Todo[]>([]);
// setTodos стабилен — можно передавать напрямую
return <Child setTodos={setTodos} />;
};

// Подход 4: Использование кастомного хука
// Пlus: инкапсуляция логики, переиспользование
// Минусы: дополнительная абстракция

const useTodos = () => {
const [todos, setTodos] = useState<Todo[]>([]);

const toggle = useCallback((id: string) => {
setTodos(prev => prev.map(t =>
t.id === id ? { ...t, completed: !t.completed } : t
));
}, []);

const remove = useCallback((id: string) => {
setTodos(prev => prev.filter(t => t.id !== id));
}, []);

return { todos, toggle, remove };
};

Когда useCallback НЕ нужен

// 1. Функция не передаётся в дочерние компоненты
const LocalHandler = () => {
const [value, setValue] = useState('');

// useCallback не нужен — функция используется только здесь
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
};

return <input value={value} onChange={handleChange} />;
};

// 2. Дочерний компонент не обёрнут в React.memo
const ParentWithoutMemo = () => {
const [count, setCount] = useState(0);

// useCallback бесполезен — Child рендерится в любом случае
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []);

return <Child onClick={handleClick} />; // Child без React.memo
};

// 3. Функция передаётся только в зависимости useEffect
const EffectOnly = () => {
const [data, setData] = useState(null);

// Если функция нужна только в useEffect — useCallback избыточен
useEffect(() => {
const fetchData = async () => {
const result = await api.fetch();
setData(result);
};
fetchData();
}, []);

return <div>{data}</div>;
};

Итог

  • Проблема: каждая функция в JavaScript — новый объект с новой ссылкой. При каждом рендере родителя создаются новые функции → React.memo видит новые пропсы → рендер не пропускается.
  • Решение: useCallback сохраняет стабильную ссылку на функцию между рендерами, пока не изменятся зависимости.
  • Лучшая практика: используйте функциональное обновление состояния (setCount(c => c + 1)) — это позволяет использовать пустые зависимости в useCallback.
  • Альтернатива: useReducer с передачей dispatch — dispatch стабилен по умолчанию, не требует useCallback.
  • Главное правило: не оборачивайте все функции в useCallback без разбора — только те, которые передаются в мемоизированные дочерние компоненты.

Вопрос 13. Как решить проблему рендеринга большого списка (1000 элементов), который вызывает лаги?

Таймкод: 00:20:35

Ответ собеседника: Правильный. Использовать виртуализацию списка (виртуальный список). Работает на примере библиотеки tanstack/virtual. Суть: есть область видимости (viewport) и estimated size — ожидаемая высота элемента списка. Подсчитывается количество элементов на основе массива данных. Отрисовываются только элементы в области видимости экрана (например, ~10 штук). При скролле размонтируются элементы, вышедшие за пределы видимости (сверху), и монтируются новые (снизу — 11, 12, 13 и т.д.). Также можно решить пагинацией на бэкенде.

Правильный ответ:

Ответ собеседника отличный — он точно описывает механизм виртуализации и упоминает конкретную библиотеку. Разберём тему глубже — почему большие списки тормозят, как именно работает виртуализация внутри, какие есть библиотеки и какие дополнительные оптимизации можно применить.

Почему большие списки тормозят

// ❌ Проблема: рендеринг 1000 элементов одновременно
const SlowList = ({ items }: { items: Item[] }) => {
return (
<div style={{ height: '600px', overflow: 'auto' }}>
{items.map(item => (
<ExpensiveItem key={item.id} item={item} />
))}
</div>
);
};

// Что происходит при рендере 1000 элементов:
// 1. React создаёт 1000 Fiber узлов в памяти
// 2. React вычисляет layout для 1000 элементов
// 3. Браузер создаёт 1000 DOM-узлов
// 4. Браузер вычисляет стили для 1000 элементов
// 5. Браузер выполняет layout (reflow) для 1000 элементов
// 6. Браузер отрисовывает (repaint) видимую часть

// Итого: ~1000 DOM-узлов, ~1000 вычислений стилей, ~1000 layout-вычислений
// При скролле: повторный layout + repaint для видимой области

// Проблемы:
// - Инициальный рендер: 200-500ms (заметная задержка)
// - Потребление памяти: ~50-100MB для 1000 сложных элементов
// - Скролл: 16ms на кадр (60fps) — любая задержка видна
// - Перерендер при изменении одного элемента: все 1000 элементов

Виртуализация — принцип работы

// Виртуализация: рендерим только видимые элементы
// + небольшой буфер сверху и снизу для плавного скролла

// Визуализация:
//
// ┌─────────────────────────┐
// │ Невидимая область │ ← Не рендерится
// │ (элементы 0-49) │
// ├─────────────────────────┤ ← scrollTop
// │ Буфер (элементы 50-54) │ ← Рендерится (подготовка)
// ├─────────────────────────┤
// │ │
// │ Видимая область │ ← Рендерится (~10-15 элементов)
// │ (элементы 55-69) │
// │ │
// ├─────────────────────────┤
// │ Буфер (элементы 70-74) │ ← Рендерится (подготовка)
// ├─────────────────────────┤ ← scrollTop + viewportHeight
// │ Невидимая область │ ← Не рендерится
// │ (элементы 75-999) │
// └─────────────────────────┘

// Итого: рендерится ~20-25 элементов вместо 1000
// Выигрыш: ~40-50x меньше DOM-узлов, ~40-50x меньше вычислений

Реализация с TanStack Virtual

import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';

interface VirtualListProps {
items: Item[];
itemHeight: number; // Фиксированная высота элемента
overscan?: number; // Количество дополнительных элементов для рендера
}

const VirtualList: React.FC<VirtualListProps> = ({
items,
itemHeight,
overscan = 5,
}) => {
const parentRef = useRef<HTMLDivElement>(null);

const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => itemHeight,
overscan, // Буфер: 5 элементов сверху и снизу
// Для элементов переменной 高度:
// measureElement: (element) => element.getBoundingClientRect().height,
});

// Виртуальные элементы — только те, что нужно рендерить
const virtualItems = virtualizer.getVirtualItems();

return (
<div
ref={parentRef}
style={{
height: '600px',
overflow: 'auto',
position: 'relative',
}}
>
{/* Контейнер полной высоты для корректного скроллбара */}
<div
style={{
height: virtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{virtualItems.map(virtualItem => (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: virtualItem.size,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ListItem item={items[virtualItem.index]} />
</div>
))}
</div>
</div>
);
};

Виртуализация с переменной высотой элементов

// Когда элементы имеют разную высоту
// TanStack Virtual автоматически измеряет и кэширует размеры

const VariableHeightList: React.FC<{ items: VariableItem[] }> = ({ items }) => {
const parentRef = useRef<HTMLDivElement>(null);

const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: (index) => {
// Предполагаемая высота на основе типа контента
const item = items[index];
if (item.type === 'text') return 60;
if (item.type === 'image') return 200;
if (item.type === 'video') return 300;
return 80;
},
overscan: 10,
});

const virtualItems = virtualizer.getVirtualItems();

return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualItems.map(virtualItem => (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
{items[virtualItem.index].type === 'text' && (
<TextItem content={items[virtualItem.index].content} />
)}
{items[virtualItem.index].type === 'image' && (
<ImageItem src={items[virtualItem.index].src} />
)}
{items[virtualItem.index].type === 'video' && (
<VideoItem src={items[virtualItem.index].src} />
)}
</div>
))}
</div>
</div>
);
};

react-window — классическая библиотека

import { FixedSizeList, VariableSizeList } from 'react-window';
import { memo } from 'react';

interface ItemData {
items: Item[];
onSelect: (id: string) => void;
}

// Мемоизированный компонент строки
const Row = memo(({ index, style, data }: { index: number; style: React.CSSProperties; data: ItemData }) => {
const item = data.items[index];

return (
<div style={style} onClick={() => data.onSelect(item.id)}>
<div className="item-content">
<span className="item-name">{item.name}</span>
<span className="item-description">{item.description}</span>
</div>
</div>
);
});

// Список с фиксированной высотой
const FixedHeightList: React.FC<{ items: Item[] }> = ({ items }) => {
const handleSelect = useCallback((id: string) => {
console.log('Selected:', id);
}, []);

const itemData = useMemo(() => ({
items,
onSelect: handleSelect,
}), [items, handleSelect]);

return (
<FixedSizeList
height={600}
width="100%"
itemCount={items.length}
itemSize={80}
itemData={itemData}
overscanCount={5}
>
{Row}
</FixedSizeList>
);
};

react-virtuoso — продвинутая виртуализация

import { Virtuoso } from 'react-virtuoso';

// react-virtuoso автоматически определяет размеры элементов
// и поддерживает бесконечную прокрутку

const VirtuosoList: React.FC<{ items: Item[] }> = ({ items }) => {
return (
<Virtuoso
style={{ height: '600px' }}
data={items}
itemContent={(index, item) => (
<div style={{ padding: '10px', borderBottom: '1px solid #eee' }}>
<h3>{item.name}</h3>
<p>{item.description}</p>
</div>
)}
overscan={200} // Дополнительный рендер за пределами viewport (в пикселях)
/>
);
};

// Бесконечная прокрутка с подгрузкой
const InfiniteList: React.FC = () => {
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);

const loadMore = useCallback(async () => {
if (loading || !hasMore) return;

setLoading(true);
const newItems = await fetchItems(items.length, 20);
setItems(prev => [...prev, ...newItems]);
setHasMore(newItems.length === 20);
setLoading(false);
}, [items.length, loading, hasMore]);

return (
<Virtuoso
style={{ height: '600px' }}
data={items}
endReached={loadMore}
itemContent={(index, item) => (
<div className="item">
<h3>{item.name}</h3>
<p>{item.description}</p>
</div>
)}
components={{
Footer: () => loading ? <div>Loading...</div> : null,
}}
/>
);
};

Сравнение библиотек виртуализации

// react-window
// Плюсы: лёгкая (~3KB), стабильная, хорошо протестирована
// Минусы: нет переменной высоты из коробки, нет бесконечной прокрутки
// Размер: ~3KB gzip
// Когда: простые списки с фиксированной высотой

// react-virtuoso
// Плюсы: автоматическое определение размеров, бесконечная прокрутка,
// grouped items, sticky headers, переменная высота
// Минусы: больше размер, сложнее API
// Размер: ~8KB gzip
// Когда: сложные списки с переменной высотой, бесконечная прокрутка

// @tanstack/react-virtual
// Плюсы: фреймворк-агностичный (React, Vue, Solid, Svelte),
// переменная высота, горизонтальная виртуализация,
// grid-виртуализация, кастомные скролл-контейнеры
// Минусы: требует ручной настройки для сложных случаев
// Размер: ~4KB gzip
// Когда: максимальная гибкость, нестандартные кейсы

// Сравнительная таблица:
//
// Функция │ react-window │ react-virtuoso │ tanstack/virtual
// ─────────────────────┼──────────────┼────────────────┼─────────────────
// Фиксированная высота │ ✅ │ ✅ │ ✅
// Переменная высота │ ⚠️ │ ✅ │ ✅
// Горизонтальная │ ✅ │ ✅ │ ✅
// Grid │ ✅ │ ❌ │ ✅
// Бесконечная прокрутка│ ❌ │ ✅ │ ⚠️
// Sticky headers │ ❌ │ ✅ │ ⚠️
// Размер (gzip) │ ~3KB │ ~8KB │ ~4KB

Дополнительные оптимизации для больших списков

// 1. Мемоизация элементов списка
const MemoizedListItem = React.memo(
({ item, onSelect }: { item: Item; onSelect: (id: string) => void }) => {
return (
<div onClick={() => onSelect(item.id)}>
<h3>{item.name}</h3>
<p>{item.description}</p>
</div>
);
},
(prev, next) => prev.item.id === next.item.id && prev.item === next.item
);

// 2. Стабильные коллбэки
const StableList: React.FC<{ items: Item[] }> = ({ items }) => {
// useCallback предотвращает ре-рендер при перерендере родителя
const handleSelect = useCallback((id: string) => {
console.log('Selected:', id);
}, []);

return (
<Virtuoso
data={items}
itemContent={(index, item) => (
<MemoizedListItem item={item} onSelect={handleSelect} />
)}
/>
);
};

// 3. Дебаунс для поиска в списке
const SearchableList: React.FC<{ items: Item[] }> = ({ items }) => {
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');

useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300);
return () => clearTimeout(timer);
}, [search]);

const filteredItems = useMemo(() => {
if (!debouncedSearch) return items;
return items.filter(item =>
item.name.toLowerCase().includes(debouncedSearch.toLowerCase())
);
}, [items, debouncedSearch]);

return (
<div>
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search..."
/>
<Virtuoso
data={filteredItems}
itemContent={(index, item) => <ListItem item={item} />}
/>
</div>
);
};

// 4. Ленивая загрузка изображений
const LazyImageItem: React.FC<{ item: Item }> = ({ item }) => {
const [loaded, setLoaded] = useState(false);

return (
<div>
{!loaded && <div className="skeleton" style={{ height: 200 }} />}
<img
src={item.imageUrl}
alt={item.name}
loading="lazy"
onLoad={() => setLoaded(true)}
style={{ display: loaded ? 'block' : 'none' }}
/>
</div>
);
};

// 5. Пагинация на бэкенде для очень больших списков
const PaginatedList: React.FC = () => {
const [page, setPage] = useState(1);
const [allItems, setAllItems] = useState<Item[]>([]);
const [hasMore, setHasMore] = useState(true);

useEffect(() => {
fetchItems(page).then(newItems => {
setAllItems(prev => [...prev, ...newItems]);
setHasMore(newItems.length === 20);
});
}, [page]);

return (
<div>
<Virtuoso
data={allItems}
endReached={() => hasMore => setPage(p => p + 1)}
itemContent={(index, item) => <ListItem item={item} />}
components={{
Footer: () => hasMore ? <div>Loading more...</div> : null,
}}
/>
</div>
);
};

Итог

  • Виртуализация — основной инструмент для больших списков. Рендерим только видимые элементы + небольшой буфер.
  • Выбор библиотеки: react-window для простых случаев, react-virtuoso для сложных (переменная высота, бесконечная прокрутка), tanstack/virtual для максимальной гибкости.
  • Дополнительные оптимизации: React.memo для элементов, useCallback для коллбэков, дебаунс для поиска, ленивая загрузка изображений.
  • Пагинация на бэкенде — когда список может содержать десятки тысяч элементов, виртуализация с бесконечной подгрузкой предпочтительнее загрузки всех данных сразу.
  • Профилируйте — используйте React DevTools Profiler для измерения реального улучшения после внедрения виртуализации.

Вопрос 14. Как организуешь работу с бэкендом на фронтенде: какие подходы и инструменты используешь?

Таймкод: 00:23:15

Ответ собеседника: Правильный. Хранит URL бэкенда в environment-переменных. Использует подход с папкой services — отдельно от FSD. Пишет функции с fetch для получения данных по конкретным API-поинтам с возможностью прокидывания параметров. Функции возвращают промисы. Также использует TanStack Query (React Query), который предоставляет флаги загрузки, спиннеры, лоадинг, стратегии кэширования, мутации и запросы. Работает через Next.js с React Query.

Правильный ответ:

Ответ собеседника хороший — он описывает рабочий подход с использованием современных инструментов. Разберём тему глубже — как правильно организовать слой работы с API, какие паттерны применять и как обрабатывать ошибки.

Архитектура слоя работы с API

1. Базовая конфигурация HTTP-клиента

// api/client.ts — базовый HTTP-клиент

interface RequestConfig extends RequestInit {
params?: Record<string, string | number | boolean>;
timeout?: number;
}

class ApiClient {
private baseUrl: string;
private defaultHeaders: HeadersInit;
private defaultTimeout: number;

constructor(config: {
baseUrl: string;
headers?: HeadersInit;
timeout?: number;
}) {
this.baseUrl = config.baseUrl.replace(/\/$/, '');
this.defaultHeaders = {
'Content-Type': 'application/json',
...config.headers,
};
this.defaultTimeout = config.timeout || 10000;
}

private buildUrl(endpoint: string, params?: Record<string, string | number | boolean>): string {
const url = new URL(`${this.baseUrl}${endpoint}`);

if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.append(key, String(value));
}
});
}

return url.toString();
}

private async request<T>(
endpoint: string,
config: RequestConfig = {}
): Promise<T> {
const { params, timeout = this.defaultTimeout, ...fetchConfig } = config;

const url = this.buildUrl(endpoint, params);

// AbortController для таймаута
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);

try {
const response = await fetch(url, {
...fetchConfig,
headers: {
...this.defaultHeaders,
...fetchConfig.headers,
},
signal: controller.signal,
});

clearTimeout(timeoutId);

// Обработка ошибок HTTP
if (!response.ok) {
const errorBody = await response.json().catch(() => null);
throw new ApiError(
response.status,
errorBody?.message || response.statusText,
errorBody?.code,
errorBody
);
}

// Пустой ответ (204 No Content)
if (response.status === 204) {
return undefined as T;
}

return response.json();
} catch (error) {
clearTimeout(timeoutId);

if (error instanceof ApiError) {
throw error;
}

if (error instanceof DOMException && error.name === 'AbortError') {
throw new ApiError(408, 'Request timeout', 'TIMEOUT');
}

// Сетевые ошибки
throw new ApiError(0, 'Network error', 'NETWORK_ERROR', { originalError: error });
}
}

get<T>(endpoint: string, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, { ...config, method: 'GET' });
}

post<T>(endpoint: string, data?: unknown, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, {
...config,
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
});
}

put<T>(endpoint: string, data?: unknown, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, {
...config,
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
});
}

patch<T>(endpoint: string, data?: unknown, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, {
...config,
method: 'PATCH',
body: data ? JSON.stringify(data) : undefined,
});
}

delete<T>(endpoint: string, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, { ...config, method: 'DELETE' });
}
}

// Типизированный класс ошибки API
class ApiError extends Error {
constructor(
public status: number,
message: string,
public code?: string,
public data?: unknown
) {
super(message);
this.name = 'ApiError';
}

isNotFound(): boolean {
return this.status === 404;
}

isUnauthorized(): boolean {
return this.status === 401;
}

isForbidden(): boolean {
return this.status === 403;
}

isValidationError(): boolean {
return this.status === 422;
}

isServerError(): boolean {
return this.status >= 500;
}

isNetworkError(): boolean {
return this.status === 0;
}
}

// Создаём экземпляр клиента
export const apiClient = new ApiClient({
baseUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080',
timeout: 15000,
});

export { ApiError };

2. Слой сервисов — типобезопасные API-методы

// api/services/user.service.ts

import { apiClient } from '../client';

// Типы данных
export interface User {
id: string;
email: string;
name: string;
avatar: string | null;
role: 'user' | 'admin' | 'moderator';
createdAt: string;
updatedAt: string;
}

export interface CreateUserInput {
email: string;
name: string;
password: string;
}

export interface UpdateUserInput {
name?: string;
avatar?: string;
}

export interface UsersListResponse {
items: User[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}

export interface GetUsersParams {
page?: number;
pageSize?: number;
search?: string;
role?: string;
sortBy?: 'name' | 'email' | 'createdAt';
sortOrder?: 'asc' | 'desc';
}

// Сервис для работы с пользователями
export const userService = {
async getUsers(params?: GetUsersParams): Promise<UsersListResponse> {
return apiClient.get<UsersListResponse>('/api/users', { params });
},

async getUserById(id: string): Promise<User> {
return apiClient.get<User>(`/api/users/${id}`);
},

async getUserByEmail(email: string): Promise<User> {
return apiClient.get<User>(`/api/users/email/${encodeURIComponent(email)}`);
},

async createUser(input: CreateUserInput): Promise<User> {
return apiClient.post<User>('/api/users', input);
},

async updateUser(id: string, input: UpdateUserInput): Promise<User> {
return apiClient.patch<User>(`/api/users/${id}`, input);
},

async deleteUser(id: string): Promise<void> {
return apiClient.delete(`/api/users/${id}`);
},

async uploadAvatar(id: string, file: File): Promise<{ avatarUrl: string }> {
const formData = new FormData();
formData.append('avatar', file);

return apiClient.post(`/api/users/${id}/avatar`, formData, {
headers: {
// Убираем Content-Type — браузер сам установит с boundary
'Content-Type': undefined as unknown as string,
},
});
},
};

// api/services/auth.service.ts

export interface AuthTokens {
accessToken: string;
refreshToken: string;
expiresIn: number;
}

export interface LoginInput {
email: string;
password: string;
}

export interface RegisterInput {
email: string;
name: string;
password: string;
}

export const authService = {
async login(input: LoginInput): Promise<AuthTokens> {
return apiClient.post<AuthTokens>('/api/auth/login', input);
},

async register(input: RegisterInput): Promise<AuthTokens> {
return apiClient.post<AuthTokens>('/api/auth/register', input);
},

async refreshToken(refreshToken: string): Promise<AuthTokens> {
return apiClient.post<AuthTokens>('/api/auth/refresh', { refreshToken });
},

async logout(): Promise<void> {
return apiClient.post('/api/auth/logout');
},

async forgotPassword(email: string): Promise<void> {
return apiClient.post('/api/auth/forgot-password', { email });
},

async resetPassword(token: string, newPassword: string): Promise<void> {
return apiClient.post('/api/auth/reset-password', { token, newPassword });
},
};

3. Интеграция с TanStack Query

// api/hooks/useUsers.ts

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { userService, GetUsersParams, CreateUserInput, UpdateUserInput } from '../services/user.service';

// Ключи запросов — централизованное управление
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (params: GetUsersParams) => [...userKeys.lists(), params] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
};

// Хук для получения списка пользователей
export const useUsers = (params: GetUsersParams = {}) => {
return useQuery({
queryKey: userKeys.list(params),
queryFn: () => userService.getUsers(params),
staleTime: 5 * 60 * 1000, // 5 минут — данные считаются свежими
gcTime: 10 * 60 * 1000, // 10 минут — время хранения в кэше
placeholderData: (previousData) => previousData, // Показываем старые данные при фетче
});
};

// Хук для получения одного пользователя
export const useUser = (id: string) => {
return useQuery({
queryKey: userKeys.detail(id),
queryFn: () => userService.getUserById(id),
enabled: !!id, // Запрос только если есть id
staleTime: 2 * 60 * 1000,
});
};

// Хук для создания пользователя
export const useCreateUser = () => {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (input: CreateUserInput) => userService.createUser(input),

// Оптимистичное обновление
onMutate: async (newUser) => {
// Отменяем текущие запросы
await queryClient.cancelQueries({ queryKey: userKeys.lists() });

// Сохраняем предыдущие данные для отката
const previousLists = queryClient.getQueriesData<UsersListResponse>({
queryKey: userKeys.lists(),
});

// Оптимистично добавляем нового пользователя
queryClient.setQueriesData<UsersListResponse>(
{ queryKey: userKeys.lists() },
(old) => {
if (!old) return old;

const optimisticUser: User = {
id: `temp-${Date.now()}`,
email: newUser.email,
name: newUser.name,
avatar: null,
role: 'user',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};

return {
...old,
items: [optimisticUser, ...old.items],
total: old.total + 1,
};
}
);

return { previousLists };
},

// Откат при ошибке
onError: (error, newUser, context) => {
if (context?.previousLists) {
context.previousLists.forEach(([key, data]) => {
queryClient.setQueryData(key, data);
});
}
},

// Инвалидация после успеха
onSettled: () => {
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
},

onSuccess: (data) => {
// Кэшируем созданного пользователя
queryClient.setQueryData(userKeys.detail(data.id), data);
},
});
};

// Хук для обновления пользователя
export const useUpdateUser = () => {
const queryClient = useQueryClient();

return useMutation({
mutationFn: ({ id, input }: { id: string; input: UpdateUserInput }) =>
userService.updateUser(id, input),

onSuccess: (updatedUser) => {
// Обновляем кэш конкретного пользователя
queryClient.setQueryData(userKeys.detail(updatedUser.id), updatedUser);

// Инвалидация списков
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
},
});
};

// Хук для удаления пользователя
export const useDeleteUser = () => {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (id: string) => userService.deleteUser(id),

onSuccess: (_, deletedId) => {
// Удаляем из кэша
queryClient.removeQueries({ queryKey: userKeys.detail(deletedId) });

// Инвалидация списков
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
},
});
};

4. Использование в компонентах

// components/UserList.tsx

import { useState } from 'react';
import { useUsers, useDeleteUser } from '@/api/hooks/useUsers';

const UserList: React.FC = () => {
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');

const { data, isLoading, isError, error, isFetching } = useUsers({
page,
pageSize: 20,
search: search || undefined,
});

const deleteUser = useDeleteUser();

if (isLoading) {
return <UserListSkeleton count={20} />;
}

if (isError) {
return <ErrorMessage error={error} />;
}

return (
<div>
{/* Индикатор фонового обновления */}
{isFetching && !isLoading && (
<div className="update-indicator">Updating...</div>
)}

<SearchInput value={search} onChange={setSearch} />

<div className="user-list">
{data.items.map(user => (
<UserCard
key={user.id}
user={user}
onDelete={() => deleteUser.mutate(user.id)}
isDeleting={deleteUser.isPending}
/>
))}
</div>

<Pagination
currentPage={page}
totalPages={data.totalPages}
onPageChange={setPage}
/>
</div>
);
};

// components/UserProfile.tsx

import { useUser, useUpdateUser } from '@/api/hooks/useUsers';

const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
const { data: user, isLoading } = useUser(userId);
const updateUser = useUpdateUser();

if (isLoading) return <ProfileSkeleton />;
if (!user) return <NotFound />;

const handleUpdateName = async (newName: string) => {
try {
await updateUser.mutateAsync({ id: userId, input: { name: newName } });
// Успех — кэш обновился автоматически
} catch (error) {
// Обработка ошибки — кэш откатился автоматически
showToast('Failed to update profile');
}
};

return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<EditableName value={user.name} onSave={handleUpdateName} />
</div>
);
};

5. Глобальная обработка ошибок и аутентификация

// api/interceptors.ts

import { QueryClient } from '@tanstack/react-query';
import { authService } from './services/auth.service';
import { ApiError } from './client';

// Глобальный обработчик ошибок для TanStack Query
export const queryErrorHandler = (error: unknown) => {
if (error instanceof ApiError) {
switch (error.status) {
case 401:
// Перенаправление на логин
window.location.href = '/login';
break;
case 403:
showToast('You do not have permission to perform this action');
break;
case 422:
// Ошибки валидации — обрабатываются в мутациях
break;
case 429:
showToast('Too many requests. Please try again later.');
break;
default:
showToast(error.message || 'An error occurred');
}
}
};

// Настройка QueryClient с глобальными обработчиками
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
// Не ретраим 401, 403, 404
if (error instanceof ApiError) {
if (error.status === 401 || error.status === 403 || error.status === 404) {
return false;
}
}
// Ретрайим сетевые ошибки и 500 до 3 раз
return failureCount < 3;
},
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000,
},
mutations: {
retry: false,
},
},
});

// Интерцептор для автоматического обновления токена
export const setupAuthInterceptor = (queryClient: QueryClient) => {
// Перехватываем 401 и пытаемся обновить токен
const originalFetch = window.fetch;

window.fetch = async (...args) => {
const response = await originalFetch(...args);

if (response.status === 401) {
try {
const refreshToken = getRefreshToken();
const { accessToken, refreshToken: newRefreshToken } = await authService.refreshToken(refreshToken);

setAccessToken(accessToken);
setRefreshToken(newRefreshToken);

// Повторяем оригинальный запрос с новым токеном
const [url, config] = args;
return originalFetch(url, {
...config,
headers: {
...config?.headers,
Authorization: `Bearer ${accessToken}`,
},
});
} catch {
// Не удалось обновить токен — разлогиниваем
clearTokens();
queryClient.clear();
window.location.href = '/login';
return response;
}
}

return response;
};
};

6. Структура проекта

src/
├── api/
│ ├── client.ts # Базовый HTTP-клиент
│ ├── interceptors.ts # Интерцепторы (auth, error handling)
│ ├── types.ts # Общие типы API (pagination, errors)
│ ├── services/
│ │ ├── user.service.ts # API-методы для пользователей
│ │ ├── auth.service.ts # API-методы для аутентификации
│ │ ├── post.service.ts # API-методы для постов
│ │ └── index.ts # Re-export всех сервисов
│ └── hooks/
│ ├── useUsers.ts # Хуки для работы с пользователями
│ ├── useAuth.ts # Хуки для аутентификации
│ ├── usePosts.ts # Хуки для работы с постами
│ └── index.ts # Re-export всех хуков

Ключевые принципы

  • Централизованный HTTP-клиент — одна точка для настройки base URL, заголовков, таймаутов, обработки ошибок.
  • Сервисный слой — типобезопасные функции для каждого API-эндпоинта, отдельно от UI-логики.
  • TanStack Query — кэширование, автоматическая инвалидация, оптимистичные обновления, фоновые рефетчи.
  • Централизованные query keys — ключи запросов в одном месте для удобной инвалидации.
  • Глобальная обработка ошибок — единая логика для 401, 403, 404, 422, 500, сетевых ошибок.
  • Автоматическая ротация токенов — интерцептор для обновления access-токена при 401.

Вопрос 15. Как работают серверные и клиентские компоненты в Next.js и как синхронизировать серверные данные с клиентскими при реализации бесконечной ленты?

Таймкод: 00:25:27

Ответ собеседника: Неполный. Серверные компоненты рендерятся на сервере и отдают готовый HTML, клиентские — рендерятся на клиенте. В современных Next.js можно сделать запрос прямо внутри серверного компонента и передать данные пропсами вниз. Для синхронизации серверных и клиентских данных при бесконечном скролле — знает о существовании параметра initialData в TanStack Query, но признал, что почти с ним не работал и затруднился дать полный ответ.

Правильный ответ:

Ответ собеседника частично верный, но не раскрывает ключевые аспекты: различия в возможностях серверных и клиентских компонентов, механизм их взаимодействия, и главное — полную архитектуру синхронизации данных для бесконечной ленты. Разберём всё подробно.

Серверные vs Клиентские компоненты в Next.js App Router

// СЕРВЕРНЫЙ КОМПОНЕНТ (по умолчанию в App Router)
// Файл: app/posts/page.tsx

// Это серверный компонент — он выполняется только на сервере
// Может быть async, может делать запросы к БД напрямую

import { db } from '@/lib/db'; // Прямой доступ к БД!
import { PostsList } from './PostsList'; // Клиентский компонент

// Серверный компонент может быть async
export default async function PostsPage({
searchParams,
}: {
searchParams: { page?: string };
}) {
const page = parseInt(searchParams.page || '1', 10);
const pageSize = 20;

// Прямой запрос к базе данных — без API-слоя!
const posts = await db.post.findMany({
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: { author: true },
});

const total = await db.post.count();
const totalPages = Math.ceil(total / pageSize);

return (
<div>
<h1>Posts</h1>

{/* Серверные данные передаются как initialData в клиентский компонент */}
<PostsList
initialPosts={posts}
initialPage={page}
totalPages={totalPages}
/>
</div>
);
}

// Что происходит:
// 1. Сервер выполняет async функцию, получает данные из БД
// 2. React рендерит компонент на сервере в RSC (React Server Components) payload
// 3. Отправляет клиенту: HTML для немедленного отображения + RSC payload
// 4. Клиент получает готовый HTML — мгновенный First Contentful Paint
// 5. Клиентский компонент hydrate-ится и становится интерактивным
// КЛИЕНТСКИЙ КОМПОНЕНТ
// Файл: app/posts/PostsList.tsx

'use client'; // Директива — этот компонент выполняется на клиенте

import { useState, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Virtuoso } from 'react-virtuoso';
import { fetchPosts } from '@/api/services/post.service';

interface PostsListProps {
initialPosts: Post[];
initialPage: number;
totalPages: number;
}

export function PostsList({ initialPosts, initialPage, totalPages }: PostsListProps) {
const [currentPage, setCurrentPage] = useState(initialPage);

// initialData — данные с сервера, placeholderData — для плавного перехода
const { data, isLoading, isFetching, hasNextPage, fetchNextPage } = useQuery({
queryKey: ['posts', 'infinite'],
queryFn: ({ pageParam = 1 }) => fetchPosts({ page: pageParam, pageSize: 20 }),
initialData: {
pages: [{ items: initialPosts, totalPages }],
pageParams: [1],
},
getNextPageParam: (lastPage, allPages) => {
const nextPage = allPages.length + 1;
return nextPage <= lastPage.totalPages ? nextPage : undefined;
},
staleTime: 60 * 1000, // 1 минута — данные считаются свежими
});

// Все посты из всех загруженных страниц
const allPosts = data?.pages.flatMap(page => page.items) ?? [];

// Загрузка следующей страницы при достижении конца списка
const handleEndReached = useCallback(() => {
if (hasNextPage && !isFetching) {
fetchNextPage();
}
}, [hasNextPage, isFetching, fetchNextPage]);

return (
<div>
{isFetching && !isLoading && (
<div className="update-indicator">Loading more...</div>
)}

<Virtuoso
data={allPosts}
endReached={handleEndReached}
itemContent={(index, post) => (
<PostCard key={post.id} post={post} />
)}
overscan={200}
components={{
Footer: () => {
if (!hasNextPage) {
return <div className="end-message">No more posts</div>;
}
if (isFetching) {
return <div className="loading-spinner">Loading...</div>;
}
return null;
},
}}
/>
</div>
);
}

Различия между серверными и клиентскими компонентами

// ═══════════════════════════════════════════════════════════════
// СЕРВЕРНЫЙ КОМПОНЕНТ
// ═══════════════════════════════════════════════════════════════

// ✅ Может быть async
export default async function ServerComponent() {
// ✅ Прямой доступ к БД
const users = await db.user.findMany();

// ✅ Прямой доступ к файловой системе
const content = await fs.readFile('README.md', 'utf-8');

// ✅ Доступ к секретам и API-ключам (не попадают в клиентский бандл)
const apiKey = process.env.SECRET_API_KEY;

// ✅ Использование серверных библиотек (bcrypt, sharp, pg, etc.)

// ❌ Не может использовать useState, useEffect, useContext
// ❌ Не может использовать браузерные API (window, document, localStorage)
// ❌ Не может использовать обработчики событий (onClick, onChange)
// ❌ Не может использовать React hooks

return <div>{users.map(u => <p key={u.id}>{u.name}</p>)}</div>;
}

// ═══════════════════════════════════════════════════════════════
// КЛИЕНТСКИЙ КОМПОНЕНТ
// ═══════════════════════════════════════════════════════════════

'use client';

import { useState, useEffect } from 'react';

export default function ClientComponent() {
// ✅ Может использовать React state
const [count, setCount] = useState(0);

// ✅ Может использовать эффекты
useEffect(() => {
console.log('Component mounted');
}, []);

// ✅ Может использовать обработчики событий
const handleClick = () => setCount(c => c + 1);

// ✅ Может использовать браузерные API
useEffect(() => {
const saved = localStorage.getItem('count');
if (saved) setCount(parseInt(saved, 10));
}, []);

// ❌ Не может быть async
// ❌ Не имеет прямого доступа к БД
// ❌ Не имеет доступа к серверным секретам

return <button onClick={handleClick}>Count: {count}</button>;
}

Архитектура бесконечной ленты с SSR + CSR

┌─────────────────────────────────────────────────────────┐
│ ПЕРВИЧНЫЙ ЗАПРОС │
│ │
│ ┌──────────┐ SSR ┌──────────────────────────┐ │
│ │ Browser │ ────────→ │ Next.js Server │ │
│ │ │ │ │ │
│ │ │ │ 1. Запрос к БД │ │
│ │ │ │ 2. Рендер Server Comp. │ │
│ │ │ │ 3. Отправка HTML + RSC │ │
│ │ │ ←──────── │ payload │ │
│ └──────────┘ └──────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 1. Мгновенный показ HTML (FCP) │ │
│ │ 2. Hydration клиентских компонентов │ │
│ │ 3. TanStack Query получает initialData из SSR │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│ КЛИЕНТСКАЯ НАВИГАЦИЯ │
│ │
│ ┌──────────┐ CSR ┌──────────────────────────┐ │
│ │ Browser │ ────────→ │ Next.js API Route │ │
│ │ │ │ или внешний API │ │
│ │ TanStack│ │ │ │
│ │ Query │ ←──────── │ 1. Запрос к БД │ │
│ │ │ │ 2. Возврат JSON │ │
│ └──────────┘ └──────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 1. TanStack Query обновляет кэш │ │
│ │ 2. Компонент ре-рендерит с новыми данными │ │
│ │ 3. Оптимистичные обновления при мутациях │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘

Полная реализация бесконечной ленты

// app/posts/page.tsx — Серверный компонент

import { Suspense } from 'react';
import { PostsList } from './PostsList';
import { PostsSkeleton } from './PostsSkeleton';
import { getPosts } from '@/lib/posts'; // Серверная функция

export const metadata = {
title: 'Posts',
description: 'Latest posts feed',
};

export default async function PostsPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const { page: pageParam } = await searchParams;
const page = parseInt(pageParam || '1', 10);

// Загружаем первую страницу на сервере
// Это попадёт в initialData клиентского компонента
const initialData = await getPosts({ page: 1, pageSize: 20 });

return (
<main className="container">
<h1>Posts Feed</h1>

{/* Suspense для показа skeleton во время загрузки */}
<Suspense fallback={<PostsSkeleton count={20} />}>
<PostsList
initialData={initialData}
initialPage={1}
/>
</Suspense>
</main>
);
}

// lib/posts.ts — Серверные функции для работы с данными

import { cache } from 'react';
import { db } from './db';

// cache() — дедупликация запросов в рамках одного рендера
export const getPosts = cache(async ({ page, pageSize }: { page: number; pageSize: number }) => {
const skip = (page - 1) * pageSize;

const [posts, total] = await Promise.all([
db.post.findMany({
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
author: { select: { id: true, name: true, avatar: true } },
_count: { select: { comments: true, likes: true } },
},
}),
db.post.count(),
]);

return {
items: posts,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
});

export const getPostById = cache(async (id: string) => {
return db.post.findUnique({
where: { id },
include: {
author: true,
comments: { include: { author: true } },
},
});
});
// app/posts/PostsList.tsx — Клиентский компонент с бесконечной лентой

'use client';

import { useCallback, useRef } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { Virtuoso } from 'react-virtuoso';
import { fetchPosts } from '@/api/services/post.service';
import { PostCard } from './PostCard';
import { PostCardSkeleton } from './PostCardSkeleton';

interface PostsListProps {
initialData: {
items: Post[];
total: number;
page: number;
pageSize: number;
totalPages: number;
};
initialPage: number;
}

export function PostsList({ initialData, initialPage }: PostsListProps) {
// useInfiniteQuery — специальный хук для бесконечной прокрутки
const {
data,
isLoading,
isFetching,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
error,
} = useInfiniteQuery({
queryKey: ['posts', 'infinite'],

// Функция загрузки страницы
queryFn: async ({ pageParam }) => {
return fetchPosts({ page: pageParam, pageSize: 20 });
},

// Начальная страница
initialPageParam: 1,

// Как получить параметр следующей страницы
getNextPageParam: (lastPage) => {
if (lastPage.page >= lastPage.totalPages) {
return undefined; // Следующей страницы нет
}
return lastPage.page + 1;
},

// Данные с сервера — предзаполненный кэш первой страницы
initialData: {
pages: [initialData],
pageParams: [initialPage],
},

// Данные с сервера считаются свежими 30 секунд
staleTime: 30 * 1000,
});

// Собираем все посты из всех загруженных страниц в один плоский массив
const allPosts = data?.pages.flatMap(page => page.items) ?? [];

// Обработчик достижения конца списка — загружаем следующую страницу
const handleEndReached = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);

// Состояние ошибки
if (error) {
return (
<div className="error-container">
<p>Failed to load posts</p>
<button onClick={() => fetchNextPage()}>Retry</button>
</div>
);
}

// Начальная загрузка (только при полном рефреше без initialData)
if (isLoading) {
return <PostsSkeleton count={20} />;
}

return (
<div className="posts-container">
{/* Индикатор фонового обновления */}
{isFetching && !isFetchingNextPage && (
<div className="bg-update-indicator">
<span className="spinner" /> Updating...
</div>
)}

{/* Виртуализированный список */}
<Virtuoso
data={allPosts}
endReached={handleEndReached}
overscan={300} // Дополнительный рендер 300px за пределами viewport
itemContent={(index, post) => (
<PostCard
key={post.id}
post={post}
// Первые 20 постов уже в кэше — мгновенный показ
// Остальные подгружаются при скролле
/>
)}
components={{
// Футер — индизагрузки или сообщение об окончании
Footer: () => {
if (isFetchingNextPage) {
return (
<div className="loading-more">
<PostCardSkeleton />
<PostCardSkeleton />
</div>
);
}
if (!hasNextPage && allPosts.length > 0) {
return (
<div className="end-message">
You've reached the end! 🎉
</div>
);
}
return null;
},
}}
/>
</div>
);
}
// api/services/post.service.ts — API-методы для клиентских запросов

import { apiClient } from '../client';

export interface Post {
id: string;
title: string;
content: string;
author: {
id: string;
name: string;
avatar: string | null;
};
_count: {
comments: number;
likes: number;
};
createdAt: string;
}

export interface PostsResponse {
items: Post[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}

export interface FetchPostsParams {
page: number;
pageSize: number;
search?: string;
tag?: string;
}

export const fetchPosts = async (params: FetchPostsParams): Promise<PostsResponse> => {
return apiClient.get<PostsResponse>('/api/posts', { params });
};

export const fetchPostById = async (id: string): Promise<Post> => {
return apiClient.get<Post>(`/api/posts/${id}`);
};

export const createPost = async (input: CreatePostInput): Promise<Post> => {
return apiClient.post<Post>('/api/posts', input);
};

export const likePost = async (postId: string): Promise<{ likesCount: number }> => {
return apiClient.post(`/api/posts/${postId}/like`);
};

Ключевые моменты синхронизации

// 1. initialData — мост между сервером и клиентом
// Сервер рендерит первую страницу → передаёт как initialData
// TanStack Query использует эти данные как начальную страницу кэша
// При скролле — загружает следующие страницы через API

// 2. staleTime — как долго данные считаются свежими
// Если staleTime не истёк — TanStack Query не делает рефетч
// Это важно: при навигации назад данные с сервера ещё актуальны

// 3. placeholderData — показ старых данных при рефетче
// При изменении queryKey (например, поиск) — показываем старые данные
// пока загружаются новые

// 4. Инвалидация при мутациях
// Создание/редактирование/удаление поста → инвалидация кэша
// TanStack Query автоматически рефетчит данные

export const useCreatePost = () => {
const queryClient = useQueryClient();

return useMutation({
mutationFn: createPost,

onSuccess: () => {
// Инвалидация бесконечного списка
// Это вызовет рефетч первой страницы
queryClient.invalidateQueries({
queryKey: ['posts', 'infinite'],
});
},
});
};

// 5. Оптимистичное обновление при лайке
export const useLikePost = () => {
const queryClient = useQueryClient();

return useMutation({
mutationFn: likePost,

onMutate: async (postId) => {
// Отменяем текущие запросы
await queryClient.cancelQueries({ queryKey: ['posts', 'infinite'] });

// Сохраняем предыдущие данные
const previousData = queryClient.getQueryData(['posts', 'infinite']);

// Оптимистично обновляем кэш
queryClient.setQueryData(['posts', 'infinite'], (old: any) => {
if (!old) return old;

return {
...old,
pages: old.pages.map((page: PostsResponse) => ({
...page,
items: page.items.map((post: Post) =>
post.id === postId
? { ...post, _count: { ...post._count, likes: post._count.likes + 1 } }
: post
),
})),
};
});

return { previousData };
},

onError: (error, postId, context) => {
// Откат при ошибке
if (context?.previousData) {
queryClient.setQueryData(['posts', 'infinite'], context.previousData);
}
},

onSettled: () => {
// Финальная синхронизация с сервером
queryClient.invalidateQueries({ queryKey: ['posts', 'infinite'] });
},
});
};

Итог

  • Серверные компоненты — выполняются на сервере, имеют доступ к БД, не увеличивают клиентский бандл, отправляют готовый HTML.
  • Клиентские компоненты — выполняются на клиенте, обеспечивают интерактивность, используют React hooks и браузерные API.
  • Синхронизация — сервер загружает первую страницу и передаёт как initialData в TanStack Query. Клиент использует эти данные как начальную страницу кэша и подгружает остальные при скролле через useInfiniteQuery.
  • Ключевые инструменты: initialData для моста SSR→CSR, useInfiniteQuery для бесконечной прокрутки, Virtuoso для виртуализации, оптимистичные обновления для мгновенного UI.

Вопрос 16. Как оцениваешь свой уровень владения TypeScript по 10-балльной шкале и какие проблемы он решает?

Таймкод: 00:27:41

Ответ собеседника: Правильный. Оценивает уровень на 6-7 из 10 (семь с натяжкой). Главная проблема JavaScript — слабая динамическая типизация, возможность неявного приведения типов (например, сложение строки и числа даёт '22'). TypeScript решает эту проблему — число нельзя прибавить к строке, нет автоматического приведения типов. Также добавляет кастомные типы, type aliases, интерфейсы и другой функционал.

Правильный ответ:

Ответ собеседника верный в части проблем, которые решает TypeScript, но слишком поверхностный. Уровень 6-7 подразумевает знание продвинутых концепций, которые не были упомянуты. Разберём тему глубже — какие проблемы решает TypeScript на практике и какие продвинутые возможности он предоставляет.

Проблемы JavaScript, которые решает TypeScript

1. Ошибки типов во время выполнения → ошибки на этапе компиляции

// ❌ JavaScript — ошибка обнаружена только во время выполнения
function getUserName(user) {
return user.name.toUpperCase();
}

getUserName({ name: "John" }); // "JOHN" — работает
getUserName({ name: null }); // TypeError: Cannot read property 'toUpperCase' of null
getUserName({}); // TypeError: Cannot read property 'toUpperCase' of undefined
getUserName(null); // TypeError: Cannot read property 'name' of null

// Все эти ошибки обнаруживаются только в продакшене
// Пользователь видит красный экран → баг → откат деплоя
// ✅ TypeScript — ошибки обнаружены до запуска кода
interface User {
name: string;
}

function getUserName(user: User): string {
return user.name.toUpperCase();
}

getUserName({ name: "John" }); // ✅ Работает
getUserName({ name: null }); // ❌ Ошибка компиляции: Type 'null' is not assignable to type 'string'
getUserName({}); // ❌ Ошибка компиляции: Property 'name' is missing
getUserName(null); // ❌ Ошибка компиляции: Argument of type 'null' is not assignable

// Все ошибки обнаружены в IDE до запуска кода
// Никаких сюрпризов в продакшене

2. Неявное приведение типов

// ❌ JavaScript — неявное приведение типов создаёт баги
console.log(1 + "2"); // "12" — строка!
console.log("2" - 1); // 1 — число!
console.log([] + {}); // "[object Object]"
console.log({} + []); // 0 (в некоторых движках)
console.log(null == undefined); // true
console.log(null === undefined); // false
console.log(0 == "0"); // true
console.log(0 == []); // true
console.log("" == false); // true

// Эти особенности — источник бесчисленных багов
// Разработчик должен помнить все правила приведения типов
// ✅ TypeScript — явные типы, никаких сюрпризов
const num: number = 1;
const str: string = "2";

// num + str; // ❌ Ошибка компиляции: Operator '+' cannot be applied to types 'number' and 'string'

// Явное приведение типов — разработчик осознанно делает конвертацию
const result: string = String(num) + str; // "12" — явно и понятно
const parsed: number = parseInt(str, 10); // 2 — явно и понятно

3. Отсутствие контрактов между модулями

// ❌ JavaScript — нет контракта, легко сломать API
// user-service.js
function createUser(data) {
return db.insert({
name: data.name,
email: data.email,
age: data.age,
});
}

// Где-то в другом файле
createUser({
name: "John",
email: "john@example.com",
// age забыли — ошибка в рантайме
// или передали не тот тип
age: "twenty-five", // Строка вместо числа — ошибка в рантайме
});
// ✅ TypeScript — явный контракт
interface CreateUserInput {
name: string;
email: string;
age: number;
}

function createUser(data: CreateUserInput): Promise<User> {
return db.insert(data);
}

createUser({
name: "John",
email: "john@example.com",
// age: забыли — ❌ Ошибка компиляции: Property 'age' is missing
// age: "twenty-five" — ❌ Ошибка компиляции: Type 'string' is not assignable to type 'number'
});

Продвинутые возможности TypeScript

1. Union и Intersection типы

// Union тип — значение может быть одного из нескольких типов
type Status = 'pending' | 'success' | 'error';
type ID = string | number;
type Response = SuccessResponse | ErrorResponse;

// Использование union типов
function handleResponse(response: Response) {
if (response.status === 'success') {
// TypeScript знает, что здесь response — SuccessResponse
console.log(response.data);
} else {
// TypeScript знает, что здесь response — ErrorResponse
console.log(response.error);
}
}

// Intersection тип — объединение нескольких типов
type HasName = { name: string };
type HasAge = { age: number };
type HasEmail = { email: string };

type Person = HasName & HasAge & HasEmail;

// Person должен иметь ВСЕ поля из всех типов
const person: Person = {
name: "John",
age: 30,
email: "john@example.com",
};

2. Generics — типобезопасная переиспользуемость

// Без generics — теряем типобезопасность
function getFirst(arr: any[]): any {
return arr[0];
}

const first = getFirst([1, 2, 3]); // Тип — any, нет автодополнения

// С generics — сохраняем тип
function getFirst<T>(arr: T[]): T | undefined {
return arr[0];
}

const firstNum = getFirst([1, 2, 3]); // Тип — number
const firstStr = getFirst(["a", "b", "c"]); // Тип — string

// Generics в интерфейсах
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}

interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}

// Использование
type UsersResponse = ApiResponse<PaginatedResponse<User>>;
type PostResponse = ApiResponse<Post>;

// Generics с ограничениями
interface HasId {
id: string;
}

function findById<T extends HasId>(items: T[], id: string): T | undefined {
return items.find(item => item.id === id);
}

// T должен иметь поле id — TypeScript проверит это на этапе компиляции
const users = [{ id: "1", name: "John" }, { id: "2", name: "Jane" }];
const user = findById(users, "1"); // Тип — { id: string; name: string } | undefined

3. Utility Types — трансформация типов

interface User {
id: string;
name: string;
email: string;
password: string;
createdAt: Date;
updatedAt: Date;
}

// Partial — все поля становятся опциональными
type UpdateUserInput = Partial<User>;
// Эквивалентно: { id?: string; name?: string; email?: string; ... }

// Pick — выбрать только указанные поля
type UserPublicInfo = Pick<User, 'id' | 'name' | 'email'>;
// { id: string; name: string; email: string; }

// Omit — исключить указанные поля
type CreateUserInput = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
// { name: string; email: string; password: string; }

// Required — все поля становятся обязательными
type CompleteUser = Required<Partial<User>>;

// Readonly — все поля становятся только для чтения
type ImmutableUser = Readonly<User>;

// Record — создать тип-словарь
type UserRoles = Record<string, 'admin' | 'user' | 'moderator'>;
const roles: UserRoles = {
"user-1": "admin",
"user-2": "user",
};

// ReturnType — извлечь тип возвращаемого значения функции
function createUser() {
return { id: "1", name: "John", email: "john@example.com" };
}
type NewUser = ReturnType<typeof createUser>;
// { id: string; name: string; email: string; }

// Parameters — извлечь типы параметров функции
function updateUser(id: string, data: Partial<User>) { /* ... */ }
type UpdateUserParams = Parameters<typeof updateUser>;
// [string, Partial<User>]

4. Conditional Types и Mapped Types

// Conditional Types — типы с условиями
type IsString<T> = T extends string ? true : false;

type A = IsString<string>; // true
type B = IsString<number>; // false

// Практический пример: извлечь тип из Promise
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

type Result1 = UnwrapPromise<Promise<string>>; // string
type Result2 = UnwrapPromise<number>; // number

// Mapped Types — трансформация полей типа
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};

type Optional<T> = {
[P in keyof T]?: T[P];
};

type Nullable<T> = {
[P in keyof T]: T[P] | null;
};

// Практический пример: все поля nullable для формы
interface UserFormData {
name: string | null;
email: string | null;
age: number | null;
}

// Или через mapped type:
type NullableUser = Nullable<User>;

5. Template Literal Types

// Типы на основе строковых литералов
type EventName = 'click' | 'focus' | 'blur';
type EventHandlerName = `on${Capitalize<EventName>}`;
// 'onClick' | 'onFocus' | 'onBlur'

// Практический пример: API endpoints
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Endpoint = '/users' | '/posts' | '/comments';
type ApiRoute = `${HttpMethod} ${Endpoint}`;
// 'GET /users' | 'POST /users' | 'PUT /users' | 'DELETE /users' | ...

// Извлечение параметров из URL
type ExtractRouteParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractRouteParams<`/${Rest}`>
: T extends `${string}:${infer Param}`
? Param
: never;

type Params = ExtractRouteParams<'/users/:userId/posts/:postId'>;
// 'userId' | 'postId'

6. Discriminated Unions — типобезопасные состояния

// Паттерн для моделирования состояний приложения
type RequestState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };

// TypeScript автоматически сужает тип по полю status
function renderUser(state: RequestState<User>) {
switch (state.status) {
case 'idle':
return <div>Click to load</div>;

case 'loading':
return <Spinner />;

case 'success':
// TypeScript знает, что здесь есть state.data
return <div>{state.data.name}</div>;

case 'error':
// TypeScript знает, что здесь есть state.error
return <div className="error">{state.error}</div>;
}
}

// Пример с формами
type FormState =
| { status: 'editing'; values: FormValues; errors: Record<string, string> }
| { status: 'submitting'; values: FormValues }
| { status: 'submitted'; result: SubmitResult }
| { status: 'submitFailed'; values: FormValues; error: string };

7. Type Guards и Assertion Functions

// Type Guard — функция, сужающая тип
function isString(value: unknown): value is string {
return typeof value === 'string';
}

function processValue(value: string | number) {
if (isString(value)) {
// TypeScript знает, что здесь value — string
return value.toUpperCase();
}
// TypeScript знает, что здесь value — number
return value.toFixed(2);
}

// Assertion Function — выбрасывает ошибку, если тип не соответствует
function assertIsDefined<T>(value: T): asserts value is NonNullable<T> {
if (value === undefined || value === null) {
throw new Error('Value is not defined');
}
}

function processUser(user: User | null) {
assertIsDefined(user);
// TypeScript знает, что здесь user — User (не null)
console.log(user.name);
}

// Практический пример: проверка API-ответа
function isApiError(response: unknown): response is { error: string; code: number } {
return (
typeof response === 'object' &&
response !== null &&
'error' in response &&
'code' in response
);
}

async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();

if (isApiError(data)) {
throw new Error(data.error);
}

return data; // TypeScript знает, что это User
}

Шкала уровней владения TypeScript

Уровень 1-3: Junior

  • Базовые типы: string, number, boolean, array, object.
  • Интерфейсы и type aliases.
  • Простые generics (Array<T>, Promise<T>).
  • Union типы.

Уровень 4-5: Junior+/Middle-

  • Utility Types: Partial, Pick, Omit, Record.
  • Type guards.
  • Generics с ограничениями (extends).
  • Mapped Types.

Уровень 6-7: Middle

  • Conditional Types.
  • Template Literal Types.
  • Discriminated Unions.
  • Assertion Functions.
  • Понимание структурной типизации vs номинальной.

Уровень 8-9: Middle+/Senior

  • Сложные conditional types с infer.
  • Recursive types.
  • Template Literal Types для маршрутов и событий.
  • Интеграция с библиотеками (типизация сторонних пакетов).
  • Понимание разницы между type и interface в контексте расширения.

Уровень 10: Expert

  • Написание сложных типовых утилит.
  • Понимание внутреннего устройства компилятора.
  • Создание типобезопасных DSL.
  • Вклад в DefinitelyTyped.

Итог

TypeScript решает фундаментальные проблемы JavaScript: обнаруживает ошибки на этапе компиляции, обеспечивает контракты между модулями, устраняет неявное приведение типов и предоставляет мощную систему типов для моделирования предметной области. Для уровня 6-7 важно уверенно владеть generics, utility types, type guards и discriminated unions — это инструменты, которые используются ежедневно в реальных проектах.

Вопрос 17. Что такое структурная типизация в TypeScript?

Таймкод: 00:29:39

Ответ собеседника: Правильный. TypeScript — язык со структурной типизацией, в отличие от номинальной типизации (например, C#, где нельзя иметь два интерфейса с одинаковым именем). В TypeScript можно иметь интерфейсы с одинаковыми именами — они объединятся. Сравнение идёт не по имени, а по структуре (полям). Если два интерфейса имеют одинаковые поля, объект может имплементировать оба. Сравнение структурное, а не номинальное.

Правильный ответ:

Ответ собеседника верный и хорошо объясняет концепцию. Разберём тему глубже — с практическими примерами, подводными камнями и способами эмуляции номинальной типизации, когда она нужна.

Структурная vs Номинальная типизация

// ═══════════════════════════════════════════════════════════════
// НОМИНАЛЬНАЯ ТИПИЗАЦИЯ (C#, Java, Kotlin)
// ═══════════════════════════════════════════════════════════════
// Два типа с одинаковой структурой, но разными именами — НЕ совместимы

// class UserId { value: string }
// class PostId { value: string }
//
// const userId: UserId = new PostId(); // ❌ Ошибка компиляции!
// Даже если структура одинаковая — имена разные, типы несовместимы

// ═══════════════════════════════════════════════════════════════
// СТРУКТУРНАЯ ТИПИЗАЦИЯ (TypeScript, Go, OCaml)
// ═══════════════════════════════════════════════════════════════
// Два типа с одинаковой структурой — совместимы, даже если имена разные

interface UserId {
value: string;
}

interface PostId {
value: string;
}

const userId: UserId = { value: "user-123" };
const postId: PostId = userId; // ✅ Работает! Структура одинаковая

// TypeScript не смотрит на имена типов
// Он смотрит на структуру: есть ли нужные поля с нужными типами

Практические примеры структурной типизации

// Пример 1: Неявная совместимость
interface User {
id: string;
name: string;
email: string;
}

interface UserDTO {
id: string;
name: string;
email: string;
}

// Поля одинаковые — типы совместимы
function displayUser(user: User) {
console.log(user.name);
}

const dto: UserDTO = { id: "1", name: "John", email: "john@example.com" };
displayUser(dto); // ✅ Работает! UserDTO структурно совместим с User

// Пример 2: Избыточные поля — не проблема
interface Named {
name: string;
}

function greet(entity: Named) {
console.log(`Hello, ${entity.name}!`);
}

const user = { id: "1", name: "John", email: "john@example.com" };
greet(user); // ✅ Работает! У объекта есть поле name

// Пример 3: Проблема с избыточными полями при литералах
interface Config {
host: string;
port: number;
}

function connect(config: Config) {
// ...
}

// ❌ Ошибка: литерал с лишними полями
connect({ host: "localhost", port: 8080, extra: "value" });

// ✅ Работает: переменная с лишними полями
const config = { host: "localhost", port: 8080, extra: "value" };
connect(config); // Лишние поля игнорируются при структурном сравнении

// Это называется "excess property checking" —
// TypeScript проверяет лишние поля только при передаче литералов напрямую

Подводные камни структурной типизации

// Проблема 1: Случайная совместимость несвязанных типов
interface UserId {
value: string;
}

interface PostId {
value: string;
}

function getUser(id: UserId): User {
return db.users.findById(id.value);
}

function getPost(id: PostId): Post {
return db.posts.findById(id.value);
}

const userId: UserId = { value: "user-123" };
// ❌ Логическая ошибка: передали UserId в функцию для Post
// TypeScript не ругается — структура одинаковая
getPost(userId);

// Проблема 2: Пустые интерфейсы совместимы со всем
interface Empty {}

function processEmpty(obj: Empty) {
// ...
}

processEmpty({ name: "John" }); // ✅ Работает
processEmpty({ id: 1, data: [] }); // ✅ Работает
processEmpty("anything"); // ✅ Работает (почти)

Эмуляция номинальной типизации (Nominal Typing)

// Способ 1: Branding pattern (рекомендуемый)
type UserId = string & { readonly __brand: 'UserId' };
type PostId = string & { readonly __brand: 'PostId' };
type CommentId = string & { readonly __brand: 'CommentId' };

// Фабрики для создания типизированных значений
function createUserId(value: string): UserId {
return value as UserId;
}

function createPostId(value: string): PostId {
return value as PostId;
}

function getUser(id: UserId): User {
return db.users.findById(id);
}

function getPost(id: PostId): Post {
return db.posts.findById(id);
}

// Использование
const userId = createUserId("user-123");
const postId = createPostId("post-456");

getUser(userId); // ✅ Работает
getPost(postId); // ✅ Работает
getPost(userId); // ❌ Ошибка компиляции: Argument of type 'UserId' is not assignable to parameter of type 'PostId'
getUser(postId); // ❌ Ошибка компиляции: Argument of type 'PostId' is not assignable to parameter of type 'UserId'

// Способ 2: Уникальный символ
declare const UserIdSymbol: unique symbol;
type UserId2 = string & { [UserIdSymbol]: typeof UserIdSymbol };

// Способ 3: Приватное поле в классе
class UserId3 {
private __nominal: void; // Приватное поле делает тип уникальным
constructor(public value: string) {}
}

class PostId3 {
private __nominal: void;
constructor(public value: string) {}
}

// UserId3 и PostId3 — разные типы, даже если структура одинаковая

Продвинутый пример: типобезопасный API-клиент

// Типобезопасный API-клиент с номинальной типизацией для ID

// Базовые ID-типы с брендированием
type Entity<T extends string> = string & { readonly __entity: T };

type UserId = Entity<'User'>;
type PostId = Entity<'Post'>;
type CommentId = Entity<'Comment'>;

// Фабрики
const createUserId = (id: string) => id as UserId;
const createPostId = (id: string) => id as PostId;
const createCommentId = (id: string) => id as CommentId;

// Интерфейсы сущностей
interface User {
id: UserId;
name: string;
email: string;
}

interface Post {
id: PostId;
title: string;
authorId: UserId;
}

interface Comment {
id: CommentId;
postId: PostId;
authorId: UserId;
text: string;
}

// Типобезопасный API-клиент
class ApiClient {
async getUser(id: UserId): Promise<User> {
return this.fetch(`/users/${id}`);
}

async getPost(id: PostId): Promise<Post> {
return this.fetch(`/posts/${id}`);
}

async getPostComments(postId: PostId): Promise<Comment[]> {
return this.fetch(`/posts/${postId}/comments`);
}

async createComment(postId: PostId, authorId: UserId, text: string): Promise<Comment> {
return this.fetch(`/posts/${postId}/comments`, {
method: 'POST',
body: JSON.stringify({ authorId, text }),
});
}

private async fetch<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, init);
return response.json();
}
}

// Использование — невозможно перепутать ID
const api = new ApiClient();
const userId = createUserId("user-123");
const postId = createPostId("post-456");

const user = await api.getUser(userId); // ✅
const post = await api.getPost(postId); // ✅
const comments = await api.getPostComments(postId); // ✅

// ❌ Ошибка компиляции — нельзя перепутать ID
await api.getUser(postId); // PostId не assignable к UserId
await api.getPost(userId); // UserId не assignable к PostId
await api.getPostComments(userId); // UserId не assignable к PostId

Объявление интерфейсов с одинаковыми именами (Declaration Merging)

// TypeScript позволяет объявлять интерфейсы с одинаковым именем
// Они объединяются (merge) в один интерфейс

interface User {
name: string;
}

interface User {
email: string;
}

interface User {
age: number;
}

// Результат: User имеет ВСЕ три поля
const user: User = {
name: "John",
email: "john@example.com",
age: 30,
};

// Это используется для расширения типов из библиотек
// Например, расширение Window:
declare global {
interface Window {
myCustomProperty: string;
}
}

// Или расширение Express Request:
declare namespace Express {
interface Request {
user?: { id: string; role: string };
}
}

Структурная типизация с функциями

// Функции сравниваются по сигнатуре
type EventHandler = (event: Event) => void;
type ClickHandler = (event: Event) => void;

const handleClick: ClickHandler = (event) => console.log('click');
const eventHandler: EventHandler = handleClick; // ✅ Совместимы

// Контравариантность параметров функций
interface Animal {
name: string;
}

interface Dog extends Animal {
breed: string;
}

type AnimalHandler = (animal: Animal) => void;
type DogHandler = (dog: Dog) => void;

const handleAnimal: AnimalHandler = (animal) => console.log(animal.name);
const handleDog: DogHandler = (dog) => console.log(dog.breed);

// Контравариантность: DogHandler может быть использован как AnimalHandler
const animalHandler: AnimalHandler = handleDog; // ❌ TypeScript запрещает (strictFunctionTypes)

// Ковариантность возвращаемого значения
type GetAnimal = () => Animal;
type GetDog = () => Dog;

const getAnimal: GetAnimal = () => ({ name: "Generic" });
const getDog: GetDog = () => ({ name: "Buddy", breed: "Labrador" });

const animalGetter: GetAnimal = getDog; // ✅ Dog extends Animal — безопасно
const dogGetter: GetDog = getAnimal; // ❌ Animal не обязательно Dog — небезопасно

Итог

  • Структурная типизация — сравнение типов по структуре (полям), а не по имени. Два типа с одинаковой структурой совместимы.
  • Номинальная типизация — сравнение по имени. Даже одинаковая структура не делает типы совместимыми.
  • Плюсы структурной типизации: гибкость, duck typing, совместимость с JavaScript.
  • Минусы: случайная совместимость несвязанных типов, нельзя различить типы с одинаковой структурой.
  • Решение: branding pattern для эмуляции номинальной типизации, когда нужна строгая типобезопасность для ID и других примитивных типов.
  • Declaration merging — возможность расширять интерфейсы с одинаковым именем, полезна для расширения типов библиотек.

Вопрос 18. Какие утилитарные типы TypeScript используешь в работе?

Таймкод: 00:30:57

Ответ собеседника: Неполный. Вопрос был задан, но ответ не был дан — диалог перешёл к следующей теме.

Правильный ответ:

Utility Types — это встроенные в TypeScript инструменты для трансформации типов. Они используются ежедневно в реальных проектах. Разберём все основные с практическими примерами.

Базовые Utility Types

// Исходный интерфейс для примеров
interface User {
id: string;
name: string;
email: string;
password: string;
role: 'admin' | 'user' | 'moderator';
createdAt: Date;
updatedAt: Date;
}

1. Partial<T> — все поля опциональные

// Создаёт тип, где все поля T становятся опциональными
type UpdateUserInput = Partial<User>;
// Эквивалентно:
// {
// id?: string;
// name?: string;
// email?: string;
// password?: string;
// role?: 'admin' | 'user' | 'moderator';
// createdAt?: Date;
// updatedAt?: Date;
// }

// Практическое использование: обновление сущности
async function updateUser(id: string, data: Partial<User>): Promise<User> {
// Можно передать только те поля, которые нужно обновить
return db.users.update(id, data);
}

// Вызов — можно передать любое подмножество полей
await updateUser("user-1", { name: "New Name" });
await updateUser("user-1", { email: "new@email.com", role: "admin" });

2. Required<T> — все поля обязательные

// Обратный Partial — все поля становятся обязательными
interface PartialUser {
id?: string;
name?: string;
email?: string;
}

type CompleteUser = Required<PartialUser>;
// {
// id: string; // Было опциональным — стало обязательным
// name: string;
// email: string;
// }

3. Pick<T, K> — выбрать указанные поля

// Создаёт тип, выбирая только указанные поля из T
type UserPublicInfo = Pick<User, 'id' | 'name' | 'email'>;
// {
// id: string;
// name: string;
// email: string;
// }

// Практическое использование: публичный профиль
function getPublicProfile(userId: string): Promise<UserPublicInfo> {
return db.users.findById(userId, ['id', 'name', 'email']);
}

// Тип для формы — только редактируемые поля
type UserEditableFields = Pick<User, 'name' | 'email'>;

4. Omit<T, K> — исключить указанные поля

// Создаёт тип, исключая указанные поля из T
type CreateUserInput = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
// {
// name: string;
// email: string;
// password: string;
// role: 'admin' | 'user' | 'moderator';
// }

// Практическое использование: создание сущности
async function createUser(data: CreateUserInput): Promise<User> {
return db.users.create({
...data,
id: generateId(),
createdAt: new Date(),
updatedAt: new Date(),
});
}

// Безопасный DTO — исключаем чувствительные поля
type UserSafeDTO = Omit<User, 'password'>;

5. Record<K, V> — словарь с ключами K и значениями V

// Создаёт тип-объект с ключами типа K и значениями типа V
type UserRoles = Record<string, 'admin' | 'user' | 'moderator'>;

const roles: UserRoles = {
"user-1": "admin",
"user-2": "user",
"user-3": "moderator",
};

// Практическое использование: локализация
type LocaleMessages = Record<string, string>;

const enMessages: LocaleMessages = {
greeting: "Hello",
farewell: "Goodbye",
welcome: "Welcome",
};

// Маппинг статусов
type StatusConfig = Record<'pending' | 'success' | 'error', {
color: string;
icon: string;
}>;

const statusConfig: StatusConfig = {
pending: { color: 'yellow', icon: 'clock' },
success: { color: 'green', icon: 'check' },
error: { color: 'red', icon: 'x' },
};

6. Readonly<T> — все поля только для чтения

// Создаёт тип, где все поля становятся readonly
type ImmutableUser = Readonly<User>;

const user: ImmutableUser = {
id: "1",
name: "John",
email: "john@example.com",
password: "secret",
role: "user",
createdAt: new Date(),
updatedAt: new Date(),
};

// user.name = "Jane"; // ❌ Ошибка: Cannot assign to 'name' because it is a read-only property

// Практическое использование: конфигурация
interface AppConfig {
apiUrl: string;
timeout: number;
retries: number;
}

const config: Readonly<AppConfig> = {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3,
};

// config.timeout = 10000; // ❌ Ошибка — конфигурация неизменяема

Продвинутые Utility Types

7. ReturnType<T> — тип возвращаемого значения функции

// Извлекает тип возвращаемого значения функции
function createUser() {
return {
id: "1",
name: "John",
email: "john@example.com",
};
}

type NewUser = ReturnType<typeof createUser>;
// {
// id: string;
// name: string;
// email: string;
// }

// Практическое использование: типизация ответов API
async function fetchUsers() {
const response = await fetch('/api/users');
return response.json();
}

type UsersResponse = Awaited<ReturnType<typeof fetchUsers>>;

8. Parameters<T> — типы параметров функции

// Извлекает типы параметров функции как кортеж
function updateUser(id: string, data: Partial<User>) {
// ...
}

type UpdateUserParams = Parameters<typeof updateUser>;
// [string, Partial<User>]

// Практическое использование: типизация обёрток
async function withLogging<T extends (...args: any[]) => any>(
fn: T,
...args: Parameters<T>
): Promise<ReturnType<T>> {
console.log('Calling function with args:', args);
const result = await fn(...args);
console.log('Result:', result);
return result;
}

// Типы параметров выводятся автоматически
withLogging(updateUser, "user-1", { name: "New Name" }); // ✅
withLogging(updateUser, 123, { name: "New Name" }); // ❌ Ошибка — id должен быть string

9. NonNullable<T> — исключает null и undefined

// Убирает null и undefined из типа
type MaybeString = string | null | undefined;
type DefinitelyString = NonNullable<MaybeString>; // string

// Практическое использование: фильтрация nullable значений
function getDefinedValues<T>(values: T[]): NonNullable<T>[] {
return values.filter((value): value is NonNullable<T> => value != null);
}

const values: (string | null | undefined)[] = ["a", null, "b", undefined, "c"];
const defined = getDefinedValues(values); // string[] — только "a", "b", "c"

10. Awaited<T> — извлекает тип из Promise

// Рекурсивно извлекает тип из Promise
type PromiseType = Awaited<Promise<string>>; // string
type NestedPromise = Awaited<Promise<Promise<number>>>; // number

// Практическое использование: типизация async функций
async function fetchUser(): Promise<User> {
// ...
}

type UserFromFetch = Awaited<ReturnType<typeof fetchUser>>; // User

Комбинирование Utility Types

// Комбинация Partial и Pick
type UpdateUserProfile = Partial<Pick<User, 'name' | 'email'>>;
// {
// name?: string;
// email?: string;
// }

// Комбинация Omit и Pick
type UserCredentials = Pick<User, 'email' | 'password'>;
type UserPublicData = Omit<User, 'password'>;

// Комбинация Readonly и Partial
type ImmutableUpdate = Readonly<Partial<User>>;
// Все поля опциональные И readonly

// Практический пример: полная система типов для CRUD
interface Entity {
id: string;
createdAt: Date;
updatedAt: Date;
}

interface User extends Entity {
name: string;
email: string;
password: string;
role: 'admin' | 'user';
}

// Типы для операций CRUD
type CreateUserDTO = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
type UpdateUserDTO = Partial<CreateUserDTO>;
type UserResponseDTO = Omit<User, 'password'>;
type UserListResponse = {
items: UserResponseDTO[];
total: number;
page: number;
pageSize: number;
};

// Тип для поиска
type UserSearchParams = Partial<Pick<User, 'name' | 'email' | 'role'>> & {
page?: number;
pageSize?: number;
};

// Тип для формы
type UserFormData = Omit<User, 'id' | 'createdAt' | 'updatedAt' | 'role'> & {
confirmPassword: string;
};

Кастомные Utility Types

// DeepPartial — рекурсивный Partial для вложенных объекts
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

interface NestedConfig {
database: {
host: string;
port: number;
credentials: {
username: string;
password: string;
};
};
}

type PartialConfig = DeepPartial<NestedConfig>;
// Все поля на всех уровнях вложенности опциональны

const config: PartialConfig = {
database: {
host: "localhost",
// port и credentials опциональны
},
};

// DeepReadonly — рекурсивный Readonly
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};

// Nullable — все поля nullable
type Nullable<T> = {
[P in keyof T]: T[P] | null;
};

// Использование для форм, где все поля могут быть null
type UserFormNullable = Nullable<User>;

// ValueOf — тип значений объекта
type ValueOf<T> = T[keyof T];

type UserRoles = 'admin' | 'user' | 'moderator';
type RolePermissions = Record<UserRoles, string[]>;

type Permission = ValueOf<RolePermissions>; // string[]

// KeysOfValueType — ключи, значения которых имеют определённый тип
type KeysOfValueType<T, V> = {
[K in keyof T]: T[K] extends V ? K : never;
}[keyof T];

interface Mixed {
id: string;
name: string;
age: number;
isActive: boolean;
}

type StringKeys = KeysOfValueType<Mixed, string>; // "id" | "name"
type NumberKeys = KeysOfValueType<Mixed, number>; // "age"

Итог

Наиболее часто используемые Utility Types в повседневной работе:

  • Partial — для DTO обновления, где все поля опциональны.
  • Pick — для выбора подмножества полей (публичный профиль, форма).
  • Omit — для исключения полей (безопасный DTO без пароля, входные данные без id).
  • Record — для словарей, конфигураций, маппингов.
  • Readonly — для неизменяемых конфигураций и состояний.
  • ReturnType/Parameters — для типизации обёрток и утилит над функциями.

Комбинирование Utility Types позволяет создавать точные типы для любых сценариев: CRUD операции, формы, API-ответы, конфигурации.

Вопрос 18. Какие утилитарные типы TypeScript используешь в работе?

Таймкод: 00:31:01

Ответ собеседника: Правильный. Наиболее часто использует Partial, Omit, Pick. Также знает Required, Readonly, литеральные строковые типы вроде Uppercase, Lowercase, но с ними не работал. Упомянул Extract, но отметил, что он больше используется в основе Omit и Pick.

Правильный ответ:

Ответ собеседника хороший — он демонстрирует практический опыт с основными Utility Types и понимание их взаимосвязей. Дополним ответ полным обзором всех ключевых Utility Types с практическими примерами.

Наиболее частые в повседневной работе

interface User {
id: string;
name: string;
email: string;
password: string;
role: 'admin' | 'user' | 'moderator';
createdAt: Date;
updatedAt: Date;
}

// Partial — обновление сущностей
type UpdateUserInput = Partial<User>;

// Omit — безопасные DTO без чувствительных полей
type UserPublicDTO = Omit<User, 'password'>;

// Pick — выбор конкретных полей
type UserCredentials = Pick<User, 'email' | 'password'>;

Extract и Exclude — работа с Union типами

// Собеседник правильно отметил связь с Pick/Omit.
// Разберём подробнее — это важная пара.

// Exclude<T, U> — исключает из T типы, которые есть в U
type Status = 'pending' | 'active' | 'completed' | 'cancelled';
type ActiveStatus = Exclude<Status, 'cancelled'>;
// 'pending' | 'active' | 'completed'

// Extract<T, U> — извлекает из T только типы, которые есть в U
type FinishedStatus = Extract<Status, 'completed' | 'cancelled'>;
// 'completed' | 'cancelled'

// Практическое использование: фильтрация событий
type EventType = 'click' | 'focus' | 'blur' | 'keydown' | 'keyup';
type MouseEventType = Extract<EventType, 'click'>;
type KeyboardEventType = Extract<EventType, 'keydown' | 'keyup'>;
type FocusEventType = Exclude<EventType, 'click' | 'keydown' | 'keyup'>;

// С Pick/Omit для объектов:
// Pick<T, K> = { [P in Extract<keyof T, K>]: T[P] }
// Omit<T, K> = Pick<T, Exclude<keyof T, K>>

Record — типобезопасные словари

type UserRoles = Record<string, 'admin' | 'user' | 'moderator'>;

// Конфигурация статусов
type StatusConfig = Record<'pending' | 'success' | 'error', {
color: string;
icon: string;
}>;

const statusConfig: StatusConfig = {
pending: { color: 'yellow', icon: 'clock' },
success: { color: 'green', icon: 'check' },
error: { color: 'red', icon: 'x' },
};

ReturnType и Parameters — типизация функций

// ReturnType — тип возвращаемого значения
function createUser() {
return { id: "1", name: "John", email: "john@example.com" };
}
type NewUser = ReturnType<typeof createUser>;

// Parameters — типы параметров
function updateUser(id: string, data: Partial<User>) { /* ... */ }
type UpdateUserParams = Parameters<typeof createUser>;

String manipulation types

// Uppercase, Lowercase, Capitalize, Uncapitalize
type HttpMethod = 'get' | 'post' | 'put' | 'delete';
type UpperMethod = Uppercase<HttpMethod>; // 'GET' | 'POST' | 'PUT' | 'DELETE'

type EventName = 'click' | 'focus' | 'blur';
type HandlerName = `on${Capitalize<EventName>}>; // 'onClick' | 'onFocus' | 'onBlur'

// Практическое использование: генерация API endpoints
type Entity = 'user' | 'post' | 'comment';
type GetEndpoint = `get${Capitalize<Entity>}s`; // 'getUsers' | 'getPosts' | 'getComments'

Итог

Ответ собеседника демонстрирует хороший практический уровень. Основные Utility Types для ежедневной работы:

  • Partial, Omit, Pick — основа типобезопасных DTO и форм.
  • Exclude/Extract — работа с union типами, основа для Pick/Omit.
  • Record — типобезопасные словари и конфигурации.
  • ReturnType/Parameters — типизация обёрток и утилит.
  • String manipulation — генерация типов из строк, менее частая, но мощная возможность.

Вопрос 19. В чём разница между Omit и Exclude в TypeScript?

Таймкод: 00:31:59

Ответ собеседника: Неполный. Omit удаляет конкретные поля из типа (можно выбрать одно или несколько полей). Exclude работает с union-типами — исключает из первого типа все типы, которые совместимы со вторым. Кандидат сначала неправильно описал Exclude (сказал, что он удаляет совместимые свойства из двух типов), но после обсуждения и проверки в коде пришёл к правильному пониманию: Exclude работает именно с union-типами, а не с полями объектных типов.

Правильный ответ:

Ответ собеседника после уточнения стал правильным. Разберём разницу между Omit и Exclude подробнее — это одна из самых частых путаниц в TypeScript.

Ключевое различие

┌─────────────────────────────────────────────────────────────┐
│ Omit<T, K> │
│ │
│ Работает с: объектными типами (interface, type) │
│ Исключает: ключи (поля) из объекта │
│ Результат: объектный тип без указанных полей │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│ Exclude<T, U> │
│ │
│ Работает с: union типами (A | B | C) │
│ Исключает: элементы union из первого типа │
│ Результат: union тип без указанных элементов │
└─────────────────────────────────────────────────────────────┘

Omit — работа с объектными типами

interface User {
id: string;
name: string;
email: string;
password: string;
createdAt: Date;
}

// Omit исключает указанные КЛЮЧИ из объектного типа
type PublicUser = Omit<User, 'password' | 'createdAt'>;
// {
// id: string;
// name: string;
// email: string;
// }

// Пример использования
function displayUser(user: PublicUser) {
console.log(user.name); // ✅
console.log(user.email); // ✅
// console.log(user.password); // ❌ Property 'password' does not exist
}

Exclude — работа с union типами

// Exclude исключает элементы из union типа
type Status = 'pending' | 'active' | 'completed' | 'cancelled';

// Убираем 'cancelled' из union типа
type ActiveStatus = Exclude<Status, 'cancelled'>;
// 'pending' | 'active' | 'completed'

// Убираем несколько элементов
type FinalStatus = Exclude<Status, 'pending' | 'active'>;
// 'completed' | 'cancelled'

// Пример использования
function isActive(status: ActiveStatus): boolean {
switch (status) {
case 'pending': return true;
case 'active': return true;
case 'completed': return false;
// case 'cancelled': // ❌ Type '"cancelled"' is not assignable
}
}

Практические примеры использования

// ═══════════════════════════════════════════════════════════
// Omit: безопасные DTO
// ═══════════════════════════════════════════════════════════

interface UserEntity {
id: string;
name: string;
email: string;
passwordHash: string;
refreshToken: string;
createdAt: Date;
updatedAt: Date;
}

// Публичный DTO — исключаем чувствительные поля
type UserPublicDTO = Omit<UserEntity, 'passwordHash' | 'refreshToken'>;

// Входные данные для создания — исключаем автогенерируемые поля
type CreateUserDTO = Omit<UserEntity, 'id' | 'createdAt' | 'updatedAt'>;

// Входные данные для обновления — все поля опциональные, кроме id
type UpdateUserDTO = Partial<Omit<UserEntity, 'id'>> & Pick<UserEntity, 'id'>;

// ═══════════════════════════════════════════════════════════
// Exclude: фильтрация union типов
// ═══════════════════════════════════════════════════════════

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';

// Только безопасные методы (без side effects)
type SafeMethod = Exclude<HttpMethod, 'POST' | 'PUT' | 'DELETE' | 'PATCH'>;
// 'GET'

// Только методы, которые изменяют данные
type MutatingMethod = Exclude<HttpMethod, 'GET'>;
// 'POST' | 'PUT' | 'DELETE' | 'PATCH'

// Типы событий
type EventType = 'click' | 'focus' | 'blur' | 'keydown' | 'keyup' | 'mousemove';

// Только события клавиатуры
type KeyboardEvent = Exclude<EventType, 'click' | 'focus' | 'blur' | 'mousemove'>;
// 'keydown' | 'keyup'

// Только события мыши
type MouseEvent = Exclude<EventType, 'keydown' | 'keyup' | 'focus' | 'blur'>;
// 'click' | 'mousemove'

Связь между Omit и Exclude

// Omit можно выразить через Pick и Exclude:

type MyOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

// Разбор:
// 1. keyof T — получаем все ключи объекта: 'id' | 'name' | 'email' | 'password'
// 2. Exclude<keyof T, K> — убираем указанные ключи: 'id' | 'name' | 'email'
// 3. Pick<T, ...> — выбираем оставшиеся ключи из T

interface User {
id: string;
name: string;
email: string;
password: string;
}

// Пошагово:
// keyof User = 'id' | 'name' | 'email' | 'password'
// Exclude<keyof User, 'password'> = 'id' | 'name' | 'email'
// Pick<User, 'id' | 'name' | 'email'> = { id: string; name: string; email: string }

type PublicUser = MyOmit<User, 'password'>;
// { id: string; name: string; email: string }

Сводная таблица

┌─────────────┬──────────────────────┬──────────────────────┐
│ │ Omit │ Exclude │
├─────────────┼──────────────────────┼──────────────────────┤
│ Входной тип │ Объектный тип │ Union тип │
│ │ (interface, type) │ (A | B | C) │
├─────────────┼──────────────────────┼──────────────────────┤
│ Что │ Ключи объекта │ Элементы union │
│ исключает │ ('name', 'email') │ ('a', 'b') │
├─────────────┼──────────────────────┼──────────────────────┤
│ Результат │ Объектный тип │ Union тип │
│ │ без полей │ без элементов │
├─────────────┼──────────────────────┼──────────────────────┤
│ Пример │ Omit<User, 'pass'> │ Exclude<Status, │
│ │ │ 'cancelled'> │
├─────────────┼──────────────────────┼──────────────────────┤
│ Применение │ DTO, формы, API │ Фильтрация статусов, │
│ │ │ событий, методов │
└─────────────┴──────────────────────┴──────────────────────┘

Итог

  • Omit<T, K> — работает с объектными типами, исключает указанные ключи (поля). Используется для создания DTO, форм, API-контрактов.
  • Exclude<T, U> — работает с union типами, исключает элементы из первого типа, которые совместимы со вторым. Используется для фильтрации статусов, событий, методов.
  • Связь: Omit внутри использует Exclude для фильтрации ключей: Omit<T, K> = Pick<T, Exclude<keyof T, K>>.

Вопрос 20. Что такое Union и Intersection типы в TypeScript?

Таймкод: 00:37:52

Ответ собеседника: Правильный. Union-типы (объединения) — это «либо один тип, либо другой» (оператор |). Берёт поля либо из одного типа, либо из другого. Intersection работает как наследование в интерфейсах — объединяет все свойства и методы нескольких типов в один (оператор &). Тип, использующий intersection, получает все свойства и методы всех составляющих типов.

Правильный ответ:

Ответ собеседника верный и хорошо передаёт суть. Дополним ответ подробностями о разнице в доступе к полям, практическими паттернами и распространёнными ошибками.

Union типы (A | B)

// Union — значение может быть ЛИБО одним типом, ЛИБО другим
type Status = 'pending' | 'success' | 'error';
type ID = string | number;
type Response = SuccessResponse | ErrorResponse;

// ═══════════════════════════════════════════════════════════
// Ключевой момент: безопасный доступ к полям Union типа
// ═══════════════════════════════════════════════════════════

interface Cat {
name: string;
purr(): void;
meow(): void;
}

interface Dog {
name: string;
bark(): void;
wagTail(): void;
}

type Pet = Cat | Dog;

// ❌ Нельзя вызвать метод, который есть только в одном типе
function interact(pet: Pet) {
console.log(pet.name); // ✅ name есть в обоих типах
// pet.meow(); // ❌ Ошибка: может быть Dog, у которого нет meow
// pet.bark(); // ❌ Ошибка: может быть Cat, у которого нет bark
}

// ✅ Нужна проверка типа (type narrowing)
function interactSafely(pet: Pet) {
console.log(pet.name); // ✅ name есть в обоих

if ('meow' in pet) {
// TypeScript знает: здесь pet — Cat
pet.meow(); // ✅
pet.purr(); // ✅
} else {
// TypeScript знает: здесь pet — Dog
pet.bark(); // ✅
pet.wagTail(); // ✅
}
}

Intersection типы (A & B)

// Intersection — значение должно соответствовать ВСЕМ типам одновременно
type Named = { name: string };
type Aged = { age: number };
type Emailed = { email: string };

type Person = Named & Aged & Emailed;

// Person должен иметь ВСЕ поля из всех типов
const person: Person = {
name: "John", // Из Named
age: 30, // Из Aged
email: "john@example.com", // Из Emailed
};

// ═══════════════════════════════════════════════════════════
// Практическое использование: композиция поведения
// ═══════════════════════════════════════════════════════════

interface Serializable {
serialize(): string;
}

interface Deserializable<T> {
deserialize(data: string): T;
}

interface Validatable {
validate(): boolean;
}

// Комбинируем поведение через intersection
type UserModel = User & Serializable & Deserializable<User> & Validatable;

// Теперь UserModel имеет все методы:
const user: UserModel = {
id: "1",
name: "John",
email: "john@example.com",
serialize() { return JSON.stringify(this); },
deserialize(data: string) { return JSON.parse(data) as User; },
validate() { return this.name.length > 0 && this.email.includes('@'); },
};

Различие в доступе к полям

interface A {
a: string;
shared: number;
}

interface B {
b: number;
shared: boolean;
}

// Union: доступны только общие поля (без проверки типа)
type UnionAB = A | B;

const union: UnionAB = { a: "hello", shared: 42 }; // ✅ Это A
const union2: UnionAB = { b: 123, shared: true }; // ✅ Это B

function processUnion(item: UnionAB) {
// item.shared — number | boolean (union типов полей)
// item.a — недоступно без проверки
// item.b — недоступно без проверки

if ('a' in item) {
item.a; // ✅ string
item.shared; // ✅ number
} else {
item.b; // ✅ number
item.shared; // ✅ boolean
}
}

// Intersection: доступны ВСЕ поля
type IntersectionAB = A & B;

const intersection: IntersectionAB = {
a: "hello",
b: 123,
// shared: ??? // number & boolean = never (невозможно!)
};

function processIntersection(item: IntersectionAB) {
item.a; // ✅ string
item.b; // ✅ number
item.shared; // ✅ number & boolean = never (конфликт типов)
}

Паттерн: Discriminated Union

// Самый частый и полезный паттерн с Union типами
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number };

// TypeScript автоматически сужает тип по полю kind
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rectangle':
return shape.width * shape.height;
case 'triangle':
return (shape.base * shape.height) / 2;
}
}

Сводная таблица

┌──────────────┬────────────────────────┬────────────────────────┐
│ │ Union (|) │ Intersection (&) │
├──────────────┼────────────────────────┼────────────────────────┤
│ Значение │ Один из типов │ Все типы одновременно │
├──────────────┼────────────────────────┼────────────────────────┤
│ Доступ к │ Только общие поля │ Все поля всех типов │
│ полям │ (без narrowing) │ │
├──────────────┼────────────────────────┼────────────────────────┤
│ Аналогия │ ИЛИ (OR) │ И (AND) │
├──────────────┼────────────────────────┼────────────────────────┤
│ Применение │ Статусы, события, │ Композиция поведения, │
│ │ ответы API │ mixins, расширение │
└──────────────┴────────────────────────┴────────────────────────┘

Итог

  • Union (A | B) — значение может быть одним из типов. Без type narrowing доступны только общие поля. Используется для статусов, событий, ответов API.
  • Intersection (A & B) — значение должно соответствовать всем типам одновременно. Доступны все поля всех типов. Используется для композиции поведения (mixins).
  • Ключевая ошибка: путать доступ к полям — в Union нужен narrowing, в Intersection поля с конфликтом типов становятся never.

Вопрос 21. Какие антипаттерны в TypeScript ты знаешь?

Таймкод: 00:39:05

Ответ собеседника: Правильный. Два главных антипаттерна — any и type assertion (as). Они ломают логику TypeScript вместо подсказок — разработчик утверждает, что знает что делает. Также упомянул @ts-ignore, необработанную исчерпывающую проверку в discriminated unions, использование enums (из-за попадания в рантайм), и типизацию ради типизации — создание огромных типов там, где TypeScript может вывести тип автоматически.

Правильный ответ:

Ответ собеседника отличный — он перечисляет все ключевые антипаттерны. Разберём каждый подробнее с примерами проблем и правильными альтернативами.

1. Использование any

// ❌ Антипаттерн: any отключает всю типобезопасность
function processData(data: any) {
return data.name.toUpperCase(); // Никакой проверки
// data может быть чем угодно — ошибка в рантайме
}

processData({ name: "John" }); // "JOHN"
processData({ name: null }); // TypeError в рантайме!
processData("string"); // TypeError в рантайме!

// ✅ Альтернатива: использовать unknown + type narrowing
function processDataSafe(data: unknown) {
if (typeof data === 'object' && data !== null && 'name' in data) {
const obj = data as { name: string };
if (typeof obj.name === 'string') {
return obj.name.toUpperCase();
}
}
throw new Error('Invalid data format');
}

// ✅ Ещё лучше: использовать конкретный тип
interface DataWithName {
name: string;
}

function processDataTyped(data: DataWithName) {
return data.name.toUpperCase(); // Безопасно
}

2. Type Assertion (as) вместо типобезопасного кода

// ❌ Антипаттерн: утверждение типа без проверки
function handleResponse(response: unknown) {
const user = response as User; // Утверждаем, что это User
console.log(user.name); // Может упасть в рантайме!
}

// ✅ Альтернатива: type guard
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
'email' in value &&
typeof (value as User).id === 'string' &&
typeof (value as User).name === 'string' &&
typeof (value as User).email === 'string'
);
}

function handleResponseSafe(response: unknown) {
if (isUser(response)) {
console.log(response.name); // Безопасно — TypeScript знает, что это User
} else {
console.error('Invalid response format');
}
}

3. @ts-ignore и @ts-expect-error

// ❌ Антипаттерн: подавление ошибок компиляции
// @ts-ignore
const result = someFunction(); // Почему ошибка? Никто не знает.

// ❌ Злоупотребление @ts-expect-error
// @ts-expect-error: временная мера
const data = response as any; // "Временная мера" остаётся навсегда

// ✅ Альтернатива: исправить корневую причину
// Если ошибка легитимна — использовать правильный тип
// Если ошибка в библиотеке — написать .d.ts файл
// Если временно — оставить TODO с объяснением

4. Необработанная исчерпывающая проверка (exhaustive check)

// ❌ Антипаттерн: забыли обработать все варианты
type Status = 'pending' | 'success' | 'error';

function getStatusMessage(status: Status): string {
switch (status) {
case 'pending': return 'Загрузка...';
case 'success': return 'Успешно!';
// case 'error' забыли — вернёт undefined в рантайме
}
}

// ✅ Альтернатива: exhaustive check через never
function getStatusMessageSafe(status: Status): string {
switch (status) {
case 'pending': return 'Загрузка...';
case 'success': return 'Успешно!';
case 'error': return 'Ошибка!';
default:
// Если добавить новый статус в тип, TypeScript выдаст ошибку здесь
const _exhaustive: never = status;
throw new Error(`Unhandled status: ${_exhaustive}`);
}
}

5. Enums вместо union типов

// ❌ Антипаттерн: enum генерирует код в рантайме
enum Status {
Pending = 'PENDING',
Success = 'SUCCESS',
Error = 'ERROR',
}

// Компилируется в:
// var Status;
// (function (Status) {
// Status["Pending"] = "PENDING";
// Status["Success"] = "SUCCESS";
// Status["Error"] = "ERROR";
// })(Status || (Status = {}));

// ✅ Альтернатива: const enum или union тип
type StatusUnion = 'PENDING' | 'SUCCESS' | 'ERROR';

// Или const enum (не генерирует объект в рантайме)
const enum StatusConst {
Pending = 'PENDING',
Success = 'SUCCESS',
Error = 'ERROR',
}

6. Типизация ради типизации (избыточные типы)

// ❌ Антипаттерн: создание типов, которые TypeScript может вывести
const name: string = "John"; // string выводится автоматически
const numbers: number[] = [1, 2, 3]; // number[] выводится автоматически
const user: User = { // User выводится из return type
id: "1",
name: "John",
email: "john@example.com",
};

function getUserName(user: User): string { // string выводится из return
return user.name;
}

// ✅ Альтернатива: доверить вывод типов TypeScript
const name = "John"; // TypeScript выводит string
const numbers = [1, 2, 3]; // TypeScript выводит number[]
const user = { // TypeScript выводит тип объекта
id: "1",
name: "John",
email: "john@example.com",
};

function getUserName(user: User) { // TypeScript выводит string
return user.name;
}

7. Использование ! (non-null assertion)

// ❌ Антипаттерн: утверждение, что значение не null без проверки
function getFirstElement(arr: string[]): string {
return arr[0]!; // Массив может быть пустым!
}

getFirstElement([]); // undefined в рантайме

// ✅ Альтернатива: проверка или optional chaining
function getFirstElementSafe(arr: string[]): string | undefined {
return arr[0]; // TypeScript правильно выводит string | undefined
}

// Или с дефолтным значением
function getFirstElementOrDefault(arr: string[], defaultValue: string): string {
return arr[0] ?? defaultValue;
}

8. Игнорирование strict режима

// ❌ Антипаттерн: отключение strict в tsconfig.json
// {
// "compilerOptions": {
// "strict": false, // Отключает все strict-проверки
// "noImplicitAny": false,
// "strictNullChecks": false
// }
// }

// ✅ Альтернатива: включить strict и исправлять ошибки
// {
// "compilerOptions": {
// "strict": true // Включает все strict-проверки
// }
// }

Сводная таблица антипаттернов

┌─────────────────────┬──────────────────────────────────┐
│ Антипаттерн │ Проблема │
├─────────────────────┼──────────────────────────────────┤
│ any │ Отключает типобезопасность │
│ as (type assertion) │ Утверждение без проверки │
│ @ts-ignore │ Подавление ошибок │
│ non-null assertion │ Игнорирование null/undefined │
│ enum │ Код в рантайме, больше бандла │
│ Избыточные типы │ Шум, сложность поддержки │
│ Без strict режима │ Много скрытых ошибок │
└─────────────────────┴──────────────────────────────────┘

Итог

Ответ собеседника покрывает все основные антипаттерны. Главный принцип — TypeScript должен помогать находить ошибки на этапе компиляции, а не переносить их в рантайм. any, as и @ts-ignore — это способы сказать TypeScript «доверься мне», но чаще всего это приводит к багам в продакшене. Правильный подход — использовать unknown, type guards, exhaustive checks и доверять выводу типов.

Вопрос 21. Какие антипаттерны в TypeScript ты знаешь?

Таймкод: 00:39:05

Ответ собеседника: Правильный. Два главных антипаттерна — any и type assertion (as). Они ломают логику TypeScript вместо подсказок — разработчик утверждает, что знает что делает. Также упомянул @ts-ignore, необработанную исчерпывающую проверку в discriminated unions, использование enums (из-за попадания в рантайм), и типизацию ради типизации — создание огромных типов там, где TypeScript может вывести тип автоматически.

Правильный ответ:

Ответ собеседника отличный — он перечисляет все ключевые антипаттерны. Разберём каждый подробнее с примерами проблем и правильными альтернативами.

1. Использование any

// ❌ Антипаттерн: any отключает всю типобезопасность
function processData(data: any) {
return data.name.toUpperCase(); // Никакой проверки
}

processData({ name: null }); // TypeError в рантайме!
processData("string"); // TypeError в рантайме!

// ✅ Альтернатива: unknown + type narrowing
function processDataSafe(data: unknown) {
if (typeof data === 'object' && data !== null && 'name' in data) {
const obj = data as { name: string };
if (typeof obj.name === 'string') {
return obj.name.toUpperCase();
}
}
throw new Error('Invalid data format');
}

2. Type Assertion (as) без проверки

// ❌ Антипаттерн: утверждение типа без проверки
function handleResponse(response: unknown) {
const user = response as User; // Утверждаем, что это User
console.log(user.name); // Может упасть в рантайме!
}

// ✅ Альтернатива: type guard
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
'email' in value &&
typeof (value as User).id === 'string' &&
typeof (value as User).name === 'string' &&
typeof (value as User).email === 'string'
);
}

function handleResponseSafe(response: unknown) {
if (isUser(response)) {
console.log(response.name); // Безопасно
}
}

3. @ts-ignore и @ts-expect-error

// ❌ Подавление ошибок без понимания причины
// @ts-ignore
const result = someFunction();

// ✅ Лучше: исправить корневую причину или использовать @ts-expect-error
// @ts-expect-error: тип из библиотеки неверный, issue #1234
const result = someFunction();

4. Необработанная исчерпывающая проверка

// ❌ Забыли обработать все варианты
type Status = 'pending' | 'success' | 'error';

function getMessage(status: Status): string {
switch (status) {
case 'pending': return 'Загрузка...';
case 'success': return 'Успешно!';
// case 'error' забыли
}
}

// ✅ Exhaustive check через never
function getMessageSafe(status: Status): string {
switch (status) {
case 'pending': return 'Загрузка...';
case 'success': return 'Успешно!';
case 'error': return 'Ошибка!';
default:
const _exhaustive: never = status;
throw new Error(`Unhandled status: ${_exhaustive}`);
}
}

5. Enums вместо union типов

// ❌ Enum генерирует код в рантайме
enum Status {
Pending = 'PENDING',
Success = 'SUCCESS',
Error = 'ERROR',
}

// ✅ Union тип — нет кода в рантайме
type StatusUnion = 'PENDING' | 'SUCCESS' | 'ERROR';

// Или const enum
const enum StatusConst {
Pending = 'PENDING',
Success = 'SUCCESS',
Error = 'ERROR',
}

6. Избыточная типизация

// ❌ Типизация там, где TypeScript выводит тип
const name: string = "John";
const numbers: number[] = [1, 2, 3];

// ✅ Доверить вывод типов
const name = "John"; // string
const numbers = [1, 2, 3]; // number[]

7. Non-null assertion (!)

// ❌ Утверждение без проверки
function getFirst(arr: string[]): string {
return arr[0]!; // Массив может быть пустым!
}

// ✅ Проверка или optional chaining
function getFirstSafe(arr: string[]): string | undefined {
return arr[0];
}

Итог

Главный принцип — TypeScript должен помогать находить ошибки на этапе компиляции. any, as и @ts-ignore — это способы сказать «доверься мне», но чаще всего это приводит к багам в продакшене. Правильный подход — использовать unknown, type guards, exhaustive checks и доверять выводу типов.

Вопрос 22. Чем unknown отличается от any и как правильно работать с unknown?

Таймкод: 00:40:52

Ответ собеседника: Правильный. unknown безопаснее any — не даёт доступ ко всем методам и свойствам без проверки. Для работы с unknown необходимо сузить тип (type narrowing) до ожидаемого через type guards. Основные способы: typeof, проверка свойств объекта (in), instanceof, сравнения с null/undefined, switch-конструкции. После сужения типа становятся доступны все методы соответствующего типа.

Правильный ответ:

Ответ собеседника верный. Дополним его подробным сравнением any и unknown, а также практическими паттернами работы с unknown.

Ключевое отличие: any vs unknown

┌─────────────────────────────────────────────────────────────┐
│ any │
│ │
│ • Отключает ВСЕ проверки типов │
│ • Можно вызвать любой метод │
│ • Можно присвоить куда угодно │
│ • TypeScript не помогает │
│ • Ошибки только в рантайме │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│ unknown │
│ │
│ • Сохраняет типобезопасность │
│ • Нельзя вызвать никаких методов без проверки │
│ • Нельзя присвоить без приведения типа │
│ • TypeScript заставляет проверить тип │
│ • Ошибки на этапе компиляции │
└─────────────────────────────────────────────────────────────┘

Практическое сравнение

// ═══════════════════════════════════════════════════════════
// any — опасно, ошибки только в рантайме
// ═══════════════════════════════════════════════════════════

function processWithAny(data: any) {
// TypeScript не проверяет НИЧЕГО
console.log(data.name.toUpperCase()); // ✅ Компилируется
console.log(data.foo.bar.baz); // ✅ Компилируется
data(); // ✅ Компиляция

// Всё это упадёт в рантайме, если data не соответствует ожиданиям
}

processWithAny({ name: null }); // TypeError: Cannot read property 'toUpperCase' of null
processWithAny("string"); // TypeError: data.name is undefined
processWithAny(42); // TypeError: data is not a function

// ═══════════════════════════════════════════════════════════
// unknown — безопасно, ошибки на этапе компиляции
// ═══════════════════════════════════════════════════════════

function processWithUnknown(data: unknown) {
// TypeScript ЗАСТАВЛЯЕТ проверить тип перед использованием
// console.log(data.name.toUpperCase()); // ❌ Ошибка: Object is of type 'unknown'
// console.log(data.foo.bar.baz); // ❌ Ошибка: Object is of type 'unknown'
// data(); // ❌ Ошибка: Object is of type 'unknown'

// Нужно явно сузить тип
if (typeof data === 'string') {
console.log(data.toUpperCase()); // ✅ TypeScript знает: здесь data — string
}

if (typeof data === 'object' && data !== null && 'name' in data) {
const obj = data as { name: unknown };
if (typeof obj.name === 'string') {
console.log(obj.name.toUpperCase()); // ✅ Полная проверка
}
}
}

Способы type narrowing для unknown

// ═══════════════════════════════════════════════════════════
// 1. typeof — проверка примитивных типов
// ═══════════════════════════════════════════════════════════

function handleValue(value: unknown) {
if (typeof value === 'string') {
console.log(value.toUpperCase()); // string
} else if (typeof value === 'number') {
console.log(value.toFixed(2)); // number
} else if (typeof value === 'boolean') {
console.log(value ? 'yes' : 'no'); // boolean
}
}

// ═══════════════════════════════════════════════════════════
// 2. in — проверка наличия свойства
// ═══════════════════════════════════════════════════════════

interface User {
id: string;
name: string;
email: string;
}

function processObject(obj: unknown) {
if (typeof obj === 'object' && obj !== null) {
if ('id' in obj && 'name' in obj) {
// obj имеет хотя бы id и name
console.log((obj as { id: unknown; name: unknown }).id);
}

if ('email' in obj) {
// obj имеет email
console.log((obj as { email: unknown }).email);
}
}
}

// ═══════════════════════════════════════════════════════════
// 3. instanceof — проверка класса
// ═══════════════════════════════════════════════════════════

class ApiError extends Error {
constructor(public statusCode: number, message: string) {
super(message);
}
}

function handleError(error: unknown) {
if (error instanceof ApiError) {
console.log(`API Error ${error.statusCode}: ${error.message}`);
} else if (error instanceof Error) {
console.log(`Error: ${error.message}`);
} else {
console.log('Unknown error:', error);
}
}

// ═══════════════════════════════════════════════════════════
// 4. Type guard функция
// ═══════════════════════════════════════════════════════════

function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
'email' in value &&
typeof (value as User).id === 'string' &&
typeof (value as User).name === 'string' &&
typeof (value as User).email === 'string'
);
}

function processUser(data: unknown) {
if (isUser(data)) {
console.log(data.name); // TypeScript знает: здесь data — User
console.log(data.email); // Безопасно
} else {
throw new Error('Invalid user data');
}
}

// ═══════════════════════════════════════════════════════════
// 5. Discriminated union + switch
// ═══════════════════════════════════════════════════════════

type ApiResponse =
| { status: 'success'; data: User[] }
| { status: 'error'; error: string }
| { status: 'loading' };

function handleResponse(response: unknown) {
if (
typeof response === 'object' &&
response !== null &&
'status' in response &&
typeof (response as ApiResponse).status === 'string'
) {
const typed = response as ApiResponse;

switch (typed.status) {
case 'success':
typed.data.forEach(user => console.log(user.name)); // User[]
break;
case 'error':
console.error(typed.error); // string
break;
case 'loading':
console.log('Loading...');
break;
}
}
}

Практический пример: безопасный JSON.parse

// ❌ Опасный парсинг
function parseUserUnsafe(json: string): User {
return JSON.parse(json) as User; // Нет гарантии, что это User!
}

// ✅ Безопасный парсинг с валидацией
function parseUserSafe(json: string): User {
const parsed: unknown = JSON.parse(json);

if (!isUser(parsed)) {
throw new Error('Invalid user data');
}

return parsed; // TypeScript знает: это User
}

// Или с результатом
function parseUserResult(json: string): { success: true; data: User } | { success: false; error: string } {
try {
const parsed: unknown = JSON.parse(json);

if (isUser(parsed)) {
return { success: true, data: parsed };
}

return { success: false, error: 'Invalid user format' };
} catch (e) {
return { success: false, error: 'Invalid JSON' };
}
}

Когда использовать unknown вместо any

// Используйте unknown, когда:
// 1. Принимаете данные извне (API, localStorage, пользовательский ввод)
// 2. Пишете утилитные функции с неизвестным входом
// 3. Хотите сохранить типобезопасность

// ❌ Плохо
function saveToLocalStorage(key: string, value: any) {
localStorage.setItem(key, JSON.stringify(value));
}

function loadFromLocalStorage(key: string): any {
const data = localStorage.getItem(key);
return data ? JSON.parse(data) : null;
}

// ✅ Хорошо
function saveToLocalStorage<T>(key: string, value: T): void {
localStorage.setItem(key, JSON.stringify(value));
}

function loadFromLocalStorage<T>(key: string, validator: (data: unknown) => data is T): T | null {
const data = localStorage.getItem(key);
if (!data) return null;

const parsed: unknown = JSON.parse(data);
return validator(parsed) ? parsed : null;
}

// Использование
const user = loadFromLocalStorage('user', isUser);
if (user) {
console.log(user.name); // Безопасно
}

Итог

  • any — «доверься мне, я знаю, что делаю». Отключает все проверки. Ошибки только в рантайме.
  • unknown — «я не знаю, что это, но буду работать безопасно». Заставляет проверить тип перед использованием. Ошибки на этапе компиляции.
  • Правило: Всегда используйте unknown вместо any, когда тип заранее неизвестен. Это особенно важно для данных извне: API-ответы, localStorage, пользовательский ввод, JSON.parse.

Вопрос 23. Что такое дженерики в TypeScript и для чего они используются?

Таймкод: 00:42:55

Ответ собеседника: Правильный. Дженерики позволяют создавать универсальные типы, когда заранее неизвестно, какой тип будет использован. Привёл пример с интерфейсом пагинации, где поле items могло содержать массив пользователей или продуктов — дженерик позволил универсализировать тип. Признал, что это его слабая тема — не упомянул constraints (ограничения) и условные конструкции в дженериках.

Правильный ответ:

Ответ собеседника хороший — он правильно описал суть дженериков и привёл практический пример. Дополним ответ ограничениями (constraints), условными типами и продвинутыми паттернами.

Базовые дженерики

// ═══════════════════════════════════════════════════════════
// Простая дженерик-функция
// ═══════════════════════════════════════════════════════════

function identity<T>(value: T): T {
return value;
}

const str = identity("hello"); // string
const num = identity(42); // number
const arr = identity([1, 2, 3]); // number[]

// ═══════════════════════════════════════════════════════════
// Дженерик-интерфейс (пример собеседника — пагинация)
// ═══════════════════════════════════════════════════════════

interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}

interface User {
id: string;
name: string;
email: string;
}

interface Product {
id: string;
title: string;
price: number;
}

// Универсальный тип для любой сущности
const userResponse: PaginatedResponse<User> = {
items: [{ id: "1", name: "John", email: "john@example.com" }],
total: 1,
page: 1,
pageSize: 10,
totalPages: 1,
};

const productResponse: PaginatedResponse<Product> = {
items: [{ id: "1", title: "Laptop", price: 999 }],
total: 1,
page: 1,
pageSize: 10,
totalPages: 1,
};

// Универсальная функция для получения пагинированных данных
async function fetchPaginated<T>(
endpoint: string,
page: number,
pageSize: number
): Promise<PaginatedResponse<T>> {
const response = await fetch(`${endpoint}?page=${page}&pageSize=${pageSize}`);
return response.json();
}

// Тип выводится автоматически
const users = await fetchPaginated<User>("/api/users", 1, 10); // PaginatedResponse<User>
const products = await fetchPaginated<Product>("/api/products", 1, 10); // PaginatedResponse<Product>

Ограничения (Constraints)

// ═══════════════════════════════════════════════════════════
// extends — ограничение типа параметра
// ═══════════════════════════════════════════════════════════

// ❌ Без ограничений — можно передать что угодно
function getPropertyBad<T>(obj: T, key: string): any {
// obj[key] — ошибка: нет гарантии, что key существует в T
return obj[key];
}

// ✅ С ограничением — key должен быть ключом obj
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}

const user = { id: "1", name: "John", email: "john@example.com" };

const name = getProperty(user, "name"); // string
const id = getProperty(user, "id"); // string
// const bad = getProperty(user, "phone"); // ❌ Ошибка: "phone" не является ключом User

// ═══════════════════════════════════════════════════════════
// Ограничение на наличие определённых полей
// ═══════════════════════════════════════════════════════════

interface HasId {
id: string;
}

// T должен иметь поле id
function findById<T extends HasId>(items: T[], id: string): T | undefined {
return items.find(item => item.id === id);
}

const users = [
{ id: "1", name: "John", email: "john@example.com" },
{ id: "2", name: "Jane", email: "jane@example.com" },
];

const user = findById(users, "1"); // Работает — User имеет id

// ═══════════════════════════════════════════════════════════
// Ограничение на классы
// ═══════════════════════════════════════════════════════════

interface Constructable<T> {
new (...args: any[]): T;
}

// T должен быть классом, который можно создать через new
function createInstance<T>(ctor: Constructable<T>, ...args: any[]): T {
return new ctor(...args);
}

class UserEntity {
constructor(public name: string, public email: string) {}
}

const userInstance = createInstance(UserEntity, "John", "john@example.com");

Условные типы (Conditional Types)

// ═══════════════════════════════════════════════════════════
// Базовый синтаксис: T extends U ? X : Y
// ═══════════════════════════════════════════════════════════

// Если T — массив, возвращаем элемент массива, иначе возвращаем T
type ElementType<T> = T extends (infer U)[] ? U : T;

type A = ElementType<string[]>; // string
type B = ElementType<number[]>; // number
type C = ElementType<string>; // string

// ═══════════════════════════════════════════════════════════
// Практический пример: ApiResponse<T>
// ═══════════════════════════════════════════════════════════

type ApiResponse<T> = T extends Array<infer U>
? { items: U[]; total: number }
: { data: T };

// Для массива
type UserList = ApiResponse<User[]>;
// { items: User[]; total: number }

// Для одиночного объекта
type SingleUser = ApiResponse<User>;
// { data: User }

// ═══════════════════════════════════════════════════════════
// Условные типы для фильтрации
// ═══════════════════════════════════════════════════════════

// Убираем из union типа null и undefined
type NonNullable<T> = T extends null | undefined ? never : T;

type A = NonNullable<string | null | undefined>; // string

// Извлекаем только строковые ключи
type StringKeys<T> = {
[K in keyof T]: T[K] extends string ? K : never;
}[keyof T];

interface User {
id: string;
name: string;
age: number;
email: string;
}

type UserStringKeys = StringKeys<User>; // "id" | "name" | "email"

Продвинутые паттерны

// ═══════════════════════════════════════════════════════════
// Дженерик-класс: Repository pattern
// ═══════════════════════════════════════════════════════════

interface Entity {
id: string;
}

class Repository<T extends Entity> {
private items: Map<string, T> = new Map();

create(item: T): T {
this.items.set(item.id, item);
return item;
}

findById(id: string): T | undefined {
return this.items.get(id);
}

findAll(): T[] {
return Array.from(this.items.values());
}

update(id: string, data: Partial<Omit<T, 'id'>>): T | undefined {
const existing = this.items.get(id);
if (!existing) return undefined;

const updated = { ...existing, ...data };
this.items.set(id, updated);
return updated;
}

delete(id: string): boolean {
return this.items.delete(id);
}
}

// Использование
interface User extends Entity {
id: string;
name: string;
email: string;
}

interface Product extends Entity {
id: string;
title: string;
price: number;
}

const userRepo = new Repository<User>();
const productRepo = new Repository<Product>();

userRepo.create({ id: "1", name: "John", email: "john@example.com" });
productRepo.create({ id: "1", title: "Laptop", price: 999 });

// ═══════════════════════════════════════════════════════════
// Дженерик с дефолтным типом
// ═══════════════════════════════════════════════════════════

interface ApiResponse<T = unknown> {
data: T;
status: number;
message: string;
}

// Без указания типа — unknown
const response1: ApiResponse = {
data: { anything: true },
status: 200,
message: "OK",
};

// С указанием типа — User
const response2: ApiResponse<User> = {
data: { id: "1", name: "John", email: "john@example.com" },
status: 200,
message: "OK",
};

// ═══════════════════════════════════════════════════════════
// Mapped Types + дженерики
// ═══════════════════════════════════════════════════════════

// Делаем все поля опциональными
type Partial<T> = {
[P in keyof T]?: T[P];
};

// Делаем все поля readonly
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};

// Выбираем только указанные поля
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};

// Исключаем указанные поля
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

// ═══════════════════════════════════════════════════════════
// infer — вывод типа внутри условного типа
// ═══════════════════════════════════════════════════════════

// Извлекаем тип из Promise
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

type A = UnwrapPromise<Promise<string>>; // string
type B = UnwrapPromise<number>; // number

// Извлекаем тип из массива
type UnwrapArray<T> = T extends (infer U)[] ? U : T;

type C = UnwrapArray<string[]>; // string
type D = UnwrapArray<number>; // number

// Извлекаем тип возвращаемого значения функции
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

function getUser() {
return { id: "1", name: "John" };
}

type UserReturn = ReturnType<typeof getUser>; // { id: string; name: string }

Итог

  • Базовые дженерики — создание универсальных функций, интерфейсов, классов.
  • Constraints (extends) — ограничение типов параметров: T extends HasId, K extends keyof T.
  • Условные типыT extends U ? X : Y для создания типов, зависящих от входного типа.
  • infer — вывод типа внутри условного типа: T extends Promise<infer U> ? U : T.
  • Практическое применение: репозитории, API-ответы, утилитные функции, type-safe ORM.

Вопрос 23. Что такое дженерики в TypeScript и для чего они используются?

Таймкод: 00:42:55

Ответ собеседника: Правильный. Дженерики позволяют создавать универсальные типы, когда заранее неизвестно, какой тип будет использован. Привёл пример с интерфейсом пагинации, где поле items могло содержать массив пользователей или продуктов — дженерик позволил универсализировать тип. Признал, что это его слабая тема — не упомянул constraints (ограничения) и условные конструкции в дженериках.

Правильный ответ:

Ответ собеседника хороший — он правильно описал суть дженериков и привёл практический пример. Дополним ответ ограничениями (constraints), условными типами и продвинутыми паттернами.

Базовые дженерики

// ═══════════════════════════════════════════════════════════
// Простая дженерик-функция
// ═══════════════════════════════════════════════════════════

function identity<T>(value: T): T {
return value;
}

const str = identity("hello"); // string
const num = identity(42); // number

// ═══════════════════════════════════════════════════════════
// Дженерик-интерфейс: пагинация
// ═══════════════════════════════════════════════════════════

interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}

interface User {
id: string;
name: string;
email: string;
}

interface Product {
id: string;
title: string;
price: number;
}

// Универсальный тип для любой сущности
const userResponse: PaginatedResponse<User> = {
items: [{ id: "1", name: "John", email: "john@example.com" }],
total: 1,
page: 1,
pageSize: 10,
totalPages: 1,
};

const productResponse: PaginatedResponse<Product> = {
items: [{ id: "1", title: "Laptop", price: 999 }],
total: 1,
page: 1,
pageSize: 10,
totalPages: 1,
};

// Универсальная функция для получения пагинированных данных
async function fetchPaginated<T>(
endpoint: string,
page: number,
pageSize: number
): Promise<PaginatedResponse<T>> {
const response = await fetch(`${endpoint}?page=${page}&pageSize=${pageSize}`);
return response.json();
}

// Тип выводится автоматически
const users = await fetchPaginated<User>("/api/users", 1, 10);
const products = await fetchPaginated<Product>("/api/products", 1, 10);

Ограничения (Constraints)

// ═══════════════════════════════════════════════════════════
// extends — ограничение типа параметра
// ═══════════════════════════════════════════════════════════

// ❌ Без ограничений — можно передать что угодно
function getPropertyBad<T>(obj: T, key: string): any {
return obj[key]; // Ошибка: нет гарантии, что key существует в T
}

// ✅ С ограничением — key должен быть ключом obj
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}

const user = { id: "1", name: "John", email: "john@example.com" };

const name = getProperty(user, "name"); // string
const id = getProperty(user, "id"); // string
// const bad = getProperty(user, "phone"); // ❌ Ошибка: "phone" не является ключом User

// ═══════════════════════════════════════════════════════════
// Ограничение на наличие определённых полей
// ═══════════════════════════════════════════════════════════

interface HasId {
id: string;
}

// T должен иметь поле id
function findById<T extends HasId>(items: T[], id: string): T | undefined {
return items.find(item => item.id === id);
}

const users = [
{ id: "1", name: "John", email: "john@example.com" },
{ id: "2", name: "Jane", email: "jane@example.com" },
];

const user = findById(users, "1"); // Работает — User имеет id

// ═══════════════════════════════════════════════════════════
// Ограничение на классы
// ═══════════════════════════════════════════════════════════

interface Constructable<T> {
new (...args: any[]): T;
}

// T должен быть классом, который можно создать через new
function createInstance<T>(ctor: Constructable<T>, ...args: any[]): T {
return new ctor(...args);
}

class UserEntity {
constructor(public name: string, public email: string) {}
}

const userInstance = createInstance(UserEntity, "John", "john@example.com");

Условные типы (Conditional Types)

// ═══════════════════════════════════════════════════════════
// Базовый синтаксис: T extends U ? X : Y
// ═══════════════════════════════════════════════════════════

// Если T — массив, возвращаем элемент массива, иначе возвращаем T
type ElementType<T> = T extends (infer U)[] ? U : T;

type A = ElementType<string[]>; // string
type B = ElementType<number[]>; // number
type C = ElementType<string>; // string

// ═══════════════════════════════════════════════════════════
// Практический пример: ApiResponse<T>
// ═══════════════════════════════════════════════════════════

type ApiResponse<T> = T extends Array<infer U>
? { items: U[]; total: number }
: { data: T };

// Для массива
type UserList = ApiResponse<User[]>;
// { items: User[]; total: number }

// Для одиночного объекта
type SingleUser = ApiResponse<User>;
// { data: User }

// ═══════════════════════════════════════════════════════════
// Условные типы для фильтрации
// ═══════════════════════════════════════════════════════════

// Убираем из union типа null и undefined
type NonNullable<T> = T extends null | undefined ? never : T;

type A = NonNullable<string | null | undefined>; // string

// Извлекаем только строковые ключи
type StringKeys<T> = {
[K in keyof T]: T[K] extends string ? K : never;
}[keyof T];

interface User {
id: string;
name: string;
age: number;
email: string;
}

type UserStringKeys = StringKeys<User>; // "id" | "name" | "email"

Продвинутые паттерны

// ═══════════════════════════════════════════════════════════
// Дженерик-класс: Repository pattern
// ═══════════════════════════════════════════════════════════

interface Entity {
id: string;
}

class Repository<T extends Entity> {
private items: Map<string, T> = new Map();

create(item: T): T {
this.items.set(item.id, item);
return item;
}

findById(id: string): T | undefined {
return this.items.get(id);
}

findAll(): T[] {
return Array.from(this.items.values());
}

update(id: string, data: Partial<Omit<T, 'id'>>): T | undefined {
const existing = this.items.get(id);
if (!existing) return undefined;

const updated = { ...existing, ...data };
this.items.set(id, updated);
return updated;
}

delete(id: string): boolean {
return this.items.delete(id);
}
}

// Использование
interface User extends Entity {
id: string;
name: string;
email: string;
}

interface Product extends Entity {
id: string;
title: string;
price: number;
}

const userRepo = new Repository<User>();
const productRepo = new Repository<Product>();

userRepo.create({ id: "1", name: "John", email: "john@example.com" });
productRepo.create({ id: "1", title: "Laptop", price: 999 });

// ═══════════════════════════════════════════════════════════
// Дженерик с дефолтным типом
// ═══════════════════════════════════════════════════════════

interface ApiResponse<T = unknown> {
data: T;
status: number;
message: string;
}

// Без указания типа — unknown
const response1: ApiResponse = {
data: { anything: true },
status: 200,
message: "OK",
};

// С указанием типа — User
const response2: ApiResponse<User> = {
data: { id: "1", name: "John", email: "john@example.com" },
status: 200,
message: "OK",
};

// ═══════════════════════════════════════════════════════════
// Mapped Types + дженерики
// ═══════════════════════════════════════════════════════════

// Делаем все поля опциональными
type Partial<T> = {
[P in keyof T]?: T[P];
};

// Делаем все поля readonly
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};

// Выбираем только указанные поля
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};

// Исключаем указанные поля
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

// ═══════════════════════════════════════════════════════════
// infer — вывод типа внутри условного типа
// ═══════════════════════════════════════════════════════════

// Извлекаем тип из Promise
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

type A = UnwrapPromise<Promise<string>>; // string
type B = UnwrapPromise<number>; // number

// Извлекаем тип из массива
type UnwrapArray<T> = T extends (infer U)[] ? U : T;

type C = UnwrapArray<string[]>; // string
type D = UnwrapArray<number>; // number

// Извлекаем тип возвращаемого значения функции
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

function getUser() {
return { id: "1", name: "John" };
}

type UserReturn = ReturnType<typeof getUser>; // { id: string; name: string }

Итог

  • Базовые дженерики — создание универсальных функций, интерфейсов, классов.
  • Constraints (extends) — ограничение типов параметров: T extends HasId, K extends keyof T.
  • Условные типыT extends U ? X : Y для создания типов, зависящих от входного типа.
  • infer — вывод типа внутри условного типа: T extends Promise<infer U> ? U : T.
  • Практическое применение: репозитории, API-ответы, утилитные функции, type-safe ORM.

Вопрос 24. Чем функциональное обновление состояния отличается от прямого в React?

Таймкод: 00:47:59

Ответ собеседника: Правильный. Count будет равен 1, потому что оба вызова setCount используют одно и то же значение count из замыкания (батчинг). Для корректного инкремента нужно использовать функциональное обновление: setCount(prev => prev + 1).

Правильный ответ:

Ответ собеседника полностью верный. Это один из ключевых концептов React, который стоит понимать глубже. Разберём подробнее.

Проблема: замыкание и батчинг

// ═══════════════════════════════════════════════════════════
// ❌ Неправильный подход
// ═══════════════════════════════════════════════════════════

function Counter() {
const [count, setCount] = useState(0);

const handleClick = () => {
// Оба вызова видят count = 0 из замыкания
setCount(count + 1); // setCount(0 + 1) → запланировано: count = 1
setCount(count + 1); // setCount(0 + 1) → запланировано: count = 1
// Итог: count = 1, а не 2!
};

return <button onClick={handleClick}>Count: {count}</button>;
}

// ═══════════════════════════════════════════════════════════
// ✅ Правильный подход: функциональное обновление
// ═══════════════════════════════════════════════════════════

function Counter() {
const [count, setCount] = useState(0);

const handleClick = () => {
// Каждый вызов получает актуальное предыдущее значение
setCount(prev => prev + 1); // prev = 0 → запланировано: count = 1
setCount(prev => prev + 1); // prev = 1 → запланировано: count = 2
// Итог: count = 2 ✓
};

return <button onClick={handleClick}>Count: {count}</button>;
}

Как это работает внутри React

Прямое обновление (setCount(count + 1)):
┌─────────────────────────────────────────────────┐
│ Render 1: count = 0 │
│ handleClick замыкается на count = 0 │
│ setCount(0 + 1) → планируем count = 1 │
│ setCount(0 + 1) → планируем count = 1 │
│ (второй вызов перезаписывает первый) │
│ Render 2: count = 1 │
└─────────────────────────────────────────────────┘

Функциональное обновление (setCount(prev => prev + 1)):
┌─────────────────────────────────────────────────┐
│ Render 1: count = 0 │
│ handleClick замыкается на count = 0 │
│ setCount(prev => prev + 1) │
│ → функция добавлена в очередь │
│ setCount(prev => prev + 1) │
│ → функция добавлена в очередь │
│ │
│ React обрабатывает очередь: │
│ prev = 0 → return 0 + 1 = 1 │
│ prev = 1 → return 1 + 1 = 2 │
│ Render 2: count = 2 │
└─────────────────────────────────────────────────┘

Когда обязательно использовать функциональное обновление

// ═══════════════════════════════════════════════════════════
// 1. Множественные обновления в одном обработчике
// ═══════════════════════════════════════════════════════════

function TodoList() {
const [todos, setTodos] = useState([]);

const addTwoTodos = () => {
// ❌ Неправильно — второй вызов перезапишет первый
setTodos([...todos, { id: Date.now(), text: "Todo 1" }]);
setTodos([...todos, { id: Date.now() + 1, text: "Todo 2" }]);

// ✅ Правильно — каждое обновление видит актуальный массив
setTodos(prev => [...prev, { id: Date.now(), text: "Todo 1" }]);
setTodos(prev => [...prev, { id: Date.now() + 1, text: "Todo 2" }]);
};
}

// ═══════════════════════════════════════════════════════════
// 2. Обновление в асинхронном коде (setTimeout, Promise)
// ═══════════════════════════════════════════════════════════

function AsyncCounter() {
const [count, setCount] = useState(0);

// ❌ Неправильно — замыкание на старом значении
const handleBadAsync = () => {
setTimeout(() => {
setCount(count + 1); // count может быть устаревшим
}, 1000);
};

// ✅ Правильно — всегда получаем актуальное значение
const handleGoodAsync = () => {
setTimeout(() => {
setCount(prev => prev + 1); // prev всегда актуален
}, 1000);
};
}

// ═══════════════════════════════════════════════════════════
// 3. Обновление в useEffect с зависимостями
// ═══════════════════════════════════════════════════════════

function PollingComponent() {
const [data, setData] = useState(null);
const [retryCount, setRetryCount] = useState(0);

useEffect(() => {
const interval = setInterval(() => {
fetchData().then(result => {
setData(result);
// ✅ Функциональное обновление не требует
// retryCount в зависимостях useEffect
setRetryCount(prev => prev + 1);
});
}, 5000);

return () => clearInterval(interval);
}, []); // Пустые зависимости — нет предупреждений ESLint
}

// ═══════════════════════════════════════════════════════════
// 4. Обновление объектов и массивов
// ═══════════════════════════════════════════════════════════

function UserProfile() {
const [user, setUser] = useState({
name: "John",
preferences: { theme: "dark", language: "en" }
});

// ❌ Неправильно — можно потерять другие поля
const updateTheme = (theme) => {
setUser({
...user,
preferences: { theme } // Потеряем language!
});
};

// ✅ Правильно — сохраняем все вложенные поля
const updateThemeCorrect = (theme) => {
setUser(prev => ({
...prev,
preferences: {
...prev.preferences,
theme
}
}));
};

// ✅ Или используем библиотеку immer для иммутабельных обновлений
// import produce from 'immer';
// setUser(produce(user, draft => {
// draft.preferences.theme = theme;
// }));
}

Батчинг в React 18+

// ═══════════════════════════════════════════════════════════
// React 18 автоматически батчит ВСЕ обновления состояния,
// даже вне обработчиков событий
// ═══════════════════════════════════════════════════════════

function BatchedUpdates() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);

// React 17 (внутри обработчика — батч, снаружи — нет):
const handleClickOld = () => {
// Батч: один ре-рендер
setCount(c => c + 1);
setFlag(f => !f);
};

// React 18+ (везде — батч):
const handleClickNew = () => {
// Батч: один ре-рендер
setCount(c => c + 1);
setFlag(f => !f);
};

// Вне обработчика — тоже батч в React 18+
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// React 17: два ре-рендера
// React 18: один ре-рендер (автоматический батчинг)
}, 1000);

// Если нужно принудительно синхронно обновить (редко):
// import { flushSync } from 'react-dom';
// flushSync(() => setCount(c => c + 1));
// DOM обновлён синхронно
}

// ═══════════════════════════════════════════════════════════
// Принудительный сброс батча через flushSync
// ═══════════════════════════════════════════════════════════

import { flushSync } from 'react-dom';

function ForceSyncUpdate() {
const [count, setCount] = useState(0);

const handleClick = () => {
// Принудительный синхронный рендер
flushSync(() => {
setCount(c => c + 1);
});
// Здесь DOM уже обновлён, можно читать из DOM
console.log(document.getElementById('count').textContent); // "1"
};
}

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

┌─────────────────────┬──────────────────────┬──────────────────────┐
│ Ситуация │ Прямое обновление │ Функциональное │
├─────────────────────┼──────────────────────┼──────────────────────┤
│ Один setCount │ ✅ Работает │ ✅ Работает │
│ Два setCount │ ❌ Теряет обновления │ ✅ Корректно │
│ В setTimeout │ ❌ Устаревшее знач. │ ✅ Актуальное знач. │
│ В useEffect deps=[] │ ❌ Нужна зависимость │ ✅ Без зависимости │
│ Сложные объекты │ ⚠️ Риск потери полей │ ✅ Безопасно │
└─────────────────────┴──────────────────────┴──────────────────────┘

Правило: если новое состояние зависит от предыдущего — всегда используйте функциональное обновление setState(prev => ...). Это безопаснее, не требует добавления в зависимости useEffect, и корректно работает при батчинге.

Вопрос 25. Какие проблемы возникают при использовании useEffect с обработчиком события и пустым массивом зависимостей?

Таймкод: 00:48:44

Ответ собеседника: Правильный. Проблема в том, что handleBefore объявлена вне useEffect и может меняться, но не указана в массиве зависимостей. Нужно добавить handleBefore в массив зависимостей, чтобы при изменении функции эффект пересоздавался с актуальной версией обработчика. Все используемые зависимости должны быть переданы в массив, за редким исключением.

Правильный ответ:

Ответ собеседника верный. Это одна из самых распространённых ошибок при работе с useEffect. Разберём все грани этой проблемы и способы решения.

Проблема: stale closure (устаревшее замыкание)

// ═══════════════════════════════════════════════════════════
// ❌ Проблемный код
// ═══════════════════════════════════════════════════════════

function Component({ userId }) {
const [data, setData] = useState(null);

// handleBefore создаётся при каждом рендере
const handleBefore = () => {
// Замыкается на data из момента создания эффекта
console.log("Current data:", data); // Всегда null!
};

// Пустой массив зависимостей — эффект запускается один раз
useEffect(() => {
window.addEventListener("beforeunload", handleBefore);
return () => window.removeEventListener("beforeunload", handleBefore);
}, []); // handleBefore не в зависимостях!

useEffect(() => {
fetchData(userId).then(setData);
}, [userId]);

return <div>{data ? data.name : "Loading..."}</div>;
}

// Что происходит:
// 1. Первый рендер: data = null, handleBefore замыкается на null
// 2. Эффект запускается, подписывается на handleBefore (с data = null)
// 3. Данные загрузились: data = { name: "John" }
// 4. Второй рендер: handleBefore пересоздаётся, но эффект НЕ перезапускается
// 5. При закрытии вкладки: handleBefore видит data = null (устаревшее значение!)

Решение 1: Добавить зависимость и использовать useCallback

// ═══════════════════════════════════════════════════════════
// ✅ Решение с useCallback
// ═══════════════════════════════════════════════════════════

function Component({ userId }) {
const [data, setData] = useState(null);

// useCallback сохраняет ссылку стабильной, пока data не изменится
const handleBefore = useCallback(() => {
console.log("Current data:", data);
}, [data]); // Пересоздаётся только при изменении data

useEffect(() => {
window.addEventListener("beforeunload", handleBefore);
return () => window.removeEventListener("beforeunload", handleBefore);
}, [handleBefore]); // Эффект перезапускается при изменении handleBefore

useEffect(() => {
fetchData(userId).then(setData);
}, [userId]);

return <div>{data ? data.name : "Loading..."}</div>;
}

Решение 2: useRef для хранения актуального значения

// ═══════════════════════════════════════════════════════════
// ✅ Решение с useRef (без пересоздания эффекта)
// ═══════════════════════════════════════════════════════════

function Component({ userId }) {
const [data, setData] = useState(null);
const dataRef = useRef(data);

// Синхронизируем ref с актуальным значением
useEffect(() => {
dataRef.current = data;
}, [data]);

// Эффект запускается один раз, но всегда читает актуальное значение
useEffect(() => {
const handleBefore = () => {
console.log("Current data:", dataRef.current); // Всегда актуально!
};

window.addEventListener("beforeunload", handleBefore);
return () => window.removeEventListener("beforeunload", handleBefore);
}, []); // Пустой массив — эффект не пересоздаётся

useEffect(() => {
fetchData(userId).then(setData);
}, [userId]);

return <div>{data ? data.name : "Loading..."}</div>;
}

Решение 3: Объявление функции внутри useEffect

// ═══════════════════════════════════════════════════════════
// ✅ Решение с функцией внутри useEffect
// ═══════════════════════════════════════════════════════════

function Component({ userId }) {
const [data, setData] = useState(null);

useEffect(() => {
const handleBefore = () => {
console.log("Current data:", data); // Замыкается на data из этого рендера
};

window.addEventListener("beforeunload", handleBefore);
return () => window.removeEventListener("beforeunload", handleBefore);
}, [data]); // Эффект перезапускается при изменении data

useEffect(() => {
fetchData(userId).then(setData);
}, [userId]);

return <div>{data ? data.name : "Loading..."}</div>;
}

Решение 4: Кастомный хук useEvent (React 19+)

// ═══════════════════════════════════════════════════════════
// ✅ React 19: useEvent (экспериментальный API)
// ═══════════════════════════════════════════════════════════

import { useEvent } from 'react';

function Component({ userId }) {
const [data, setData] = useState(null);

// useEvent создаёт стабильную ссылку, но всегда вызывает актуальную функцию
const handleBefore = useEvent(() => {
console.log("Current data:", data); // Всегда актуально!
});

useEffect(() => {
window.addEventListener("beforeunload", handleBefore);
return () => window.removeEventListener("beforeunload", handleBefore);
}, []); // handleBefore стабильна, можно не добавлять в зависимости

useEffect(() => {
fetchData(userId).then(setData);
}, [userId]);

return <div>{data ? data.name : "Loading..."}</div>;
}

Сравнение решений

┌─────────────────────┬────────────────────┬─────────────────────┬──────────────┐
│ Решение │ Пересоздаёт эффект │ Стабильная ссылка │ Сложность │
├─────────────────────┼────────────────────┼─────────────────────┼──────────────┤
│ useCallback + deps │ Да │ Да (с зависимостями)│ Средняя │
│ useRef │ Нет │ Да │ Высокая │
│ Функция внутри │ Да │ Нет │ Низкая │
│ useEvent (React 19) │ Нет │ Да │ Низкая │
└─────────────────────┴────────────────────┴─────────────────────┴──────────────┘

Правила для массива зависимостей useEffect

// ═══════════════════════════════════════════════════════════
// Правило 1: ВСЕ значения из замыкания должны быть в зависимостях
// ═══════════════════════════════════════════════════════════

function Component({ userId }) {
const [filter, setFilter] = useState("all");

useEffect(() => {
// userId и filter — из замыкания, должны быть в зависимостях
fetchUsers(userId, filter).then(setUsers);
}, [userId, filter]); // ✅ Все зависимости указаны
}

// ═══════════════════════════════════════════════════════════
// Правило 2: Функции из замыкания — используйте useCallback
// ═══════════════════════════════════════════════════════════

function Component() {
const [items, setItems] = useState([]);

const handleAdd = useCallback((item) => {
setItems(prev => [...prev, item]);
}, []); // Стаильная ссылка

useEffect(() => {
eventBus.on("add-item", handleAdd);
return () => eventBus.off("add-item", handleAdd);
}, [handleAdd]); // ✅ handleBefore стабильна благодаря useCallback
}

// ═══════════════════════════════════════════════════════════
// Правило 3: setState и dispatch — стабильны, можно не добавлять
// ═══════════════════════════════════════════════════════════

function Component() {
const [count, setCount] = useState(0);
const [state, dispatch] = useReducer(reducer, initialState);

useEffect(() => {
// setCount и dispatch стабильны — можно не добавлять в зависимости
const interval = setInterval(() => {
setCount(c => c + 1);
dispatch({ type: "tick" });
}, 1000);
return () => clearInterval(interval);
}, []); // ✅ setCount и dispatch стабильны
}

// ═══════════════════════════════════════════════════════════
// Правило 4: Объекты и массивы — создавайте вне компонента или useMemo
// ═══════════════════════════════════════════════════════════

// ❌ Неправильно — новый объект при каждом рендере
function Component() {
useEffect(() => {
fetchData({ limit: 10, offset: 0 });
}, [{ limit: 10, offset: 0 }]); // Новый объект каждый раз — бесконечный цикл!
}

// ✅ Правильно — вынести константу
const DEFAULT_PARAMS = { limit: 10, offset: 0 };

function Component() {
useEffect(() => {
fetchData(DEFAULT_PARAMS);
}, [DEFAULT_PARAMS]); // Стабильная ссылка
}

// ✅ Или использовать useMemo
function Component({ userId }) {
const params = useMemo(() => ({ limit: 10, offset: 0, userId }), [userId]);

useEffect(() => {
fetchData(params);
}, [params]); // Меняется только при изменении userId
}

Итог

  • Пустой массив зависимостей — эффект захватывает значения из первого рендера и никогда не обновляет замыкание.
  • Все внешние значения используемые внутри useEffect должны быть в массиве зависимостей.
  • Функции — оборачивайте в useCallback или используйте useRef для хранения актуального значения.
  • setState и dispatch — стабильны по ссылке, можно не добавлять в зависимости.
  • ESLint-правило react-hooks/exhaustive-deps помогает найти пропущенные зависимости — не игнорируйте его.

Вопрос 26. Как найти пересечение двух массивов (общие элементы без дубликатов)?

Таймкод: 00:50:22

Ответ собеседника: Неполный. Кандидат сначала предложил наивный подход — двойной цикл сравнения элементов (O(n²)). После подсказки про хэш-карту (Map) начал реализовывать оптимальное решение: первый проход по первому массиву — заполнение Map значениями, второй проход по второму массиву — проверка наличия элемента в Map и добавление в result с проверкой через result.includes() для исключения дубликатов. Допустил синтаксическую ошибку (неверное имя переменной r1 вместо result) и логическую ошибку (проверял result.includes(x) вместо !result.includes(x)). После исправления обеих ошибок решение заработало корректно. Интервьюер также показал более элегантное решение через Set.

Правильный ответ:

Это классическая задача на знание структур данных. Разберём все подходы от наивного до оптимального.

Подход 1: Наивный (двойной цикл) — O(n × m)

// ═══════════════════════════════════════════════════════════
// ❌ Наивный подход — O(n × m)
// ═══════════════════════════════════════════════════════════

function intersectionNaive(arr1, arr2) {
const result = [];

for (let i = 0; i < arr1.length; i++) {
for (let j = 0; j < arr2.length; j++) {
if (arr1[i] === arr2[j]) {
// Проверяем, нет ли уже этого элемента в result
let alreadyExists = false;
for (let k = 0; k < result.length; k++) {
if (result[k] === arr1[i]) {
alreadyExists = true;
break;
}
}
if (!alreadyExists) {
result.push(arr1[i]);
}
}
}
}

return result;
}

// Пример
console.log(intersectionNaive([1, 2, 2, 3, 4], [2, 2, 3, 5]));
// [2, 3]

// Сложность: O(n × m × min(n,m)) — три вложенных цикла

Подход 2: Хэш-карта (Map) — O(n + m)

// ═══════════════════════════════════════════════════════════
// ✅ Оптимальный подход с Map — O(n + m)
// ═══════════════════════════════════════════════════════════

function intersectionWithMap(arr1, arr2) {
const map = new Map();
const result = [];

// Первый проход: заполняем Map элементами первого массива
for (const item of arr1) {
map.set(item, true);
}

// Второй проход: проверяем элементы второго массива
for (const item of arr2) {
if (map.has(item)) {
result.push(item);
map.delete(item); // Удаляем, чтобы избежать дубликатов
}
}

return result;
}

// Пример
console.log(intersectionWithMap([1, 2, 2, 3, 4], [2, 2, 3, 5]));
// [2, 3]

// Сложность: O(n + m) — два линейных прохода
// Память: O(min(n, m)) — для хранения Map

Подход 3: Set — самый элегантный

// ═══════════════════════════════════════════════════════════
// ✅ Самое элегантное решение через Set
// ═══════════════════════════════════════════════════════════

function intersectionWithSet(arr1, arr2) {
const set1 = new Set(arr1);
const set2 = new Set(arr2);

return [...set1].filter(item => set2.has(item));
}

// Или ещё короче
function intersectionShort(arr1, arr2) {
const set2 = new Set(arr2);
return [...new Set(arr1)].filter(item => set2.has(item));
}

// Пример
console.log(intersectionWithSet([1, 2, 2, 3, 4], [2, 2, 3, 5]));
// [2, 3]

// Сложность: O(n + m)
// Память: O(n + m)

Подход 4: Для отсортированных массивов — O(n log n + m log m)

// ═══════════════════════════════════════════════════════════
// ✅ Два указателя для отсортированных массивов
// ═══════════════════════════════════════════════════════════

function intersectionSorted(arr1, arr2) {
// Предварительная сортировка (если массивы не отсортированы)
arr1.sort((a, b) => a - b);
arr2.sort((a, b) => a - b);

const result = [];
let i = 0;
let j = 0;

while (i < arr1.length && j < arr2.length) {
if (arr1[i] === arr2[j]) {
// Избегаем дубликатов в результате
if (result[result.length - 1] !== arr1[i]) {
result.push(arr1[i]);
}
i++;
j++;
} else if (arr1[i] < arr2[j]) {
i++;
} else {
j++;
}
}

return result;
}

// Пример
console.log(intersectionSorted([1, 2, 2, 3, 4], [2, 2, 3, 5]));
// [2, 3]

// Сложность: O(n log n + m log m) — из-за сортировки
// Если массивы уже отсортированы: O(n + m)
// Память: O(1) дополнительной (без учёта результата)

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

┌─────────────────────┬────────────────┬──────────────┬────────────────────────┐
│ Подход │ Время │ Память │ Когда использовать │
├─────────────────────┼────────────────┼──────────────┼────────────────────────┤
│ Двойной цикл │ O(n × m × k) │ O(min(n,m)) │ Никогда на практике │
│ Map │ O(n + m) │ O(n) │ Универсальный │
│ Set │ O(n + m) │ O(n + m) │ Самый читаемый код │
│ Два указателя │ O(n log n + │ O(1) │ Отсортированные массивы│
│ │ m log m) │ │ или ограниченная память│
└─────────────────────┴────────────────┴──────────────┴────────────────────────┘

Реализация на Go (для Golang-разработчика)

package main

import "fmt"

// Intersection находит пересечение двух слайсов без дубликатов
func Intersection(arr1, arr2 []int) []int {
// Используем map как множество для первого массива
set := make(map[int]bool)
for _, v := range arr1 {
set[v] = true
}

// Собираем результат, удаляя из map для исключения дубликатов
result := make([]int, 0)
for _, v := range arr2 {
if set[v] {
result = append(result, v)
delete(set, v) // Удаляем, чтобы не добавить повторно
}
}

return result
}

func main() {
arr1 := []int{1, 2, 2, 3, 4}
arr2 := []int{2, 2, 3, 5}

fmt.Println(Intersection(arr1, arr2)) // [2 3]
}

Типичные ошибки при решении

// ═══════════════════════════════════════════════════════════
// ❌ Ошибка 1: Забыли исключить дубликаты
// ═══════════════════════════════════════════════════════════

function intersectionBug1(arr1, arr2) {
const set1 = new Set(arr1);
const result = [];

for (const item of arr2) {
if (set1.has(item)) {
result.push(item); // 2 будет добавлена дважды!
}
}

return result;
}

console.log(intersectionBug1([1, 2, 2, 3], [2, 2, 3]));
// [2, 2, 3] — дубликаты не исключены!

// ═══════════════════════════════════════════════════════════
// ❌ Ошибка 2: Неправильное условие добавления
// ═══════════════════════════════════════════════════════════

function intersectionBug2(arr1, arr2) {
const map = new Map();
const result = [];

for (const item of arr1) {
map.set(item, true);
}

for (const item of arr2) {
if (map.has(item) && result.includes(item)) {
// ❌ Логическая ошибка: добавляем только если УЖЕ есть в result
result.push(item);
}
}

return result;
}

console.log(intersectionBug2([1, 2, 3], [2, 3, 4]));
// [] — пустой результат!

// ═══════════════════════════════════════════════════════════
// ❌ Ошибка 3: Проверка через result.includes() — O(n) на каждый элемент
// ═══════════════════════════════════════════════════════════

function intersectionBug3(arr1, arr2) {
const map = new Map();
const result = [];

for (const item of arr1) {
map.set(item, true);
}

for (const item of arr2) {
if (map.has(item) && !result.includes(item)) {
// ⚠️ result.includes() — O(n), итого O(n × m)
result.push(item);
}
}

return result;
}

// Лучше использовать другой Set для отслеживания добавленных элементов
function intersectionFixed(arr1, arr2) {
const set1 = new Set(arr1);
const added = new Set();
const result = [];

for (const item of arr2) {
if (set1.has(item) && !added.has(item)) {
result.push(item);
added.add(item); // O(1) вместо O(n)
}
}

return result;
}

Итог

  • Лучшее решение по соотношению простоты и эффективности — через Set: [...new Set(arr1)].filter(x => set2.has(x)).
  • Оптимальное по памяти — через Map с удалением элемента после добавления в результат.
  • Для отсортированных массивов — два указателя, O(1) дополнительной памяти.
  • Избегайте result.includes() внутри цикла — это превращает O(n + m) в O(n × m).