Прогнозований базовий проект на 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-запит у типізовану функцію, яка кидає помилку при некоректній відповіді. 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, щоб типи документували та забезпечували дотримання меж модулів.
- Організуйте код за функціями, додавайте залежності лише за потребою та оптимізуйте після вимірювань.