React + TypeScript: Продвинуті патерни
React + TypeScript: Продвинуті патерни
1. Context API + TypeScript
Context API — це спос
іб передати дані через дерево компонентів без prop drilling. Але в TypeScript є нюанси.
1.1. Базовий Context
import { createContext, useContext, ReactNode } from 'react';
// 1. Створюємо тип даних
interface User {
id: string;
name: string;
role: 'admin' | 'user';
}
// 2. Створюємо Context з типом і null
const UserContext = createContext<User | null>(null);
// 3. Provider компонент
interface UserProviderProps {
children: ReactNode;
user: User;
}
function UserProvider({ children, user }: UserProviderProps) {
return <UserContext.Provider value={user}>{children}</UserContext.Provider>;
}
// 4. Custom Hook для використання (з перевіркою на null)
function useUser() {
const context = useContext(UserContext);
if (context === null) {
throw new Error('useUser must be used within UserProvider');
}
return context;
}
Чому це важливо?
- Без перевірки на
null, TypeScript вимагає опціональну перевірку кожен раз:user?.name. - Custom Hook гарантує, що контекст завжди визначений.
1.2. Context зі State
Часто Context містить не тільки дані, але й функції для їх зміни:
import { createContext, useContext, useState, ReactNode } from 'react';
interface AuthState {
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthState | null>(null);
function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const login = async (email: string, password: string) => {
const userData = await api.login(email, password);
setUser(userData);
};
const logout = () => {
setUser(null);
};
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
// Використання
function LoginButton() {
const { login } = useAuth();
return <button onClick={() => login('user@example.com', 'password')}>
Log In
</button>;
}
1.3. Context з Reducer (Redux-style)
Для складного стану використовуйте useReducer:
import { createContext, useContext, useReducer, ReactNode } from 'react';
// 1. State
interface TodoState {
todos: Todo[];
}
interface Todo {
id: string;
text: string;
completed: boolean;
}
// 2. Actions (Discriminated Union!)
type TodoAction =
| { type: 'ADD_TODO'; text: string }
| { type: 'TOGGLE_TODO'; id: string }
| { type: 'DELETE_TODO'; id: string };
// 3. Reducer
function todoReducer(state: TodoState, action: TodoAction): TodoState {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [...state.todos, {
id: crypto.randomUUID(),
text: action.text,
completed: false,
}],
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map((todo) =>
todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
),
};
case 'DELETE_TODO':
return {
...state,
todos: state.todos.filter((todo) => todo.id !== action.id),
};
default:
return state;
}
}
// 4. Context Type
interface TodoContextType {
state: TodoState;
dispatch: React.Dispatch<TodoAction>;
}
const TodoContext = createContext<TodoContextType | null>(null);
// 5. Provider
function TodoProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(todoReducer, { todos: [] });
return (
<TodoContext.Provider value={{ state, dispatch }}>
{children}
</TodoContext.Provider>
);
}
// 6. Hook
function useTodos() {
const context = useContext(TodoContext);
if (!context) {
throw new Error('useTodos must be used within TodoProvider');
}
return context;
}
// Використання
function AddTodoForm() {
const { dispatch } = useTodos();
const [text, setText] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
dispatch({ type: 'ADD_TODO', text });
setText('');
};
return (
<form onSubmit={handleSubmit}>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button>Add</button>
</form>
);
}
2. Generic Components
Generic компоненти дозволяють створити один компонент, який працює з різними типами даних.
2.1. Generic List
interface ListProps<T> {
items: T[];
renderItem: (item: T) => ReactNode;
keyExtractor: (item: T) => string;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
// Використання з User
interface User {
id: string;
name: string;
}
<List<User>
items={users}
renderItem={(user) => <div>{user.name}</div>}
keyExtractor={(user) => user.id}
/>
// Використання з Product
interface Product {
sku: string;
title: string;
}
<List<Product>
items={products}
renderItem={(product) => <div>{product.title}</div>}
keyExtractor={(product) => product.sku}
/>
2.2. Generic з Constraints
Іноді потрібно обмежити тип generic:
// T має обов'язково мати поле id
interface ListProps<T extends { id: string }> {
items: T[];
renderItem: (item: T) => ReactNode;
}
function List<T extends { id: string }>({ items, renderItem }: ListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>{renderItem(item)}</li>
))}
</ul>
);
}
// Тепер keyExtractor не потрібен, бо id гарантований
3. Polymorphic Components (Поліморфні Компоненти)
Це один з найскладніших патернів у React + TS. Він дозволяє компоненту рендеритись як різні HTML елементи.
3.1. Базовий Polymorphic Button
import { ComponentProps, ElementType, ReactNode } from 'react';
interface ButtonOwnProps<E extends ElementType> {
as?: E;
children: ReactNode;
variant?: 'primary' | 'secondary';
}
type ButtonProps<E extends ElementType> = ButtonOwnProps<E> &
Omit<ComponentProps<E>, keyof ButtonOwnProps<E>>;
function Button<E extends ElementType = 'button'>({
as,
children,
variant = 'primary',
...props
}: ButtonProps<E>) {
const Component = as || 'button';
return (
<Component className={`btn-${variant}`} {...props}>
{children}
</Component>
);
}
// Використання
<Button>Click me</Button> // Рендериться як <button>
<Button as="a" href="/home">Go Home</Button> // Рендериться як <a>
<Button as="div" onClick={() => {}}>Div Button</Button> // Рендериться як <div>
Що тут відбувається?
E extends ElementType— generic тип, який може бути будь-яким HTML тегом або React компонентом.ComponentProps<E>— витягує всі пропси для цього елемента (наприклад, для'a'це будеhref,target).Omit<ComponentProps<E>, keyof ButtonOwnProps<E>>— видаляє конфлікти між власними пропсами кнопки та пропсами елемента.
3.2. Advanced Polymorphic з Ref
import { ComponentPropsWithRef, ElementType, forwardRef } from 'react';
type PolymorphicRef<E extends ElementType> = ComponentPropsWithRef<E>['ref'];
type ButtonProps<E extends ElementType> = {
as?: E;
children: React.ReactNode;
} & Omit<ComponentPropsWithRef<E>, 'as' | 'children'>;
const Button = forwardRef(
<E extends ElementType = 'button'>(
{ as, children, ...props }: ButtonProps<E>,
ref: PolymorphicRef<E>
) => {
const Component = as || 'button';
return (
<Component ref={ref} {...props}>
{children}
</Component>
);
}
);
// Використання з Ref
const buttonRef = useRef<HTMLButtonElement>(null);
<Button ref={buttonRef}>Click</Button>
const linkRef = useRef<HTMLAnchorElement>(null);
<Button as="a" href="/" ref={linkRef}>Link</Button>
4. Типізація useReducer
useReducer — це більш потужна альтернатива useState для складної логіки.
4.1. Базовий Reducer
interface CountState {
count: number;
}
type CountAction =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'RESET'; payload: number };
function countReducer(state: CountState, action: CountAction): CountState {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
case 'DECREMENT':
return { count: state.count - 1 };
case 'RESET':
return { count: action.payload };
default:
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(countReducer, { count: 0 });
return (
<div>
<p>{state.count}</p>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
<button onClick={() => dispatch({ type: 'RESET', payload: 0 })}>Reset</button>
</div>
);
}
5. useImperativeHandle + forwardRef
Іноді батьківський компонент має викликати методи дочірнього компонента.
import { useRef, useImperativeHandle, forwardRef } from 'react';
// 1. Визначаємо API, яке буде доступне ззовні
interface InputHandle {
focus: () => void;
clear: () => void;
}
// 2. Компонент з forwardRef
const CustomInput = forwardRef<InputHandle>((props, ref) => {
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({
focus: () => {
inputRef.current?.focus();
},
clear: () => {
if (inputRef.current) {
inputRef.current.value = '';
}
},
}));
return <input ref={inputRef} />;
});
// 3. Використання
function ParentComponent() {
const inputRef = useRef<InputHandle>(null);
const handleFocus = () => {
inputRef.current?.focus();
};
const handleClear = () => {
inputRef.current?.clear();
};
return (
<div>
<CustomInput ref={inputRef} />
<button onClick={handleFocus}>Focus</button>
<button onClick={handleClear}>Clear</button>
</div>
);
}
6. Render Props Pattern
Хоча Hooks замінили більшість use-case для Render Props, він все ще корисний.
interface MouseTrackerProps {
children: (position: { x: number; y: number }) => ReactNode;
}
function MouseTracker({ children }: MouseTrackerProps) {
const [position, setPosition] = useState({ x: 0, y: 0 });
const handleMouseMove = (e: React.MouseEvent) => {
setPosition({ x: e.clientX, y: e.clientY });
};
return <div onMouseMove={handleMouseMove}>{children(position)}</div>;
}
// Використання
<MouseTracker>
{({ x, y }) => (
<p>
Mouse position: ({x}, {y})
</p>
)}
</MouseTracker>
7. Custom Hooks з TypeScript
7.1. Generic Hook
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
const storedValue = localStorage.getItem(key)
return storedValue ? JSON.parse(storedValue) : initialValue
})
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value))
}, [key, value])
return [value, setValue] as const
}
// Використання
const [name, setName] = useLocalStorage<string>('name', 'Anonymous')
7.2. Hook з Fetch
interface UseFetchResult<T> {
data: T | null
error: string | null
isLoading: boolean
}
function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
async function fetchData() {
try {
const response = await fetch(url)
const json = await response.json()
setData(json)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
setIsLoading(false)
}
}
fetchData()
}, [url])
return { data, error, isLoading }
}
// Використання
interface User {
id: string
name: string
}
const { data, error, isLoading } = useFetch<User>('/api/user')
8. Composition Patterns
8.1. Compound Components
import { createContext, useContext, ReactNode } from 'react';
interface TabsContextType {
activeTab: string;
setActiveTab: (tab: string) => void;
}
const TabsContext = createContext<TabsContextType | null>(null);
function useTabs() {
const context = useContext(TabsContext);
if (!context) {
throw new Error('Tab components must be used within Tabs');
}
return context;
}
interface TabsProps {
children: ReactNode;
defaultTab: string;
}
function Tabs({ children, defaultTab }: TabsProps) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
interface TabListProps {
children: ReactNode;
}
function TabList({ children }: TabListProps) {
return <div className="tab-list">{children}</div>;
}
interface TabProps {
id: string;
children: ReactNode;
}
function Tab({ id, children }: TabProps) {
const { activeTab, setActiveTab } = useTabs();
return (
<button
className={activeTab === id ? 'active' : ''}
onClick={() => setActiveTab(id)}
>
{children}
</button>
);
}
interface TabPanelProps {
id: string;
children: ReactNode;
}
function TabPanel({ id, children }: TabPanelProps) {
const { activeTab } = useTabs();
if (activeTab !== id) return null;
return <div className="tab-panel">{children}</div>;
}
// Використання
<Tabs defaultTab="home">
<TabList>
<Tab id="home">Home</Tab>
<Tab id="profile">Profile</Tab>
</TabList>
<TabPanel id="home">
<p>Home content</p>
</TabPanel>
<TabPanel id="profile">
<p>Profile content</p>
</TabPanel>
</Tabs>
9. Error Boundaries (Class Components)
Error Boundaries поки що працюють тільки через класові компоненти.
import { Component, ReactNode, ErrorInfo } from 'react';
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return this.props.fallback || <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
// Використання
<ErrorBoundary fallback={<div>Oops!</div>}>
<MyComponent />
</ErrorBoundary>
10. Практичне завдання
Створіть Accordion компонент з наступними вимогами:
- Compound Components Pattern:
Accordion,AccordionItem,AccordionHeader,AccordionPanel. - Context API: Стан відкритих панелей зберігається в контексті.
- TypeScript: Повністю типізовано.
- Можливість кількох відкритих панелей (через пропс
multiple).
Рішення
import { createContext, useContext, useState, ReactNode } from 'react';
interface AccordionContextType {
openItems: Set<string>;
toggle: (id: string) => void;
multiple: boolean;
}
const AccordionContext = createContext<AccordionContextType | null>(null);
function useAccordion() {
const context = useContext(AccordionContext);
if (!context) {
throw new Error('Accordion components must be within Accordion');
}
return context;
}
interface AccordionProps {
children: ReactNode;
multiple?: boolean;
}
function Accordion({ children, multiple = false }: AccordionProps) {
const [openItems, setOpenItems] = useState<Set<string>>(new Set());
const toggle = (id: string) => {
setOpenItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
if (!multiple) {
newSet.clear();
}
newSet.add(id);
}
return newSet;
});
};
return (
<AccordionContext.Provider value={{ openItems, toggle, multiple }}>
<div className="accordion">{children}</div>
</AccordionContext.Provider>
);
}
interface AccordionItemProps {
children: ReactNode;
id: string;
}
function AccordionItem({ children, id }: AccordionItemProps) {
return <div className="accordion-item" data-id={id}>{children}</div>;
}
interface AccordionHeaderProps {
children: ReactNode;
id: string;
}
function AccordionHeader({ children, id }: AccordionHeaderProps) {
const { toggle } = useAccordion();
return (
<button onClick={() => toggle(id)} className="accordion-header">
{children}
</button>
);
}
interface AccordionPanelProps {
children: ReactNode;
id: string;
}
function AccordionPanel({ children, id }: AccordionPanelProps) {
const { openItems } = useAccordion();
if (!openItems.has(id)) return null;
return <div className="accordion-panel">{children}</div>;
}
// Використання
<Accordion multiple>
<AccordionItem id="1">
<AccordionHeader id="1">Item 1</AccordionHeader>
<AccordionPanel id="1">Content 1</AccordionPanel>
</AccordionItem>
<AccordionItem id="2">
<AccordionHeader id="2">Item 2</AccordionHeader>
<AccordionPanel id="2">Content 2</AccordionPanel>
</AccordionItem>
</Accordion>
11. Higher-Order Components (HOC)
HOC — це функція, яка приймає компонент і повертає новий компонент з додатковою логікою.
11.1. Базовий HOC Pattern
import { ComponentType } from 'react';
function withLogger<P extends object>(Component: ComponentType<P>): ComponentType<P> {
return (props: P) => {
console.log('Rendering with props:', props);
return <Component {...props} />;
};
}
// Використання
interface UserProps {
name: string;
}
const User = ({ name }: UserProps) => <div>{name}</div>;
const UserWithLogger = withLogger(User);
<UserWithLogger name="Alice" />;
11.2. HOC з доданими пропсами
interface WithLoadingProps {
isLoading: boolean;
}
function withLoading<P extends object>(
Component: ComponentType<P>
): ComponentType<P & WithLoadingProps> {
return ({ isLoading, ...props }: P & WithLoadingProps) => {
if (isLoading) {
return <div>Loading...</div>;
}
return <Component {...(props as P)} />;
};
}
// Використання
interface DataProps {
data: string[];
}
const DataList = ({ data }: DataProps) => (
<ul>
{data.map((item) => <li key={item}>{item}</li>)}
</ul>
);
const DataListWithLoading = withLoading(DataList);
<DataListWithLoading data={['a', 'b']} isLoading={false} />;
12. Performance Optimization
12.1. React.memo
React.memo попереджує непотрібні ре-рендери компонента, якщо пропси не змінилися.
import { memo } from 'react';
interface ExpensiveComponentProps {
value: number;
onUpdate: (val: number) => void;
}
// Без memo: ре-рендериться кожен раз
const ExpensiveComponent = ({ value, onUpdate }: ExpensiveComponentProps) => {
console.log('Rendering ExpensiveComponent');
return <div onClick={() => onUpdate(value + 1)}>{value}</div>;
};
// З memo: ре-рендериться тільки коли пропси змінюються
const MemoizedExpensiveComponent = memo(ExpensiveComponent);
12.2. Custom Comparison Function
interface ListItemProps {
id: string;
name: string;
metadata: Record<string, unknown>;
}
const ListItem = memo(
({ id, name }: ListItemProps) => <div>{name}</div>,
(prevProps, nextProps) => {
// Повертає true, якщо компонент НЕ потрібно оновлювати
return prevProps.id === nextProps.id && prevProps.name === nextProps.name;
}
);
12.3. useMemo
useMemo мемоїзує обчислення:
import { useMemo } from 'react';
interface DataListProps {
items: number[];
filter: string;
}
function DataList({ items, filter }: DataListProps) {
// Обчислення виконується тільки при зміні items або filter
const filteredItems = useMemo(() => {
console.log('Filtering...');
return items.filter((item) => item.toString().includes(filter));
}, [items, filter]);
return (
<ul>
{filteredItems.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
);
}
12.4. useCallback
useCallback мемоїзує функції:
import { useCallback } from 'react';
function Parent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
// ❌ Поганий варіант: нова функція створюється при кожному рендері
const handleClick1 = () => {
setCount(count + 1);
};
// ✅ Хороший варіант: функція створюється лише при зміні count
const handleClick2 = useCallback(() => {
setCount((prev) => prev + 1);
}, []); // Можна передати пустий масив, бо використовуємо функціональний сеттер
return (
<div>
<button onClick={handleClick2}>Increment</button>
<MemoizedChild onClick={handleClick2} />
</div>
);
}
13. Advanced Type Patterns
13.1. Conditional Types in Props
interface BaseProps {
type: 'text' | 'number' | 'email';
}
type InputProps = BaseProps & {
type: 'text';
maxLength?: number;
} | BaseProps & {
type: 'number';
min?: number;
max?: number;
} | BaseProps & {
type: 'email';
pattern?: string;
};
function Input(props: InputProps) {
if (props.type === 'text') {
// props.maxLength доступний
return <input type="text" maxLength={props.maxLength} />;
}
if (props.type === 'number') {
// props.min та props.max доступні
return <input type="number" min={props.min} max={props.max} />;
}
// props.pattern доступний
return <input type="email" pattern={props.pattern} />;
}
13.2. Extract Props from Component
import { ComponentProps } from 'react'
// Витягуємо пропси з існуючого компонента
type ButtonProps = ComponentProps<typeof Button>
// Або з HTML елемента
type DivProps = ComponentProps<'div'>
type InputProps = ComponentProps<'input'>
14. Testing з TypeScript
14.1. Типізація Test Props
import { render, screen } from '@testing-library/react';
interface UserCardProps {
name: string;
age: number;
}
function UserCard({ name, age }: UserCardProps) {
return <div>
<h1>{name}</h1>
<p>{age}</p>
</div>;
}
// Test
it('renders user info', () => {
const props: UserCardProps = {
name: 'Alice',
age: 30,
};
render(<UserCard {...props} />);
expect(screen.getByText('Alice')).toBeInTheDocument();
});
15. Best Practices Summary
Замість
isLoading + error + data використовуйте { status: 'loading' } | { status: 'error', error } | { status: 'success', data }.any у ContextЦе найпоширеніша помилка. Завжди типізуйте Context явно.
as const для tuple returnУ custom hooks:
return [value, setValue] as const.Використовуйте
T extends { id: string } замість просто T.Це складний патерн. Використовуйте тільки для UI бібліотек.
**6. Custom Hooks для переви
користання логіки**
Виділяйте повторювану логіку в типізовані custom hooks.
Підсумок
Ви навчилися:
- ✅ Створювати типобезпечний Context API з custom hooks.
- ✅ Використовувати
useReducerз Discriminated Unions для складного стану. - ✅ Писати Generic Components, які працюють з будь-якими типами даних.
- ✅ Створювати Polymorphic Components (
asпропс). - ✅ Типізувати
forwardRefтаuseImperativeHandle. - ✅ Застосовувати Compound Components Pattern.
- ✅ Будувати Error Boundaries через класові компоненти.
- ✅ Писати Higher-Order Components з правильною типізацією.
- ✅ Оптимізувати продуктивність через
React.memo,useMemo,useCallback.
Наступний крок: У фінальному розділі ми інтегруємо React з популярними бібліотеками екосистеми: Redux Toolkit, React Query, React Hook Form + Zod, та React Router.