Главная / Статьи / Предсказуемая база React с использованием TypeScript, Zustand и типизированных сервисов

Предсказуемая база React с использованием TypeScript, Zustand и типизированных сервисов

Небольшой скелет на React, TypeScript и Zustand, который разделяет хранилища данных, типизированные API-сервисы и компоненты, а также содержит правила по структуре, обработке асинхронного состояния и тестированию.

1207 слов

Первый экран нового проекта кажется незначительным, но решения, лежащие в его основе — где хранится состояние, как загружаются данные и насколько строго определяется типизация — влияют на всё, что будет создано дальше. Небольшое приложение «Hello App» — идеальное место для урегулирования этих вопросов. В этом руководстве создаётся такое приложение с использованием React, TypeScript и Zustand, объясняется функция каждого элемента структуры кода, а основные принципы преобразуются в правила, которые можно применять по мере роста кодовой базы.

Почему React, TypeScript и Zustand вместе

Каждый инструмент решает свою задачу:

  • React обеспечивает декларативный интерфейс, зрелую экосистему и мощные инструменты; композиция помогает сохранять читаемость компонентов.
  • TypeScript позволяет выявлять ошибки на этапе компиляции, делает рефакторинг более безопасным и превращает параметры компонентов и структуру хранилища в самодокументирующиеся контракты.
  • Zustand — это небольшая библиотека для работы со состоянием без лишних формальностей: хранилище представляет собой простой крючок, модель данных состоит из обычных объектов и функций, а компоненты воспринимают только те данные, которые они считывают.
  • Начните с этих трех элементов плюс роутера, и добавляйте библиотеки только тогда, когда возникает конкретная необходимость.

    Основа: хранилище, сервис и компонент

    Приведённый ниже пример показан как один список, но на самом деле включает три файла, каждый из которых выполняет одну задачу. store/counterStore.ts определяет типизированное хранилище Zustand, содержащее переменную count, флаг loading, синхронную функцию increment и асинхронную функцию loadInitial. services/counterApi.ts оборачивает HTTP-запрос в типизированную функцию, которая выбрасывает исключение при получении ответа, отличного от статуса OK. App.tsx считывает отдельные значения с помощью селекторов и запускает первоначальную загрузку в рамках эффекта.

    Обратите внимание на три момента. Магазин никогда не вызывает fetch напрямую; он делегирует эту задачу сервису, что позволяет независимо заменять или имитировать сетевой слой. Блок finally гарантирует сброс состояния loading даже в случае сбоя запроса. Кроме того, компонент подписывается на каждое поле с использованием своего селектора, поэтому перерисовывается только тогда, когда меняется значение, которое он действительно использует.

    // store/counterStore.ts
    import { create } from "zustand"
    
    type CounterState = {
      count: number
      loading: boolean
      increment: () => void
      loadInitial: () => Promise<void>
    }
    
    export const useCounter = create<CounterState>((set, get) => ({
      count: 0,
      loading: false,
      increment: () => set({ count: get().count + 1 }),
      loadInitial: async () => {
        set({ loading: true })
        try {
          const value = await fetchInitialCount()
          set({ count: value })
        } finally {
          set({ loading: false })
        }
      },
    }))
    
    // services/counterApi.ts
    export type CounterResponse = { value: number }
    
    export async function fetchInitialCount(): Promise<number> {
      const res = await fetch("/api/counter")
      if (!res.ok) throw new Error("Failed to load")
      const data = (await res.json()) as CounterResponse
      return data.value
    }
    
    // App.tsx
    import React, { useEffect } from "react"
    import { useCounter } from "./store/counterStore"
    
    export default function App() {
      const count = useCounter(s => s.count)
      const loading = useCounter(s => s.loading)
      const increment = useCounter(s => s.increment)
      const loadInitial = useCounter(s => s.loadInitial)
    
      useEffect(() => {
        void loadInitial()
      }, [loadInitial])
    
      return (
        <main>
          <h1>Hello App</h1>
          <p>{loading ? "Loading..." : `Count: ${count}`}</p>
          <button onClick={increment} disabled={loading}>
            Increment
          </button>
        </main>
      )
    }
    

    Перед тем как использовать этот код в реальном проекте, стоит уточнить несколько деталей. В виде отдельных файлов хранилищу необходимо явно импортировать функцию fetchInitialCount из модуля сервиса. Функция increment считывает текущее значение с помощью get(); функциональная форма set((s) => ({ count: s.count + 1 })) выражает ту же цель и является более распространенным подходом. Наконец, при сбое запроса загрузка просто прерывается и возникает новая ошибка, в результате чего отклонение не обрабатывается, поскольку операция уничтожает обещание с помощью void; добавление поля error в хранилище и обработка ошибки там позволяют интерфейсу отображать корректную информацию.

    Структура, которая остается понятной при росте проекта

    Группируйте код по функциям, а не по типам файлов. Папка для каждой функции, содержащая её хранилище, типы и интерфейс, удобнее для навигации, чем верхние каталоги components, utils и services, к которым приходится обращаться при любых изменениях. Общие утилиты и система дизайна имеют собственные модули. Чтобы узнать больше о сравнении лейаутов, прочитайте как выбрать структуру папок в React.

    TypeScript как слой контракта

    Рассматривайте типы как часть публичного API каждого модуля. Экспортируйте те типы, которые нужны потребителям, и оставляйте внутренние типы приватными. С самого начала включайте режим strict (включая опцию noImplicitAny) и строгие настройки JSX; внедрение строгости позже гораздо сложнее. Используйте вспомогательные типы, такие как Pick, Omit и ReturnType, чтобы производные типы оставались синхронизированными, а также задавайте типы для своих селекторов.

    Использование Zustand без проблем

    Разделите состояние на небольшие хранилища по доменам, например authStore и todosStore, каждое из которых создается с помощью функции create. Используйте узкие селекторы: выбор одного поля предотвращает повторную отрисовку при изменении не связанных полей, тогда как выбор только что созданного объекта при каждом вызове может привести к дополнительной отрисовке, если не использовать вспомогательную функцию для проверки поверхностного равенства. Zustand не предусматривает редьюсеров, поэтому обновления остаются краткими и предсказуемыми, при условии что вы создаете новые значения вместо того, чтобы мутировать состояние.

    Шаблоны компонентов и форм

    Разделяйте контейнеры от компонентов представления. Контейнеры взаимодействуют с хранилищами данных и содержат логику; компоненты представления получают заданные им параметры, остаются простыми в структуре и легко тестируемыми. Для простых форм достаточно управляемых полей ввода; для сложных форм подходит легковесная библиотека вроде react-hook-form в сочетании с схемой, учитывающей TypeScript, что позволяет синхронизировать проверку данных и типы. Используйте React.memo, useMemo и useCallback только там, где профилирование показывает их полезность.

    Асинхронные операции и побочные эффекты

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

    Тестирование и качество кода

    Единичные тесты для основной бизнес-логики и селекторов хранилищ имеют низкую стоимость и быстро приносят пользу. Сочетайте ESLint с правилами, учитывающими TypeScript, и Prettier, а также запускайте их автоматически в хуке pre-commit. Создавайте данные для тестов и Storybook с помощью типизированных фабрик фикстур, чтобы происходила ошибка на этапе компиляции при изменении модели.

    Развитие без переписывания кода

    Дополняйте функционал вертикально: каждая новая возможность требует создания новой папки для хранения и обработки данных, в то время как локализация и тематика реализуются в отдельных модулях. При изменении API или модели данных пусть компилятор указывает на все нарушенные условия взаимодействия. Внедряйте кэширование, нормализацию и оптимистичные обновления только тогда, когда это действительно необходимо; если состояние сервера начинает доминировать над данными, специализированная библиотека для загрузки данных часто будет лучшим решением, чем Zustand.

    Основные выводы

    • Храните данные, сервисы и компоненты в отдельных модулях с узкой специализацией, даже в самом маленьком приложении.
    • Читайте состояние с помощью узких селекторов, а загрузку модели и информацию об ошибках — явно.
    • Включайте строгие правила TypeScript с самого начала, чтобы типы помогали документировать и обеспечивать соблюдение границ модулей.
    • Организуйте код по функциям, добавляйте зависимости только при необходимости и оптимизируйте после измерения эффективности.
  • Защищайте асинхронные потоки с использованием проверок отмены и свежести, чтобы избежать конфликтов при обработке данных до того, как они достигнут пользователей.