Доки по разработке
This project is maintained by teniryte
В этом файле собраны распространённые архитектурные паттерны для React‑компонентов и хуков: композиция, дженерики, управление состоянием, слоты, проп‑геттеры и типизация.
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 — это функция, которая принимает компонент и возвращает новый компонент, добавляющий поведение.
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‑компонент — это “семейство” компонентов вокруг одного корневого, которые разделяют контекст и поведение.
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>
);
}
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>}
/>
</>
);
}
useFormtype 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
Каждый рендер создаёт новое замыкание. Если эффект не зависит от состояния, внутри него могут использоваться старые значения.
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”.
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 позволяет пользователю компонента переопределять логику изменения состояния, не ломая базовый функционал.
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 переключений).
Компонент может поддерживать как управляемый, так и неуправляемый режим:
value/on, компонент не хранит состояние, а лишь вызывает onChange;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)Идея проп‑геттеров:
getButtonProps, getInputProps и т.п.;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>
);
}
Чаще рекомендуют второй вариант:
children;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.* должны использоваться внутри
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 (
<Tabs.Panel id="one">Содержимое первой вкладки</Tabs.Panel>
<Tabs.Panel id="two">Содержимое второй вкладки</Tabs.Panel>
<Tabs.Panel id="three">Содержимое третьей вкладки</Tabs.Panel>
</Tabs> ); }