TypeScript

React + TypeScript: Продвинуті патерни

React + TypeScript: Продвинуті патерни

Context API, Generic Components, Polymorphic Components, та інші професійні техніки типізації в React.

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>

Що тут відбувається?

  1. E extends ElementType — generic тип, який може бути будь-яким HTML тегом або React компонентом.
  2. ComponentProps<E> — витягує всі пропси для цього елемента (наприклад, для 'a' це буде href, target).
  3. 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 компонент з наступними вимогами:

  1. Compound Components Pattern: Accordion, AccordionItem, AccordionHeader, AccordionPanel.
  2. Context API: Стан відкритих панелей зберігається в контексті.
  3. TypeScript: Повністю типізовано.
  4. Можливість кількох відкритих панелей (через пропс 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

1. Завжди використовуйте Discriminated Unions для стану
Замість isLoading + error + data використовуйте { status: 'loading' } | { status: 'error', error } | { status: 'success', data }.
2. Уникайте any у Context
Це найпоширеніша помилка. Завжди типізуйте Context явно.
3. Використовуйте as const для tuple return
У custom hooks: return [value, setValue] as const.
4. Generic Constraints для безпеки
Використовуйте T extends { id: string } замість просто T.
5. Polymorphic Components тільки якщо потрібно
Це складний патерн. Використовуйте тільки для 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.

Copyright © 2026