Предсказуемая база React с использованием TypeScript, Zustand и типизированных сервисов
Небольшой скелет на React, TypeScript и Zustand, который разделяет хранилища данных, типизированные API-сервисы и компоненты, а также содержит правила по структуре, обработке асинхронного состояния и тестированию.
Первый экран нового проекта кажется незначительным, но решения, лежащие в его основе — где хранится состояние, как загружаются данные и насколько строго определяется типизация — влияют на всё, что будет создано дальше. Небольшое приложение «Hello App» — идеальное место для урегулирования этих вопросов. В этом руководстве создаётся такое приложение с использованием React, TypeScript и Zustand, объясняется функция каждого элемента структуры кода, а основные принципы преобразуются в правила, которые можно применять по мере роста кодовой базы.
Почему React, TypeScript и Zustand вместе
Каждый инструмент решает свою задачу:
- React обеспечивает декларативный интерфейс, зрелую экосистему и мощные инструменты; композиция помогает сохранять читаемость компонентов.
- TypeScript позволяет выявлять ошибки на этапе компиляции, делает рефакторинг более безопасным и превращает параметры компонентов и структуру хранилища в самодокументирующиеся контракты.
Начните с этих трех элементов плюс роутера, и добавляйте библиотеки только тогда, когда возникает конкретная необходимость.
Основа: хранилище, сервис и компонент
Приведённый ниже пример показан как один список, но на самом деле включает три файла, каждый из которых выполняет одну задачу. 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 с самого начала, чтобы типы помогали документировать и обеспечивать соблюдение границ модулей.
- Организуйте код по функциям, добавляйте зависимости только при необходимости и оптимизируйте после измерения эффективности.