React: как создать переиспользуемый компонент
Семь лет назад я, как и многие, мог без особых раздумий скопировать пару компонентов между проектами и считать это переиспользованием. Но потом код начал ползти в разные стороны, обновлять его становилось все сложнее, и понемногу стало понятно: чтобы компоненты реально служили больше одного проекта — одной команды этого мало. Копипастой тут не отделаешься.
Спустя несколько лет и десяток продуктовых релизов внутри одной компании у меня появилась совсем другая перспектива на вопрос “как строить переиспользуемое”. Джентльменский набор советов из документации типа “делайте DRY, избегайте дублирования” звучит хорошо на бумаге, но через год легко оборачивается здоровенным компонентом на 50 пропсов с кучей if-ов (“чтобы удовлетворить все сценарии!”).
Вот по каким принципам я стараюсь работать сейчас — те, что проверил не единожды в бою.
О чем вообще речь: переиспользуемость — это не про DRY
Все еще вижу вокруг этот стереотип: если ты вытащил кусок JSX в отдельный файл — все, миссия выполнена. На деле работает совсем иначе. Настоящая переиспользуемость держится примерно на трех вещах:
- Простое и предсказуемое API (не нужно открывать сорцы, чтобы догадаться, что за props ждать)
- Минимальное влияние стилей друг на друга (никакой магии с внешними CSS для “унификации”)
- Композиция вместо бесконечных опций (блоки можно складывать из маленьких деталей под нужный кейс)
Я как-то долго боролся с искушением построить универсальный компонент — столько раз казалось, что проще добавить еще одну опцию… И еще одну. А спустя пару месяцев всё превращается во Frankenstein-компоненты с десятками входных параметров.
Как я для себя это решил? Вот прям пошагово — покажу на примере кнопки.
Шаг 1: Всё сходится к простейшему варианту
Практически каждый раз соблазн велик начать сразу с чего-то «прокачанного»: может быть добавить лоадер? Или кнопку-с-иконкой? Да что там — пусть будет поддержка тултипов прямо тут. Такой подход выглядит заманчиво… ровно до тех пор, пока не надо будет внести изменения или учесть новый случай.
Я теперь начинаю всегда с самого простого варианта — самой базовой кнопки. Только минимально необходимый функционал (условно — onClick, дети и пара стилей).
Компонент Button.jsx:
import styles from './Button.module.css';
export function Button({ children, variant = 'primary', ...props }) {
return (
<button
className={`${styles.button} ${styles[variant]}`}
{...props}
>
{children}
</button>
);
}
Обратите внимание на ...props — это ключевой момент. Компонент не блокирует доступ к атрибутам HTML. Если кому-то нужен onClick, disabled или aria-label — они просто передаются насквозь.
Шаг 2: Композиция вместо раздутого API
Когда возникает потребность в кнопке с иконкой, у джуниоров первая мысль — добавить пропс icon, так делать не стоит:
<Button icon={<SaveIcon />} iconPosition="left">Сохранить</Button>
Проблема в том, что через месяц появится iconSize, iconColor, loadingIcon. API распухает. Я решаю это через композицию:
export function Button({ children, variant = 'primary', ...props }) {
return (
<button className={`${styles.button} ${styles[variant]}`} {...props}>
<span className={styles.content}>{children}</span>
</button>
);
}
Использование становится гибким:
<Button>
<SaveIcon />
Сохранить
</Button>
Ребёнок сам решает, как выглядит его контент. Стили компонента через flexbox выравнивают всё, что внутри.
Шаг 3: Управление состоянием
Это то, что отличает библиотечный компонент от быстрого решения на коленке. Возьмём Toggle:
export function Toggle({
checked,
defaultChecked = false,
onChange
}) {
const [internalChecked, setInternalChecked] = useState(defaultChecked);
const isControlled = checked !== undefined;
const currentChecked = isControlled ? checked : internalChecked;
const handleClick = () => {
const newValue = !currentChecked;
if (!isControlled) {
setInternalChecked(newValue);
}
onChange?.(newValue);
};
return (
<button
role="switch"
aria-checked={currentChecked}
onClick={handleClick}
className={styles.toggle}
>
<span className={currentChecked ? styles.on : styles.off} />
</button>
);
}
Теперь компонент работает в двух режимах. Для простых случаев:
<Toggle defaultChecked={true} onChange={console.log} />
Для форм с валидацией, где состояние живёт выше:
<Toggle checked={formState.notifications} onChange={setNotifications} />
Этот паттерн я подсмотрел в исходниках Radix UI, и он экономит десятки часов при масштабировании проекта.
Шаг 4: Compound Components для сложных структур
Когда компонент состоит из нескольких связанных частей — например, Tabs — я использую Context вместо передачи пропсов через десять уровней:
const TabsContext = createContext(null);
export function Tabs({ children, defaultValue }) {
const [active, setActive] = useState(defaultValue);
return (
<TabsContext.Provider value={{ active, setActive }}>
<div className={styles.tabs}>{children}</div>
</TabsContext.Provider>
);
}
Tabs.List = function TabsList({ children }) {
return <div className={styles.list}>{children}</div>;
};
Tabs.Trigger = function TabsTrigger({ value, children }) {
const { active, setActive } = useContext(TabsContext);
return (
<button
className={active === value ? styles.activeTab : styles.tab}
onClick={() => setActive(value)}
>
{children}
</button>
);
};
Tabs.Content = function TabsContent({ value, children }) {
const { active } = useContext(TabsContext);
return active === value ? <div>{children}</div> : null;
};
Использование получается декларативным и читаемым:
<Tabs defaultValue="profile">
<Tabs.List>
<Tabs.Trigger value="profile">Профиль</Tabs.Trigger>
<Tabs.Trigger value="settings">Настройки</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="profile">Контент профиля</Tabs.Content>
<Tabs.Content value="settings">Контент настроек</Tabs.Content>
</Tabs>
Никакого prop drilling, каждая часть независима, но связана через контекст.
Шаг 5: Типизация как документация
Если проект на TypeScript, я всегда extend’ю нативные HTML-атрибуты, а не изобретаю велосипед:
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger';
}
Это даёт автокомплит для всех стандартных атрибутов бесплатно, и разработчикам не нужно лезть в исходники, чтобы понять, что кнопка принимает type="submit".
Шаг 6: Тестирование через поведение
Я тестирую компоненты с точки зрения пользователя через React Testing Library:
test('вызывает onChange при клике', () => {
const handleChange = jest.fn();
render(<Toggle onChange={handleChange} />);
fireEvent.click(screen.getByRole('switch'));
expect(handleChange).toHaveBeenCalledWith(true);
});
Такие тесты не ломаются при рефакторинге внутренностей — только при изменении реального поведения.
Итоговые принципы
Вот что осталось у меня на подкорке после нескольких лет возни с компонентами — свой внутренний короткий список, которому я доверяю почти рефлекторно. Каждый раз, когда появлялось что-то новое, я мысленно проходил по этим пунктам:
Во-первых
Любой компонент пусть умеет честно прокидывать пропсы родителя дальше через спред. Иногда кажется, что проще жестко прописать нужные атрибуты самому, но жизнь быстро учит — лишишь кого-то гибкости ровно тогда, когда этого меньше всего ждешь.
Сложное поведение
То, что хочется перенастроить или вообще отключить в отдельных случаях — не стоит зашивать внутрь через очередные дополнительные пропсы. Гораздо удобнее вынести это наружу и собирать композиционно, как лего из уже знакомых кубиков.
Еще штука из разряда «набил шишек»
Если твой компонент должен быть повторно переиспользуемым (а иначе зачем вся эта морока), позаботься о том, чтобы он спокойно работал и в управляемом («мне всё скажет родитель»), и в неуправляемом («я сам себе хозяин») режимах. Вариативность тут очень выручает.
Контексту тоже нашлось свое почетное место
Когда компоненты начинают строиться друг на друге (в духе сложных интерфейсных паттернов), только Context более-менее элегантно решает старую боль с prop drilling — ни один кастыль так не выручает.
Ну и последнее — штука скорее философская. Многим кажется, что писать универсальные компоненты это про набор техник и правил. А по-настоящему работает привычка каждый раз спрашивать себя: «А как бы этим стал пользоваться человек со стороны?» Если этот вопрос появляется автоматически до того, как руки потянулись к клавиатуре — велика вероятность, что ты с самого начала не делаешь одноразовый полуфабрикат, а пишешь что-то настоящее библиотечное.