Контекстно-ориентированное проектирование в реакт

Многие программисты, работающие с React, часто совмещают логику и рендеринг UI в одном компоненте. Это неудивительно, так как React интуитивно позволяет это делать. Мне тоже нравится такой подход, так как он позволяет быстро проектировать системы. Однако на больших проектах он оказывается малоэффективным. Например, когда нужно вынести некоторые компоненты в Storybook, возникает проблема отделения UI от логики. Для этого создаются дополнительные прокси-компоненты, которые используют хуки, получают из них необходимые данные и передают их в UI-компоненты.

Когда такое разделение стало повторяться снова и снова, я начал задумываться о том, как его улучшить. Так я пришёл к тому, что назвал «контекстно-ориентированным проектированием». Суть этого подхода заключается в том, чтобы перемещать как можно больше логики в контексты и затем использовать эти контексты в компонентах. Давайте рассмотрим простой пример: допустим, у нас есть компонент:

type Book = {
  id: string
  title: string
}

const Books: React.FC = () => {
  const { data: books, isLoading } = useGetBooks()
  const { mutateAsync: deleteBook } = useDeleteBook()

  if (isLoading) {
    return <p>Loading...</p>
  }

  return (
    <ul>
      {books.map((book) => (
        <li key={book.id}>
          {book.title}
          <button onClick={() => deleteBook(book.id)}>Delete</button>
        </li>
      ))}
    </ul>
  )
}

Для примера взят простой и достаточно распространённый компонент. Он с помощью хука, аналогичного Tanstack Query, получает список книг, выводит их на экран и позволяет удалять книги из коллекции.

Но что, если в команду приходит новый человек и он не знает, что можно делать с коллекцией книг или с каждой книгой по отдельности? Решение выглядит несложным — нужно создать объекты Books и Book, которые смогут вобрать в себя все необходимые методы. Тогда при вызове new Book(bookId). или new Books(). мы получим список доступных методов.

Таким образом, код можно переделать следующим образом:

type Book = {
  id: string
  title: string
}

const Books: React.FC = () => {
  const { data: books, isLoading } = useGetBooks()
  const { mutateAsync: deleteBook } = useDeleteBook()
+  const [books] = React.useState(new Books())

  if (isLoading) {
    return <p>Loading...</p>
  }

  return (
    <ul>
      {books.map((book) => (
        <li key={book.id}>
          {book.title}
-          <button onClick={() => deleteBook(book.id)}>Delete</button>
+          <button onClick={() => books.delete(book.id)}>Delete</button>
        </li>
      ))}
    </ul>
  )
}

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

type Book = {
  id: string
  title: string
}

type Books = {
  isLoading: boolean
  list: Book[]
  delete: (bookId: string) => void
}

const BooksContext = React.createContext<Books>(null as any)

export const useBooks = (): Books => {
  const context = React.useContext(BooksContext)

  if (context == null) {
    throw new Error('You can use useBooks only within BooksProvider')
  }

  return context
}

export const ApiBooksProvider: React.FC<{ children: React.ReactNode }> = ({
  children,
}) => {
  const { data, isLoading } = useGetBooks()
  const { mutateAsync: deleteBook } = useDeleteBook()

  return (
    <BooksContext.Provider
      value={{
        isLoading,
        list: data,
        delete: deleteBook,
      }}
    >
      {children}
    </BooksContext.Provider>
  )
}

И переделаем наш компонент на использование контекста:

const BooksList: React.FC = () => {
  const books = useBooks()

  if (books.isLoading) {
    return <p>Loading...</p>
  }

  return (
    <ul>
      {books.list.map((book) => (
        <li key={book.id}>
          {book.title}
          <button onClick={books.delete}>Delete</button>
        </li>
      ))}
    </ul>
  )
}

// app.tsx
const App: React.FC = () => {
  return (
    <ApiBooksProvider>
      <BooksList />
    </ApiBooksProvider>
  )
}

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

Кроме того, при вызове books. мы получим список всех доступных свойств и методов.

При таком подходе реализаций интерфейса Books может быть сколько угодно. Например, для тестового окружения можно создать контекст, который будет хранить данные локально и не делать никаких вызовов по сети. Или можно расширить контекст, добавив параметром ссылку на API:

const ApiBooksProvider: React.FC<{
  baseUrl: string
  children: React.ReactNode
}> = ({ baseUrl, children }) => {
  // rest of the code
}

// app.tsx
const App: React.FC = () => {
  return (
    <ApiBooksProvider baseUrl="https://your-production-api.com">
      <BooksList />
    </ApiBooksProvider>
  )
}

// app.test.tsx
const App: React.FC = () => {
  return (
    <ApiBooksProvider baseUrl="https://your-mock-api.com">
      <BooksList />
    </ApiBooksProvider>
  )
}

Производительность

Возникает вопрос о производительности, так как при изменении контекста будет заново рендериться всё дерево компонентов. Чтобы оптимизировать рендеры, нужно размещать провайдер как можно ближе к месту его использования. То есть не оборачивать всё приложение, а обернуть только коллекцию книг, например (обратите внимание на расположение ApiBooksProvider в дереве):

// app.tsx (before)
const App: React.FC = () => {
  return (
    <ApiBooksProvider>
      <Wrapper1>
        <Wrapper2>
          <Wrapper3>
            <BooksList />
          </Wrapper3>
        </Wrapper2>
      </Wrapper1>
    </ApiBooksProvider>
  )
}

// app.tsx (after)
const App: React.FC = () => {
  return (
    <Wrapper1>
      <Wrapper2>
        <Wrapper3>
          <ApiBooksProvider>
            <BooksList />
          </ApiBooksProvider>
        </Wrapper3>
      </Wrapper2>
    </Wrapper1>
  )
}

Выводы

Данный подход отлично зарекомендовал себя на больших объёмах кода (проекты с миллионами строк и более). Он позволяет привнести «объектно-ориентированное» мышление и упростить работу с кодом. Все сущности и методы к ним собраны в одном месте, и новичок сразу может понять, что ему разрешено делать, а что запрещено: API контекста само описывает необходимые действия.

Самое удобное в том, что при таком подходе мы можем конфигурировать, расширять и заменять контексты по своему усмотрению.

Ключевые моменты

  1. Разделение логики и UI: Важно разделять логику и рендеринг UI в компонентах React, чтобы улучшить масштабируемость и читаемость кода, особенно в больших проектах.
  2. Контекстно-ориентированное проектирование: Перемещение логики в контексты позволяет централизовать управление данными и действиями, что упрощает работу с кодом и делает его более структурированным.
  3. Упрощение компонентов: Использование контекстов делает сами компоненты проще и понятнее, так как они фокусируются только на рендеринге UI, а не на обработке данных.
  4. Гибкость и конфигурируемость: Контексты позволяют легко конфигурировать, расширять и заменять логику приложения, что делает систему более гибкой и адаптируемой к изменениям.
  5. Производительность: Для оптимизации производительности важно размещать провайдеры контекстов как можно ближе к месту их использования, чтобы минимизировать количество повторных рендеров.
  6. Упрощение для новых разработчиков: Централизованное расположение логики и методов в контексте облегчает понимание кода для новых разработчиков, так как они сразу видят доступные действия и ограничения.
  7. Масштабируемость: Подход с использованием контекстов хорошо зарекомендовал себя в крупных проектах с миллионами строк кода, обеспечивая лучшую организацию и управляемость кода.