Ещё несколько лет назад принципы SOLID были неотъемлемой частью собеседований для разработчиков любого уровня. Вопросы вроде «Расскажите, что означает каждая буква в SOLID» звучали так же часто, как «Что такое замыкание в JavaScript?». Это считалось своеобразной классикой, обязательной для понимания любого уважающего себя программиста.

Однако в последнее время, особенно во фронтенд-разработке и в мире React, акцент на SOLID заметно снизился и, например, вопросы о нем на собеседованиях встречаются всё реже. В первую очередь, это связано с переходом к функциональному стилю программирования, широким использованием хуков и отказом от классовых компонентов, что отодвигает принципы ООП на второй план.

Тем не менее, я убеждён, что принципы SOLID по-прежнему актуальны и полезны, даже в контексте функционального подхода. JavaScript и React не запрещают применять лучшие практики из ООП — наоборот, они предоставляют гибкость для использования различных парадигм.

В этой статье я хочу поделиться своим взглядом на то, как каждый из принципов SOLID может быть применен в разработке React-приложений. Здесь не будет революционных идей, особенно для опытных разработчиков, но, возможно, эта информация окажется полезной для начинающих разработчиков, стремящихся писать более качественный и поддерживаемый код.

Что такое SOLID?

Давайте начнем с обозначения терминологии и понятий о которых пойдет речь ниже.
SOLID — это аббревиатура из пяти принципов, которые помогают писать гибкий, масштабируемый и сопровождаемый код. Эти принципы направлены на улучшение архитектуры программного обеспечения и минимизацию ошибок при внесении изменений в систему. Аббревиатура расшифровывается как:

  • S — Single Responsibility Principle (Принцип единственной ответственности)

  • O — Open/Closed Principle (Принцип открытости/закрытости)

  • L — Liskov Substitution Principle (Принцип подстановки Барбары Лисков)

  • I — Interface Segregation Principle (Принцип разделения интерфейса)

  • D — Dependency Inversion Principle (Принцип инверсии зависимостей)

Принципы SOLID были популяризированы Робертом С. Мартином (он же Uncle Bob) в начале 2000-х годов, но формировались по частям ранее, в частности в трудах Барбары Лисков и других авторов. Эти идеи стали основой многих современных подходов к чистой архитектуре (Clean Architecture) и проектированию систем.
Давайте рассмотрим каждый из принципов и как их можно применять при разработке React приложений.

Single Responsibility Principle

Первый и, наверное, самый очевидный принцип - Single Responsibility Principle или принцип единой ответственности. Он гласит: модуль (в контексте React - компонент, хук или функция) должны иметь одну причину для изменения.

Давайте рассмотрим компонент, который загружает данные, фильтрует их и рендерит список пользователей.

function UserList() {
  const [users, setUsers] = useState([]);
  const [filter, setFilter] = useState('');

  useEffect(() => {
    fetch('/api/users')
      .then((res) => res.json())
      .then((data) => setUsers(data));
  }, []);

  const filteredUsers = users.filter((user) =>
    user.name.toLowerCase().includes(filter.toLowerCase())
  );

  return (
    <div>
      <input
        type="text"
        value={filter}
        onChange={(e) => setFilter(e.target.value)}
      />
      <ul>
        {filteredUsers.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

Уверен, что каждый из нас делал что-то подобное, но фактически этот компонент нарушает принцип единой ответственности, т.к. решает сразу три задачи. Давайте попробуем отрефакторить его и разделить на составные части, каждая из которых имеет свою область ответственности:

  • Сделаем отдельный хук для загрузки данных:

function useUsers() {
  const [users, setUsers] = useState([]);
  useEffect(() => {
    fetch('/api/users')
      .then((res) => res.json())
      .then((data) => setUsers(data));
  }, []);
  return users;
}
  • Фильтрацию реализуем через отдельную утилиту:

function filterUsers(users, filter) {
  return users.filter((user) =>
    user.name.toLowerCase().includes(filter.toLowerCase())
  );
}
  • Ну и собственно рендеринг оставим самому компоненту

function UserList() {
  const users = useUsers();
  const [filter, setFilter] = useState('');
  const filteredUsers = filterUsers(users, filter);

  return (
    <div>
      <input
        type="text"
        value={filter}
        onChange={(e) => setFilter(e.target.value)}
      />
      <ul>
        {filteredUsers.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

В итоге мы получаем:

  • Переиспользуемость: хук и утилиту можно использовать в других частях приложения.

  • Тестируемость: логику легче тестировать отдельно.

  • Поддерживаемость: изменения в одной части не ломают другую.

SRP помогает создавать масштабируемый и понятный код. В реальной работе это экономит время и снижает количество проблем.

Open-Closed Principle

Open-Closed Principle гласит: программные сущности должны быть открыты для расширения, но закрыты для изменения. Более простым языком наш код должен быть готов к новым фичам (открыт для расширения), но при этом не нужно постоянно переписывать старый код (закрыт для изменений).

Очень часто велик соблазн переиспользовать компонент созданный пару месяцев назад для новой фичи, допилив его немного напильником. Но если каждый раз править компоненты, можно наломать дров и добавить багов.

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

function UserCard({ user }) {
  return (
    <div className="card">
      <img src={user.avatar} alt="Avatar" />
      <h3>{user.name}</h3>
      <p>{user.phone}</p>
    </div>
  );
}

Код работает отлично, но тут приходит задача: нужно сделать список чатов, как в Telegram. Карточка чата выглядит почти так же, только вместо телефона — последнее сообщение. Что делать?
Плохой подход: переписать UserCard, чтобы он принимал и телефон, и сообщение, добавив кучу условий. Это ломает принцип OCP, потому что мы меняем старый код, рискуя сломать адресную книгу.
Хороший подход: сделать компонент более универсальным, чтобы он был открыт для новых сценариев.

function Card({ title, subtitle, image }) {
  return (
    <div className="card">
      <img src={image} alt="Avatar" />
      <h3>{title}</h3>
      <p>{subtitle}</p>
    </div>
  );
}

Теперь этот компонент можно использовать и для адресной книги, и для чатов:

// Для адресной книги
<Card title={user.name} subtitle={user.phone} image={user.avatar} />

// Для чатов
<Card title={chat.name} subtitle={chat.lastMessage} image={chat.avatar} />

В итоге наш компонент:

  1. Открыт для расширения: мы можем использовать Card для групп, каналов или чего угодно еще, просто передавая разные данные.

  2. Закрыт для изменений: нам не нужно трогать код Card, чтобы добавить новый тип карточки. Адресная книга продолжает работать без багов.

На самом деле этот принцип встречается повсюду, и вы, скорее всего, уже используете его, даже не замечая. Вот несколько типичных случаев:

  1. React-router - ты можешь добавить новую страницу, просто указав новый маршрут. Роутер открыт для новых страниц и закрыт для изменений — тебе не нужно править его код, чтобы добавить новую страницу.

  2. Библиотеки вроде react-hook-form позволяют создавать кастомные поля (например, выпадающий список или чекбокс), не трогая их исходный код. Ты просто передаешь свои компоненты, а библиотека делает остальное.

  3. Любые библиотеки типа Material UI. Мы не меняем исходных компонентов предоставляемых библиотекой, но на их базе строим свои собственные

Чтобы ваш код соответствовал OCP, придерживайтесь этих простых правил:

  1. Делайте компоненты универсальными: вместо конкретных данных (например, user.phone) используйте абстрактные пропсы (subtitle).

  2. Используйте композицию: вместо изменения компонента создавайте новые, которые используют старые.

  3. Пишите переиспользуемые хуки: инкапсулируйте логику в хуки, чтобы их можно было использовать в разных местах.

  4. Тестируйте гибкость: проверяйте, можно ли добавить новую фичу, не трогая старый код.

Liskov Substitution Principle

Принцип подстановки Барбары Лисков гласит, что объекты производного класса должны быть взаимозаменяемы с объектами базового класса без изменения поведения программы. Проще говоря, если у вас есть базовый компонент или класс, то любой его наследник или вариация должны соответствовать ожиданиям, заданным базовым компонентом, и не нарушать его контракт.

Давайте представим компонент IconButton. Вначале это может быть просто базовая кнопка-иконка, но со временем ее функционал может расширяться: могут появиться состояния (активна/неактивна), какие-нибудь счетчики, например количество товаров в корзине и т.д.

Следуя ранее рассмотренным принципам SOLID нам не стоит допиливать функциональность базового компонента для этой цели. Лучшим решением будет создание "расширенных" компонентов, добавляющих необходимый функционал.

// BaseButton.jsx
function BaseButton({ className, children, onClick }) {
  return (
    <button className={`base-button ${className || ''}``} onClick={onClick}>
      {children}
    </button>
  );
}

// IconButton.jsx
function IconButton({ icon, onClick }) {
  return (
    <BaseButton className="icon-button" onClick={onClick}>
      <span>{icon}</span>
    </BaseButton>
  );
}

// CartButton.jsx
function CartButton({ icon, count, onClick }) {
  return (
    <BaseButton className="cart-button" onClick={onClick}>
      <span>{icon}</span>
      <span>{count}</span>
    </BaseButton>
  );
}

Обратите внимание что все типы кнопок теперь используют базовый функционал и при этом на место базового компонента можно поставить расширенный и ничего не сломается. Функциональность компонентов изолирована, и если нам нужно изменить что-то в одном из компонентов можно не бояться сломать все остальные. Также упрощается написание тестов и стилей - каждый новый компонент использует базу от предыдущих и требует всего пару доп строк кода для каждого кейса.

Interface Segregation Principle

Дядюшка Боб сформулировал этот принцип так:

Программные сущности не должны зависеть от методов, которые они не используют

С одной стороны - это чисто Тайпскриптовая тема, но мы можем увидеть почему соблюдение этого принципа важно и при работе с чистым JS.

Давайте начнем с примера. Представьте что в приложении у нас существуют пользователи различного типа. У каждого типа свои возможности:

  1. Админ: может удалять посты, банить пользователей, редактировать контент.

  2. Модератор: может удалять посты и редактировать контент, но не банить пользователей.

  3. Обычный пользователь: может только создавать посты. Возникает вопрос: как типизировать пользователей? Создать один общий интерфейс для всех или отдельные интерфейсы для каждого типа?

Если мы выберем единый интерфейс, то столкнемся с проблемой:

interface User {
  createPost: () => void;
  deletePost?: () => void; // Опционально для обычных пользователей
  banUser?: () => void;   // Только для админов
  editContent?: () => void; // Для админов и модераторов
}

В этом случае мы получаем:

  1. Обычные пользователи получают доступ к методам (deletePost, banUser), которые они не должны использовать.

  2. Опциональные методы (?) снижают строгость Тайпскрипта и теряется смысл от его использования.

  3. Компоненты, использующие User, вынуждены проверять наличие методов, что усложняет код.

Это классический пример "жирного интерфейса", который заставляет компоненты зависеть от ненужных методов.

Оптимальным решением нашей проблемы может стать использование разделения интерфейсов. Я приведу немного гиперболизированный синтетический пример, просто чтобы вы поняли принцип:

interface PostCreator {
  createPost: () => void;
}

interface PostEditor {
  editContent: () => void;
}

interface PostDeleter {
  deletePost: () => void;
}

interface UserBanner {
  banUser: () => void;
}

type RegularUser = PostCreator;
type Moderator = PostCreator & PostEditor & PostDeleter;
type Admin = PostCreator & PostEditor & PostDeleter & UserBanner;

То же самое по смыслу можно реализовать с помощью классов, в том числе и в обычном JavaScript.

ISP становится особенно актуальным, когда мы работаем с сторонними библиотеками. Наверняка у вас бывало, что вы используете всего пару методов из большого npm-пакета, а после очередного обновления приложение ломается. Почему? Разработчики библиотеки изменили или удалили метод, который вы даже не использовали, но он был частью того же "жирного" интерфейса.

Возьмем, к примеру, библиотеки вроде Lodash или Moment.js. В прошлом разработчики часто подключали весь модуль, чтобы использовать всего несколько функций. Это приводило к тому что:

  1. Вы тащили в проект кучу ненужного кода.

  2. Обновление библиотеки могло сломать приложение из-за изменений в неиспользуемых методах.

  3. Ваш код зависел от огромного интерфейса библиотеки, хотя вам нужны были лишь отдельные функции.

Современные подходы и модульные библиотеки позволяют следовать ISP используя только необходимый функционал.

//вместо
import _ from 'lodash'; // Импортируем весь модуль
const debouncedFn = _.debounce(myFunction, 300);

//используем
import debounce from 'lodash/debounce'; // Импортируем только debounce
const debouncedFn = debounce(myFunction, 300);

Принцип разделения интерфейсов помогает создавать более чистый и поддерживаемый код, будь то TypeScript или чистый JavaScript. Разбивая "жирные" интерфейсы на маленькие и специализированные, мы:

  1. Упрощаем тестирование и отладку.

  2. Делаем компоненты независимыми от лишней функциональности.

  3. Снижаем риски, связанные с обновлением библиотек.

  4. Оптимизируем размер бандла приложения.

  5. Повышаем читаемость и гибкость кода.

Dependency Inversion Principle

Если мы откроем какую-нибудь Вики, то найдем там следующее определение этого принципа:

Модули высокого уровня не должны зависеть от модулей низкого уровня. И те, и другие должны зависеть от абстракций.

Давайте рассмотрим хук для получения пользователей, который мы рассматривали в самом начале статьи:

function useUsers() {
  const [users, setUsers] = useState([]);
  useEffect(() => {
    fetch('/api/users')
      .then((res) => res.json())
      .then((data) => setUsers(data));
  }, []);
  return users;
}

Он нарушает принцип инверсии зависимостей, т.к. зависит от fetch - модуля более низкого уровня. Если у нас возникнет потребность заменить fetch на что-то еще - будет необходимо идти по всему приложению и исправлять код, связанный с получением данных.

Оптимальное решение для подобных случаев состоит в том, чтобы компонент или хук зависел от какого-нибудь абстрактного API клиента, а fetch или условный axios "подстраивался" бы под интерфейс этого клиента. Мы можем создать абстрактный интерфейс для провайдера данных и использовать его:

export interface User {
  id: number;
  name: string;
}

export interface UserService {
  getUsers(): Promise<User[]>;
}

Теперь создадим конкретную реализацию с использование fetch:

import { UserService, User } from "./types";

export const fetchUserService: UserService = {
  async getUsers(): Promise<User[]> {
    const res = await fetch("/api/users");
    return res.json();
  },
};

И теперь собственно наш хук. После изменений он будет зависеть не от fetch, а от UserService:

function useUsers() {
  const [users, setUsers] = useState([]);
  
  useEffect(() => {
    fetchUserService.getUsers().then(setUsers);
  }, [userService]);
  
  return users;
}

Теперь хук зависит от UserService и если в последующем мы решим использовать axios вместо fetch нам не придется трогать код компонентов. Мы просто реализуем UserService с использованием нового подхода и будем использовать его как раньше.

Подведем итоги

Принципы SOLID, несмотря на их происхождение из объектно-ориентированного программирования, остаются актуальными и полезными в контексте разработки React-приложений, даже в эпоху функционального программирования и хуков. Они помогают создавать гибкий, масштабируемый и поддерживаемый код, что особенно важно в условиях быстро меняющихся требований и роста сложности проектов.

Практические рекомендации:

  • Дробите компоненты и логику на небольшие, специализированные части.

  • Используйте композицию и хуки для повышения переиспользуемости.

  • Стремитесь к абстракциям в зависимостях и интерфейсах.

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

SOLID — это не догма, а инструмент для улучшения качества кода. И по моему мнению, в мире React эти принципы не теряют своей ценности, а адаптируются к новым реалиям функционального программирования. Их осознанное применение помогает писать код, который легче понимать, тестировать и развивать, что особенно важно для долгосрочных проектов.

Данная статья фактически является компиляцией серии постов вышедших в моем Телеграм канале. В нем я делюсь своим опытом, пишу реальные истории из повседневной работы в качестве разработчика и преподавателя, рассказываю о своих проектах. Подписывайтесь, будет интересно и полезно https://t.me/+iEqVnqxCHfhlZmYy.

Комментарии (10)


  1. isumix
    10.05.2025 18:38

    Сам Реакт не использует single-responsibility principle. Помимо работы с ДОМ в него инкорпорирован Стейт, отсюда постоянный ре-рендер и проблемы с производительностью, с которыми решили бороться с помошью таймаутов, чтобы интерфейс не фризился, и пришлось инкорпорировать асинхронность, вместо async/await, а также componentDidCatch вместо try/catch/finally. Также теперь пошли серверные компоненты и компилятор чтобы хоть как-то улучшить производительность раздутой библиотеки фреймворка.

    Критикуешь, предлагай. Предлагаю Фьюзор. У него единственная ответственность - создавать элементы ДОМ и обновлять их. Остальное в ваших руках. Какой хотите стейт. Какое хотите обновление и diffing, хотите реактивность - пожалуйста.


  1. nin-jin
    10.05.2025 18:38

    Почему не стоит следовать SOLID я рассказывал тут. Почему не стоит использовать React - тут. Надо бы ещё и про Fusor что-то записать.. А пока вы ждёте, подписывайтесь на мой канал, будет интересно и небесполезно.


    1. Shoom3301
      10.05.2025 18:38

      Тебе каждый раз лепят тонну минусов, ты совсем никакие выводы не делаешь?


      1. nin-jin
        10.05.2025 18:38

        Прекратите писать чушь и вы меня никогда больше не увидите.


  1. Sipaha
    10.05.2025 18:38

    наверное, самый очевидный принцип - Single Responsibility Principle

    Спасибо, посмеялся. Рекомендую к ознакомлению:

    https://habr.com/ru/articles/565158/


    1. maks88sgt Автор
      10.05.2025 18:38

      Спасибо за статью. Согласен, что с очевидностью я действительно преувеличил. Но в целом я считаю, что это нормально, когда твоя точка зрения эволюционирует во времени, т.к. мы всегда чему-то учимся, получаем новый опыт, подтверждение или опровержение разных позиций. Например, еще вчера мне SRP казался очевидным, сегодня уже нет


  1. turbo_f
    10.05.2025 18:38

    Интересный пример с пользователями и фильтрацией. Автор разделил их на три функции: получение, фильтрация, рендеринг. И говорит, что если сломать одну из них, то это не затронет остальные.

    Кажется я что-то не понимаю, но разве если я сломаю «получение», все остальное выдаст какой-то адекватный результат? Или фильтрацию? Или рендеринг?

    Возможно этот подход удобнее для чтения кода или его отладки, но принципиально эти подходы вообще ни разу не ведут к повышению надежности, хотя статья так и накрывает вуалью надежности и правильности.


    1. maks88sgt Автор
      10.05.2025 18:38

      Про надежность согласен, но это же только одна из характеристик кода, а с другими, именно так как вы пишите - действительно работать становится чуть приятнее . А насчет правильности вопрос холиварный, просто по-моему мнению в этих принципах действительно есть полезные идеи и их применение на практике вполне может приносить бенефиты.


  1. wanhelsing
    10.05.2025 18:38

    Стоит также упомянуть другой легендарный принцип, описанный ещё в Совершенном коде Стивена Макконнелла:


  1. Fredwared
    10.05.2025 18:38

    Есть смысл во всём но ради своих прихотей заправлять проект разными концепциями мне кажется плохая идея.