Dev Highlights

Доки по разработке

This project is maintained by teniryte

Паттерны компонентов и хуков

В этом файле собраны распространённые архитектурные паттерны для React‑компонентов и хуков: композиция, дженерики, управление состоянием, слоты, проп‑геттеры и типизация.

Композиция компонентов

Render props

Render props — это паттерн, когда компонент принимает функцию, которая описывает, как рендерить его содержимое.

type ListProps<T> = {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
};

function List<T>({ items, renderItem }: ListProps<T>) {
  return (
    <ul>
      {items.map((item, index) => (
        <li key={index}>{renderItem(item, index)}</li>
      ))}
    </ul>
  );
}

export function RenderPropsExample() {
  return (
    <>
      <List
        items={['Alice', 'Bob']}
        renderItem={name => <strong>{name}</strong>}
      />

      <List
        items={[1, 2, 3]}
        renderItem={num => <span>{num * 2}</span>}
      />
    </>
  );
}

Плюсы:

HOC (higher‑order component)

HOC — это функция, которая принимает компонент и возвращает новый компонент, добавляющий поведение.

function withLoading<P>(Component: React.ComponentType<P>) {
  return function WithLoading(props: P & { isLoading: boolean }) {
    const { isLoading, ...rest } = props;
    if (isLoading) return <p>Загрузка...</p>;
    return <Component {...(rest as P)} />;
  };
}

type UserProps = { name: string };

const User = ({ name }: UserProps) => <h2>{name}</h2>;

const UserWithLoading = withLoading<UserProps>(User);

export function HocExample() {
  return (
    <div>
      <UserWithLoading isLoading={false} name="Alice" />
    </div>
  );
}

Compound components

Compound‑компонент — это “семейство” компонентов вокруг одного корневого, которые разделяют контекст и поведение.

const CardContext = createContext<{ highlighted: boolean } | null>(null);

type CardProps = {
  highlighted?: boolean;
  children: React.ReactNode;
};

function Card({ highlighted = false, children }: CardProps) {
  return (
    <CardContext.Provider value=>
      <div
        style=
      >
        {children}
      </div>
    </CardContext.Provider>
  );
}

function CardHeader({ children }: { children: React.ReactNode }) {
  return <div style=>{children}</div>;
}

function CardBody({ children }: { children: React.ReactNode }) {
  return <div>{children}</div>;
}

Card.Header = CardHeader;
Card.Body = CardBody;

export function CompoundExample() {
  return (
    <Card highlighted>
      <Card.Header>Заголовок</Card.Header>
      <Card.Body>Содержимое</Card.Body>
    </Card>
  );
}

Дженерики в компонентах и хуках

Generic‑список

type GenericListProps<T> = {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
};

function GenericList<T>({ items, renderItem }: GenericListProps<T>) {
  return (
    <ul>
      {items.map((item, index) => (
        <li key={index}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

export function GenericListExample() {
  return (
    <>
      <GenericList items={[1, 2, 3]} renderItem={n => <b>{n}</b>} />
      <GenericList
        items={[
          { id: 1, name: 'Alice' },
          { id: 2, name: 'Bob' },
        ]}
        renderItem={user => <span>{user.name}</span>}
      />
    </>
  );
}

Generic‑хук useForm

type FormValues = {
  username: string;
  age: number;
};

function useForm<T extends object>(initialValues: T) {
  const [values, setValues] = useState<T>(initialValues);

  function setField<K extends keyof T>(key: K, value: T[K]) {
    setValues(prev => ({ ...prev, [key]: value }));
  }

  return { values, setField };
}

export function GenericFormExample() {
  const { values, setField } = useForm<FormValues>({
    username: '',
    age: 18,
  });

  return (
    <div>
      <input
        value={values.username}
        onChange={e => setField('username', e.target.value)}
      />
      <input
        type="number"
        value={values.age}
        onChange={e => setField('age', Number(e.target.value))}
      />
      <pre>{JSON.stringify(values, null, 2)}</pre>
    </div>
  );
}

Ограничение типов (extends)

function getLength<T extends { length: number }>(item: T): number {
  return item.length;
}

getLength('hello');  // ✅ есть length
getLength([1, 2, 3]); // ✅ есть length
// getLength(123);    // ❌ число не имеет length

Паттерны управления состоянием

Проблема замыкания в эффектах (effect closure)

Каждый рендер создаёт новое замыкание. Если эффект не зависит от состояния, внутри него могут использоваться старые значения.

export function ClosureProblem() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      console.log('count внутри эффекта:', count);
    }, 1000);

    return () => clearInterval(id);
  }, []); // зависимостей нет — внутри всегда будет count из первого рендера

  return (
    <button onClick={() => setCount(c => c + 1)}>
      Count: {count}
    </button>
  );
}

При кликах значение на экране растёт, но в консоли всегда один и тот же count. Решение — либо добавить зависимость, либо использовать “latest ref”.

Latest ref (useLatest)

Паттерн “latest ref” позволяет хранить последнее значение в ref и использовать его внутри долгоживущих эффектов и подписок.

export function useLatest<T>(value: T) {
  const ref = useRef(value);
  useEffect(() => {
    ref.current = value;
  }, [value]);
  return ref;
}

export function TimerWithLatest() {
  const [count, setCount] = useState(0);
  const latestCount = useLatest(count);

  useEffect(() => {
    const id = setInterval(() => {
      console.log('всегда актуальный count:', latestCount.current);
    }, 1000);

    return () => clearInterval(id);
  }, [latestCount]);

  return (
    <button onClick={() => setCount(c => c + 1)}>
      Count: {count}
    </button>
  );
}

Хук useDebounce

Дебаунс позволяет “отложить” вызов функции, пока пользователь перестанет часто вызывать событие (например, печатать в инпуте).

type AnyFn = (...args: any[]) => void;

function debounce<F extends AnyFn>(fn: F, delay = 300) {
  let timer: ReturnType<typeof setTimeout> | null = null;
  return (...args: Parameters<F>) => {
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

function useDebounce<F extends AnyFn>(callback: F, delay = 300) {
  const latest = useLatest(callback);

  return useMemo(
    () =>
      debounce((...args: Parameters<F>) => {
        latest.current(...args);
      }, delay),
    [delay, latest],
  );
}

export function DebounceExample() {
  const [value, setValue] = useState('');

  const debouncedLog = useDebounce((v: string) => {
    console.log('Отправляем запрос с:', v);
  }, 500);

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    setValue(e.target.value);
    debouncedLog(e.target.value);
  }

  return (
    <input
      value={value}
      onChange={handleChange}
      placeholder="Поиск с дебаунсом"
    />
  );
}

Инициализация состояния через ref

Иногда нужно запомнить начальное значение и уметь “сбросить” состояние к нему, даже если пропсы поменялись. Для этого используют useRef.

function useCounter({ initial = 0 } = {}) {
  const initialRef = useRef(initial);
  const [count, setCount] = useState(initialRef.current);

  const increment = () => setCount(c => c + 1);
  const reset = () => setCount(initialRef.current);

  return { count, increment, reset };
}

export function CounterWithReset() {
  const { count, increment, reset } = useCounter({ initial: 10 });

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

Паттерн “state reducer”

State reducer позволяет пользователю компонента переопределять логику изменения состояния, не ломая базовый функционал.

type ToggleState = { on: boolean };
type ToggleAction = { type: 'toggle' } | { type: 'reset' };

type Reducer = (state: ToggleState, action: ToggleAction) => ToggleState;

const defaultReducer: Reducer = (state, action) => {
  switch (action.type) {
    case 'toggle':
      return { on: !state.on };
    case 'reset':
      return { on: false };
    default:
      return state;
  }
};

type ToggleProps = {
  reducer?: Reducer;
  onReset?: () => void;
};

export function Toggle({ reducer = defaultReducer, onReset }: ToggleProps) {
  const [state, dispatch] = useReducer(reducer, { on: false });

  return (
    <div>
      <button onClick={() => dispatch({ type: 'toggle' })}>
        {state.on ? 'ON' : 'OFF'}
      </button>
      <button
        onClick={() => {
          dispatch({ type: 'reset' });
          onReset?.();
        }}
      >
        Reset
      </button>
    </div>
  );
}

Пользователь компонента может передать свой reducer, чтобы изменить поведение (например, запретить больше N переключений).

Паттерн “control props”

Компонент может поддерживать как управляемый, так и неуправляемый режим:

type SwitchProps = {
  on?: boolean;                // управляемое значение
  defaultOn?: boolean;         // начальное значение в неуправляемом режиме
  onChange?: (on: boolean) => void;
};

function Switch({ on, defaultOn = false, onChange }: SwitchProps) {
  const [internalOn, setInternalOn] = useState(defaultOn);
  const isControlled = on !== undefined;

  const currentOn = isControlled ? on : internalOn;

  function toggle() {
    const newValue = !currentOn;
    if (!isControlled) {
      setInternalOn(newValue);
    }
    onChange?.(newValue);
  }

  return (
    <button onClick={toggle}>
      {currentOn ? 'ON' : 'OFF'}
    </button>
  );
}

export function ControlPropsExample() {
  const [on, setOn] = useState(false);

  return (
    <div>
      {/* Неуправляемый */}
      <Switch defaultOn={true} />

      {/* Управляемый */}
      <Switch on={on} onChange={setOn} />
    </div>
  );
}

Слоты и проп‑коллекции

Слоты через контекст

Слот — это “место” в компоненте, куда можно передать пользовательский элемент, но при этом получить от родителя нужные пропсы (id, обработчики и т.п.).

type Slots = Record<string, Record<string, unknown>>;

const SlotsContext = createContext<Slots>({});

function useSlotProps<P extends { slot?: string }>(
  props: P,
  defaultSlot: string,
): P {
  const slot = props.slot ?? defaultSlot;
  const slots = useContext(SlotsContext);
  const slotProps = slots[slot] ?? {};

  return {
    ...slotProps,
    ...props,
    slot,
  };
}

const Label = (props: React.ComponentProps<'label'> & { slot?: string }) => {
  const finalProps = useSlotProps(props, 'label');
  return <label {...finalProps} />;
};

const Input = (props: React.ComponentProps<'input'> & { slot?: string }) => {
  const finalProps = useSlotProps(props, 'input');
  return <input {...finalProps} />;
};

const Description = (props: React.ComponentProps<'div'> & { slot?: string }) => {
  const finalProps = useSlotProps(props, 'description');
  return <div {...finalProps} />;
};

function TextField({ children }: { children: React.ReactNode }) {
  const [error, setError] = useState(false);
  const id = useId();

  const slots: Slots = {
    label: { htmlFor: id },
    input: {
      id,
      onBlur(ev: React.FocusEvent<HTMLInputElement>) {
        setError(!ev.target.value.trim());
      },
      style: { borderColor: error ? 'red' : 'gray' },
    },
    description: {},
    error: {
      style: {
        color: 'red',
        display: error ? 'block' : 'none',
      },
    },
  };

  return (
    <SlotsContext.Provider value={slots}>
      {children}
    </SlotsContext.Provider>
  );
}

export function SlotsExample() {
  return (
    <TextField>
      <Label>Name</Label>
      <Input />
      <Description>Введите ваше имя</Description>
      <Description slot="error">Имя обязательно</Description>
    </TextField>
  );
}

Проп‑геттеры (getXProps)

Идея проп‑геттеров:

function callAll<T extends (...args: any[]) => void>(
  ...fns: Array<T | undefined>
) {
  return (...args: Parameters<T>) => {
    fns.forEach(fn => fn?.(...args));
  };
}

function useToggle(initial = false) {
  const [on, setOn] = useState(initial);

  function getButtonProps(
    props: React.ButtonHTMLAttributes<HTMLButtonElement> = {},
  ): React.ButtonHTMLAttributes<HTMLButtonElement> {
    return {
      'aria-pressed': on,
      onClick: callAll(props.onClick, () => setOn(o => !o)),
      ...props,
    };
  }

  return { on, getButtonProps };
}

export function PropGettersExample() {
  const { on, getButtonProps } = useToggle();

  return (
    <button
      {...getButtonProps({
        onClick: () => console.log('user handler'),
      })}
    >
      {on ? 'On' : 'Off'}
    </button>
  );
}

Преимущества:

Типизация компонентов: React.FC vs обычная функция

С точки зрения TypeScript есть два основных подхода:

type UserCardProps = {
  name: string;
  children?: React.ReactNode;
};

// Вариант 1: React.FC
const UserCardFC: React.FC<UserCardProps> = ({ name, children }) => {
  return (
    <div>
      <h3>{name}</h3>
      {children}
    </div>
  );
};

// Вариант 2: обычная функция
function UserCard(props: UserCardProps) {
  const { name, children } = props;
  return (
    <div>
      <h3>{name}</h3>
      {children}
    </div>
  );
}

Чаще рекомендуют второй вариант:

Tabs как compound‑компоненты

Tabs — классический пример compound‑паттерна: есть корневой Tabs и дочерние Tabs.List, Tabs.Tab, Tabs.Panel.

```tsx type TabsContextValue = { active: string; setActive: (id: string) => void; };

const TabsContext = createContext<TabsContextValue null>(null);

function useTabsContext() { const ctx = useContext(TabsContext); if (!ctx) { throw new Error(‘Компоненты Tabs.* должны использоваться внутри '); } return ctx; }

type TabsRootProps = { defaultTab: string; children: React.ReactNode; };

function TabsRoot({ defaultTab, children }: TabsRootProps) { const [active, setActive] = useState(defaultTab);

return ( <TabsContext.Provider value=> <div>{children}</div> </TabsContext.Provider> ); }

function TabsList({ children }: { children: React.ReactNode }) { return <div style=>{children}</div>; }

function TabsTab({ id, children }: { id: string; children: React.ReactNode }) { const { active, setActive } = useTabsContext(); const isActive = active === id;

return ( <button style= onClick={() => setActive(id)} > {children} </button> ); }

function TabsPanel({ id, children }: { id: string; children: React.ReactNode }) { const { active } = useTabsContext(); if (active !== id) return null; return <div>{children}</div>; }

export const Tabs = Object.assign(TabsRoot, { List: TabsList, Tab: TabsTab, Panel: TabsPanel, });

export function TabsExample() { return ( One Two Three

  <Tabs.Panel id="one">Содержимое первой вкладки</Tabs.Panel>
  <Tabs.Panel id="two">Содержимое второй вкладки</Tabs.Panel>
  <Tabs.Panel id="three">Содержимое третьей вкладки</Tabs.Panel>
</Tabs>   ); }