Сучасний React проєкт рідко обходиться без зовнішніх бібліотек. У цьому розділі ми навчимося правильно типізувати:
// 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 вивести її.
Створіть 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)
// 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
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>;
}
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>
);
}
// 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>
);
}
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 | nullimport { 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>
);
}
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>;
}
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 тип. Жодних дублювань!
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>
);
}
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>
);
}
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>
);
}
// 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} />;
}
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>;
}
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>;
}
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>;
}
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>
);
}
Давайте об'єднаємо всі знання.
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>
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' })
}
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>
);
}
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>
);
}
useAppDispatch та useAppSelector для автоматичного виводу типів.useQuery<User[]>.z.infer для витягування TypeScript типів із схем. Не дублюйте типи.unknown. Використовуйте Type Guards для безпеки.createAsyncThunk<ReturnType, ArgType, { rejectValue: ErrorType }>.A:
Вони чудово працюють разом! React Query забирає на себе складність роботи з API, а Redux залишається для глобального UI стану.
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)useState<'idle' | 'loading' | 'success'>('idle')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.
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.
A: Використовуйте code generation:
Або створіть shared пакет із типами (monorepo).
React.FC?A: У 2024+ не рекомендується. Основні причини:
children було заплутуючим).// ✅ Рекомендується
function MyComponent({ name }: { name: string }) {
return <div>{name}</div>;
}
// ❌ Застарілий підхід
const MyComponent: React.FC<{ name: string }> = ({ name }) => {
return <div>{name}</div>;
};
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" />; // ✅ Типізовано
Ви навчилися:
RootState, AppDispatch).createSlice, PayloadAction, та createAsyncThunk.useParams, useNavigate, location.state.Вітаємо! Ви завершили повний курс React + TypeScript. Тепер ви готові писати масштабні, типобезпечні React застосунки на професійному рівні.