TypeScript

React + TypeScript: Екосистема та бібліотеки

React + TypeScript: Екосистема та бібліотеки

Redux Toolkit, React Query, React Hook Form + Zod, React Router — інтеграція TypeScript з найпопулярнішими бібліотеками React екосистеми.

Вступ

Сучасний 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 | undefined
  • error: 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>;
}

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 для екосистеми

1. Redux: Використовуйте typed hooks
Завжди створюйте useAppDispatch та useAppSelector для автоматичного виводу типів.
2. React Query: Типізуйте запити явно
Хоча TypeScript виводить типи, явна анотація покращує читабельність: useQuery<User[]>.
3. Zod: Єдине джерело істини
Використовуйте z.infer для витягування TypeScript типів із схем. Не дублюйте типи.
4. React Router: Type Guards для state
Location state завжди unknown. Використовуйте Type Guards для безпеки.
5. Async Thunks: Типізуйте всі generic-параметри
createAsyncThunk<ReturnType, ArgType, { rejectValue: ErrorType }>.
6. Forms: useFieldArray для динамічних полів
Це найбезпечніший спосіб роботи з масивами у формах.

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 застосунки на професійному рівні.

Copyright © 2026