React + TypeScript: Екосистема та бібліотеки
React + TypeScript: Екосистема та бібліотеки
Вступ
Сучасний React проєкт рідко обходиться без зовнішніх бібліотек. У цьому розділі ми навчимося правильно типізувати:
- Redux Toolkit — глобальний стейт-менеджмент
- React Query (TanStack Query) — управління серверним станом
- React Hook Form + Zod — форми та валідація
- React Router — маршрутизація
1. Redux Toolkit з TypeScript
1.1. Початкова конфігурація
// src/store/index.ts
import { configureStore } from '@reduxjs/toolkit'
import userReducer from './slices/userSlice'
import postsReducer from './slices/postsSlice'
export const store = configureStore({
reducer: {
user: userReducer,
posts: postsReducer,
},
})
// Виводимо типи з самого store
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
Чому ReturnType?
Це Utility Type, який витягує тип повернення функції. Замість того, щоб вручну описувати структуру стейту, ми даємо TypeScript вивести її.
1.2. Typed Hooks
Створіть typed обгортки для useSelector та useDispatch:
// src/store/hooks.ts
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux'
import type { RootState, AppDispatch } from './index'
// Використовуйте їх замість звичайних хуків
export const useAppDispatch = () => useDispatch<AppDispatch>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
Навіщо обгортки? Щоб не писати типи вручну кожен раз:
// ❌ Без обгорток
const user = useSelector((state: RootState) => state.user)
// ✅ З обгортками
const user = useAppSelector((state) => state.user)
1.3. Создання Slice
// src/store/slices/userSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
interface User {
id: string
name: string
email: string
}
interface UserState {
currentUser: User | null
isAuthenticated: boolean
}
const initialState: UserState = {
currentUser: null,
isAuthenticated: false,
}
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
// PayloadAction<T> типізує action.payload
login(state, action: PayloadAction<User>) {
state.currentUser = action.payload
state.isAuthenticated = true
},
logout(state) {
state.currentUser = null
state.isAuthenticated = false
},
updateEmail(state, action: PayloadAction<string>) {
if (state.currentUser) {
state.currentUser.email = action.payload
}
},
},
})
export const { login, logout, updateEmail } = userSlice.actions
export default userSlice.reducer
1.4. Використання в компонентах
import { useAppSelector, useAppDispatch } from '@/store/hooks';
import { login, logout } from '@/store/slices/userSlice';
function UserProfile() {
const dispatch = useAppDispatch();
const user = useAppSelector((state) => state.user.currentUser);
const isAuthenticated = useAppSelector((state) => state.user.isAuthenticated);
const handleLogin = () => {
dispatch(login({ id: '1', name: 'Alice', email: 'alice@example.com' }));
};
if (!isAuthenticated) {
return <button onClick={handleLogin}>Log In</button>;
}
return <div>Welcome, {user?.name}</div>;
}
1.5. Async Thunks
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
interface Post {
id: string
title: string
body: string
}
interface PostsState {
posts: Post[]
loading: boolean
error: string | null
}
// Типізація thunk
export const fetchPosts = createAsyncThunk<
Post[], // Тип повернення
void, // Тип аргументу (якщо нічого не приймає)
{ rejectValue: string } // Тип помилки
>('posts/fetchPosts', async (_, { rejectWithValue }) => {
try {
const response = await fetch('/api/posts')
const data = await response.json()
return data
} catch (error) {
return rejectWithValue('Failed to fetch posts')
}
})
const postsSlice = createSlice({
name: 'posts',
initialState: { posts: [], loading: false, error: null } as PostsState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchPosts.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(fetchPosts.fulfilled, (state, action) => {
state.loading = false
state.posts = action.payload
})
.addCase(fetchPosts.rejected, (state, action) => {
state.loading = false
state.error = action.payload ?? 'Unknown error'
})
},
})
export default postsSlice.reducer
Використання:
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { fetchPosts } from '@/store/slices/postsSlice';
function PostsList() {
const dispatch = useAppDispatch();
const { posts, loading, error } = useAppSelector((state) => state.posts);
useEffect(() => {
dispatch(fetchPosts());
}, [dispatch]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
2. React Query (TanStack Query)
2.1. Setup та Typed Client
// src/lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 хвилин
retry: 2,
},
},
})
// src/main.tsx
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from './lib/queryClient';
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
);
}
2.2. useQuery з типізацією
import { useQuery } from '@tanstack/react-query';
interface User {
id: string;
name: string;
email: string;
}
async function fetchUser(userId: string): Promise<User> {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
return response.json();
}
function UserProfile({ userId }: { userId: string }) {
const { data, error, isLoading, isError } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Error: {error.message}</div>;
return <div>{data.name}</div>;
}
TypeScript автоматично виводить:
data: User | undefinederror: Error | null
2.3. useMutation
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface CreateUserInput {
name: string;
email: string;
}
interface User {
id: string;
name: string;
email: string;
}
async function createUser(input: CreateUserInput): Promise<User> {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
return response.json();
}
function CreateUserForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createUser,
onSuccess: (newUser) => {
// Invalidate queries after success
queryClient.invalidateQueries({ queryKey: ['users'] });
console.log('Created user:', newUser);
},
onError: (error) => {
console.error('Error:', error);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate({ name: 'Alice', email: 'alice@example.com' });
};
return (
<form onSubmit={handleSubmit}>
<button disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create User'}
</button>
{mutation.isError && <p>Error: {mutation.error.message}</p>}
</form>
);
}
2.4. Optimistic Updates
interface Todo {
id: string;
text: string;
completed: boolean;
}
async function updateTodo(todo: Todo): Promise<Todo> {
const response = await fetch(`/api/todos/${todo.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(todo),
});
return response.json();
}
function TodoList() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (updatedTodo) => {
// Скасувати всі "in-flight" queries
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Зберегти попередній стан
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
// Оптимістично оновити UI
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map((todo) => (todo.id === updatedTodo.id ? updatedTodo : todo))
);
// Повернути контекст для rollback
return { previousTodos };
},
onError: (err, updatedTodo, context) => {
// Відкат при помилці
queryClient.setQueryData(['todos'], context?.previousTodos);
},
onSettled: () => {
// Завжди refetch після завершення
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return <div>...</div>;
}
3. React Hook Form + Zod
3.1. Зод схема як Source of Truth
import { z } from 'zod'
const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(6, 'Password must be at least 6 characters'),
})
// Вивести TypeScript тип із Zod схеми
type LoginFormData = z.infer<typeof loginSchema>
Що дає z.infer?
Ви описуєте валідацію один раз у Zod, і автоматично отримуєте TypeScript тип. Жодних дублювань!
3.2. Інтеграція з React Hook Form
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
});
type LoginFormData = z.infer<typeof loginSchema>;
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: '',
password: '',
},
});
const onSubmit = async (data: LoginFormData) => {
console.log('Form data:', data);
// data.email та data.password типізовані!
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<button disabled={isSubmitting}>Submit</button>
</form>
);
}
3.3. Складніша схема
const profileSchema = z.object({
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
age: z.number().min(18, 'Must be at least 18').max(120),
email: z.string().email(),
newsletter: z.boolean(),
role: z.enum(['user', 'admin', 'guest']),
address: z.object({
street: z.string(),
city: z.string(),
zipCode: z.string().regex(/^\d{5}$/, 'Invalid ZIP code'),
}),
tags: z.array(z.string()).min(1, 'At least one tag required'),
})
type ProfileFormData = z.infer<typeof profileSchema>
Використання nested полів:
function ProfileForm() {
const { register, handleSubmit, formState: { errors } } = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema),
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register('firstName')} />
<input {...register('lastName')} />
<input {...register('age', { valueAsNumber: true })} />
{/* Nested fields */}
<input {...register('address.street')} />
<input {...register('address.city')} />
<input {...register('address.zipCode')} />
{errors.address?.street && <span>{errors.address.street.message}</span>}
</form>
);
}
3.4. Dynamic Fields (Arrays)
import { useFieldArray } from 'react-hook-form';
import { z } from 'zod';
const taskSchema = z.object({
tasks: z.array(
z.object({
title: z.string().min(1),
completed: z.boolean(),
})
),
});
type TaskFormData = z.infer<typeof taskSchema>;
function TaskList() {
const { control, register, handleSubmit } = useForm<TaskFormData>({
resolver: zodResolver(taskSchema),
defaultValues: {
tasks: [{ title: '', completed: false }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'tasks',
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`tasks.${index}.title`)} />
<input type="checkbox" {...register(`tasks.${index}.completed`)} />
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ title: '', completed: false })}>
Add Task
</button>
<button type="submit">Submit</button>
</form>
);
}
4. React Router v6
4.1. Typed Routes
// src/router.tsx
import { createBrowserRouter, RouteObject } from 'react-router-dom';
import HomePage from './pages/HomePage';
import UserPage from './pages/UserPage';
import NotFoundPage from './pages/NotFoundPage';
const routes: RouteObject[] = [
{
path: '/',
element: <HomePage />,
},
{
path: '/users/:userId',
element: <UserPage />,
},
{
path: '*',
element: <NotFoundPage />,
},
];
export const router = createBrowserRouter(routes);
// src/main.tsx
import { RouterProvider } from 'react-router-dom';
import { router } from './router';
function App() {
return <RouterProvider router={router} />;
}
4.2. useParams типізація
import { useParams } from 'react-router-dom';
function UserPage() {
// ❌ Без типізації: userId може бути undefined
const { userId } = useParams();
// ✅ З типізацією
const { userId } = useParams<{ userId: string }>();
// Але userId все одно може бути undefined, потрібна перевірка
if (!userId) {
return <div>User ID is missing</div>;
}
return <div>User ID: {userId}</div>;
}
4.3. useNavigate
import { useNavigate } from 'react-router-dom';
function LoginForm() {
const navigate = useNavigate();
const handleLogin = async (data: LoginFormData) => {
await loginUser(data);
// Перехід на іншу сторінку
navigate('/dashboard');
// З state
navigate('/dashboard', { state: { fromLogin: true } });
// Назад
navigate(-1);
};
return <form onSubmit={handleSubmit(handleLogin)}>...</form>;
}
4.4. useLocation з типізованим state
import { useLocation } from 'react-router-dom';
interface LocationState {
fromLogin?: boolean;
redirectUrl?: string;
}
function Dashboard() {
const location = useLocation();
// ❌ Без типізації: state має тип unknown
const state = location.state;
// ✅ Type Guard
const isFromLogin = (state: unknown): state is LocationState => {
return typeof state === 'object' && state !== null && 'fromLogin' in state;
};
if (isFromLogin(location.state)) {
console.log('Came from login:', location.state.fromLogin);
}
return <div>Dashboard</div>;
}
4.5. Typed Links
import { Link } from 'react-router-dom';
function Navigation() {
return (
<nav>
<Link to="/">Home</Link>
<Link to="/users/123">User 123</Link>
<Link to="/dashboard" state={{ fromNav: true }}>Dashboard</Link>
</nav>
);
}
5. Комплексний приклад: CRUD Todo App
Давайте об'єднаємо всі знання.
5.1. Zod Schema
import { z } from 'zod'
export const todoSchema = z.object({
id: z.string(),
title: z.string().min(1, 'Title is required'),
completed: z.boolean(),
createdAt: z.string(),
})
export const createTodoSchema = todoSchema.omit({ id: true, createdAt: true })
export type Todo = z.infer<typeof todoSchema>
export type CreateTodoInput = z.infer<typeof createTodoSchema>
5.2. API Functions
export async function fetchTodos(): Promise<Todo[]> {
const response = await fetch('/api/todos')
return response.json()
}
export async function createTodo(input: CreateTodoInput): Promise<Todo> {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
return response.json()
}
export async function updateTodo(todo: Todo): Promise<Todo> {
const response = await fetch(`/api/todos/${todo.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(todo),
})
return response.json()
}
export async function deleteTodo(id: string): Promise<void> {
await fetch(`/api/todos/${id}`, { method: 'DELETE' })
}
5.3. Todo List з React Query
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchTodos, createTodo, updateTodo, deleteTodo } from './api';
function TodoList() {
const queryClient = useQueryClient();
const { data: todos, isLoading } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
const createMutation = useMutation({
mutationFn: createTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
const updateMutation = useMutation({
mutationFn: updateTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
const deleteMutation = useMutation({
mutationFn: deleteTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
if (isLoading) return <div>Loading...</div>;
return (
<div>
<CreateTodoForm onSubmit={(input) => createMutation.mutate(input)} />
<ul>
{todos?.map((todo) => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => updateMutation.mutate({ ...todo, completed: !todo.completed })}
/>
<span>{todo.title}</span>
<button onClick={() => deleteMutation.mutate(todo.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}
5.4. Create Todo Form
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { createTodoSchema, CreateTodoInput } from './schema';
interface CreateTodoFormProps {
onSubmit: (data: CreateTodoInput) => void;
}
function CreateTodoForm({ onSubmit }: CreateTodoFormProps) {
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<CreateTodoInput>({
resolver: zodResolver(createTodoSchema),
defaultValues: {
title: '',
completed: false,
},
});
const handleFormSubmit = (data: CreateTodoInput) => {
onSubmit(data);
reset();
};
return (
<form onSubmit={handleSubmit(handleFormSubmit)}>
<input {...register('title')} placeholder="New todo" />
{errors.title && <span>{errors.title.message}</span>}
<label>
<input type="checkbox" {...register('completed')} />
Completed
</label>
<button type="submit">Add Todo</button>
</form>
);
}
6. Best Practices для екосистеми
Завжди створюйте
useAppDispatch та useAppSelector для автоматичного виводу типів.Хоча TypeScript виводить типи, явна анотація покращує читабельність:
useQuery<User[]>.Використовуйте
z.infer для витягування TypeScript типів із схем. Не дублюйте типи.Location state завжди
unknown. Використовуйте Type Guards для безпеки.createAsyncThunk<ReturnType, ArgType, { rejectValue: ErrorType }>.Це найбезпечніший спосіб роботи з масивами у формах.
7. FAQ
Q: Коли використовувати Redux, а коли React Query?
A:
- Redux: Для клієнтського стану (UI state, форми, модальні вікна, налаштування).
- React Query: Для серверного стану (дані з API, кешування, синхронізація).
Вони чудово працюють разом! React Query забирає на себе складність роботи з API, а Redux залишається для глобального UI стану.
Q: Чи потрібно типізувати кожен useState?
A: Ні! TypeScript чудово виводить типи:
// ✅ Типізація автоматична
const [count, setCount] = useState(0) // number
const [name, setName] = useState('') // string
// ❌ Надлишкова типізація
const [count, setCount] = useState<number>(0)
Типізуйте лише коли:
- Початкове значення
nullабоundefined:useState<User | null>(null) - Union типи:
useState<'idle' | 'loading' | 'success'>('idle')
Q: Як типізувати useContext без null перевірок?
A: Використовуйте pattern з custom hook:
const MyContext = createContext<MyType | null>(null)
function useMyContext() {
const context = useContext(MyContext)
if (!context) {
throw new Error('useMyContext must be within Provider')
}
return context
}
Тепер useMyContext() завжди повертає MyType, без null.
Q: Як типізувати dynamic routes з useParams?
A: React Router не підтримує типобезпечні роути "з коробки". Рішення:
// 1. Створіть типи для кожного route
interface UserParams {
userId: string;
}
// 2. Використовуйте з useParams
const { userId } = useParams<UserParams>();
// 3. userId може бути undefined, перевірка обов'язкова
if (!userId) return <NotFound />;
Або використовуйте бібліотеки типу type-safe-react-router.
Q: Як уникнути дублювання типів між frontend та backend?
A: Використовуйте code generation:
- tRPC: Автоматична типізація API через RPC.
- GraphQL Code Generator: Генерує TypeScript типи з GraphQL схеми.
- OpenAPI Generator: Генерує типи з Swagger/OpenAPI специфікації.
Або створіть shared пакет із типами (monorepo).
Q: Чи варто використовувати React.FC?
A: У 2024+ не рекомендується. Основні причини:
- Неявність (автоматичне додавання
childrenбуло заплутуючим). - Не працює з generic компонентами.
- Більшість команд React перейшли на явну типізацію.
// ✅ Рекомендується
function MyComponent({ name }: { name: string }) {
return <div>{name}</div>;
}
// ❌ Застарілий підхід
const MyComponent: React.FC<{ name: string }> = ({ name }) => {
return <div>{name}</div>;
};
Q: Як типізувати React.lazy?
A:
import { lazy, Suspense } from 'react';
// TypeScript автоматично виведе тип
const LazyComponent = lazy(() => import('./MyComponent'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<LazyComponent />
</Suspense>
);
}
Типізація пропсів працює автоматично:
interface MyComponentProps {
name: string;
}
const LazyComponent = lazy<ComponentType<MyComponentProps>>(
() => import('./MyComponent')
);
<LazyComponent name="Alice" />; // ✅ Типізовано
Підсумок
Ви навчилися:
- ✅ Налаштовувати Redux Toolkit з повною типізацією (
RootState,AppDispatch). - ✅ Типізувати
createSlice,PayloadAction, таcreateAsyncThunk. - ✅ Використовувати React Query з автоматичним виводом типів.
- ✅ Писати мутації з оптимістичними оновленнями.
- ✅ Інтегрувати Zod + React Hook Form для форм з валідацією.
- ✅ Типізувати React Router:
useParams,useNavigate,location.state. - ✅ Будувати повноцінні CRUD застосунки з типобезпечним стеком.
Вітаємо! Ви завершили повний курс React + TypeScript. Тепер ви готові писати масштабні, типобезпечні React застосунки на професійному рівні.