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

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

import { useState } from "react"

const Component: React.FC = () => {
  const [count, setCount] = useState(0)

  const handleIncrement = () => {
    setCount((c) => c + 1)
  }

  return (
    <div>
      <div data-testid="count">count: {count}</div>
      <button data-testid="increment" onClick={handleIncrement}>increment</button>
    </div>
  )
}

Через тестирование нашего кода мы можем лучше понимать, как он работает. Поэтому покроем тестом наше условие.

import { render, screen, within } from "@testing-library/react"
import userEvent from "@testing-library/user-event"

import { Component } from "./component-with-hook"

describe("Component", () => {
  test("should increment count after click", async () => {
    render(<Component />)

    const count = screen.getByTestId("count")
    expect(within(count).getByText("0")).toBeInTheDocument()

    const button = screen.getByTestId("increment")
    await userEvent.click(button)
    expect(within(count).getByText("1")).toBeInTheDocument()
  })
})

Казалось бы, это самый обычный пример использования хуков, но на сколько нормально, что логика описывается внутри компонента?

По идее должно быть так отображение + логика = компонент

Если посмотреть на наш компонент как на сложный элемент, который состоит из частей, то тест является интеграционным, так как каждая его часть (логика и отображение) - это unit.

Для декомпозиции компонента, вынесем всю логику в хук и протестируем его отдельно. Для отображения надо просто вынести хук из компонента на уровень выше. Этим уровнем будет объединяющий логики и отображение - компонент. Иногда его называют BLoC «Business Logic Component».

Напишем сначала компонент для отображения

interface Props {
  count: number
  onIncrement?: () => void
}

export const Display: React.FC<Props> = ({ count, onIncrement }) => (
  <div>
    <div>
      <b>count:</b> <span data-testid="count">{count}</span>
    </div>
    <button data-testid="increment" onClick={onIncrement}>
      increment
    </button>
  </div>
)

Покроем его тестом

import { render, screen, within } from "@testing-library/react"
import userEvent from "@testing-library/user-event"

import { Display } from "./display.component"

describe("Display", () => {
  test("should render count", () => {
    render(<Display count={4} />)

    const count = screen.getByTestId("count")

    expect(within(count).getByText("4")).toBeInTheDocument()
  })
  
  test("should call passed callback to onIncrement prop", async () => {
    const callbackMock = vi.fn() // Использую vitest обёртку над jest

    render(<Display count={0} onIncrement={callbackMock} />)

    const button = screen.getByTestId("increment")
    await userEvent.click(button)
    expect(callbackMock).toHaveBeenCalled()
  })
})

После напишем отдельный хук для управления состоянием

import { useState } from "react"

const useCount = () => {
  const [count, setCount] = useState(0)

  const handleIncrement = () => {
    setCount((c) => c + 1)
  }

  return { value: count, increment: handleIncrement }
}

Покрываем тестом хук

import { renderHook, act } from "@testing-library/react-hooks"

import { useCount } from "./count.hook"

describe("hook", () => {
  test("should render default value", () => {
    const { result } = renderHook(() => useCount())

    expect(result.current.value).toBe(0)
  })

  test("should increment value", () => {
    const { result } = renderHook(() => useCount())

    act(() => {
      result.current.increment()
    })

    expect(result.current.value).toBe(1)
  })
})

Если с отображением всё было достаточно понятно, то с хуком появляются нестыковки, которые я обозначу после создания BLoC

Реализуем BLoC

import { useCount } from "./count.hook"
import { Display } from "./display.component"

const Bloc = () => {
  const count = useCount()

  return <Display count={count.value} onIncrement={count.increment} />
}

Покрываем его тестом

import { render, screen, within } from "@testing-library/react"

import userEvent from "@testing-library/user-event"

import { Bloc } from "./bloc.component"

describe("Bloc", () => {
  it("should increment count after click", async () => {
    render(<Bloc />)

    const count = screen.getByTestId("count")

    expect(within(count).getByText("0")).toBeInTheDocument()

    const button = screen.getByTestId("increment")

    await userEvent.click(button)

    expect(within(count).getByText("1")).toBeInTheDocument()
  })
})

Как видим, интеграционный тест не изменился, изменился лишь тестируемый компонент.

Казалось бы, что на этом можно остановиться. Задача выполнена, формула воссоздана. Но, как мы можем наблюдать, наша логика не достаточно обособлена от жизненного цикла компонента. На это нам намекает хук renderHook для тестирования хуков. Иначе говоря, наш хук привязан к "экосистеме" компонента.

Можно вынести хук из компонента, а вот компонент из хука не вынести никогда.

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

Моим решением был HOC «Higher-Order Component» - это функция, которая принимает компонент и возвращает новый компонент. HOC должен совмещать логику и отображение. Cамый популярный из них - это connect из библиотеки react-redux.

Так как у нас есть уже готовое отображение, в виде компонента Display, то нам остаётся описать лишь логику. Опишем её через @reduxjs/toolkit.

import { createSlice } from "@reduxjs/toolkit"

export const countSlice = createSlice({
  name: "count",
  initialState: { value: 0 },
  reducers: {
    increment: (state) => {
      state.value += 1
    },
  },
})

Покроем тестом

import { countSlice } from "./count.slice"

describe("count slice", () => {
  it("should handle initial state", () => {
    const actual = countSlice.reducer(undefined, { type: "unknown" })

    expect(actual).toEqual({ value: 0 })
  })

  it("should handle increment", () => {
    const actual = countSlice.reducer(
      { value: 0 },
      countSlice.actions.increment(),
    )
    expect(actual.value).toBe(1)
  })
})

Как вы видите, теперь отсутствует прямая связь логики с отображением. Пробуем собрать это в одно целое.

import { connect } from "react-redux"

import { Display } from "./display.component"
import { countSlice } from "./count.slice"

export const Count = connect(
  (state: { value: number }) => ({ count: state.value }),
  (dispatch) => ({
    onIncrement: () => dispatch(countSlice.actions.increment()),
  }),
)(Display)

Покроем тестом

import { render, screen, within } from "@testing-library/react"
import userEvent from "@testing-library/user-event"

import { configureStore } from "@reduxjs/toolkit"

import { Count } from "./count.component"
import { countSlice } from "./count.slice"

const store = configureStore({
  reducer: countSlice.reducer,
})

describe("HOC connect", () => {
  it("should increment count after click", async () => {
    render(<Count store={store} />) // Вместо пропса store можно использовать обёртку из Provider с переданным store

    const count = screen.getByTestId("count")

    expect(within(count).getByText("0")).toBeInTheDocument()

    const button = screen.getByTestId("increment")

    await userEvent.click(button)

    expect(within(count).getByText("1")).toBeInTheDocument()
  })
})

Мы получили заветную формулу отображение + логика = компонент. Рассмотрим плюсы и минусы данного подхода.

Плюсы:

  • Улучшение читаемости и структурированности кода

  • Повышение гибкости и возможность повторного использования компонентов

  • Упрощение процесса тестирования

Минусы:

  • Усложнение процесса разработки

  • Увеличение кодовой базы

  • Усложнение процесса отладки

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

Ccылка на репозиторий с примерами

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


  1. devlev
    06.01.2024 18:34
    +1

    Вместо connect из react-redux можно использовать хуки useDispatch и useSeletector и результат будет тот же самый.


    1. yaroslav-emelyanov Автор
      06.01.2024 18:34

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


  1. 19Zb84
    06.01.2024 18:34
    +4

    А где тут улучшение читаемости ? Элементарная функция стала чем то монструозным.


    1. Pab10
      06.01.2024 18:34
      +2

      Логика была отделена от представления и может быть изменена и протестирована отдельно. Элементарная функция здесь для упрощения понимания материала. Так же не стоит забывать, что минусы так же перечислены в конце статьи и если они критичны, то не следует пользоваться предложеным способом.

      Статья полезна, ибо описывает один из способов разработки. Расширение кругозора и другой взгляд еще никому не вредили. Пользоваться или нет - каждый решит сам.


      1. 19Zb84
        06.01.2024 18:34
        +3

        С чего вы решили, что эти плюсы существуют не только у вас в голове ?

        Например.

        Пишем сервер на brainfack

        Плюсы

        Минимальное колличество символов, при написании кода

        Минусы

        Усложнение процесса разработки

        А потом отдаем этот сервер в поддержку третьему разработчику.

        Из жизни.

        Мне надо было на реакте за 2-3 дня по постановке раздел приложения собрать.

        С вашим подходом я боюсь у меня ушло бы на это раза в 3 4 больше времени.

        А поддержка такого кода в сложном приложении может стать адом.

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


        1. yaroslav-emelyanov Автор
          06.01.2024 18:34

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

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


          1. karevn
            06.01.2024 18:34

            Вы объявляете развязку представления и логики как преимущество, взяв за догму что это хорошо само по себе. Однако нет никаких доказательств что это всегда так (я лично из опыта сказал бы, что это в большинстве случаев совсем даже не так). В данном случае вы обменяли простоту и производительность (а HOC в реакте раздувают VirtualDOM и снижают производительность кода и отладки) на достаточно субъективную "читаемость". Можно было достигнуть почти того же, просто вынося всю логику в большой хук и замокав его для тестов рендеринга. Это намного проще читается и отлаживается, а заодно не создаёт соблазна вынести HOC в отдельный модуль и получить еще оверхед времени выполнения на резолвинг дополнительных модулей. В общем, ваше намерение понятно, но результат скорее отрицательный.


            1. yaroslav-emelyanov Автор
              06.01.2024 18:34

              За догму можно взять и то, что писать всё в рендер функции — это хорошо, чтобы было проще. Могут быть разные ситуации, где один подход будет выглядеть привлекательнее другого и наоборот. Но из моего опыта есть закономерность, что написанный код в одном файле после его декомпозиции становится более читабельным. Разделение на логические блоки кода создаёт более четкие границы, которые человеку проще воспринимать. Конечно, могут быть исключения.

              Да, результат скорее отрицательный, но для меня было интересно покапать в этом направлении.


              1. karevn
                06.01.2024 18:34
                +1

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

                Исходный вариант: есть компонент, который знает как отрендерить интерфейс и какой хук вызвать чтобы отработала бизнес-логика. Делаем себе пометочку, что вообще-то ему не очень стоило бы знать про эту бизнес-логику. Но с прагматической точки зрения проблемы от этого бывают крайне редко. Если вдруг это ПРОБЛЕМА, то можно передавать ссылку на хук как один из пропсов с дефолтным значением. Это попахивает странностями, но поможет динамически менять поведение в широких пределах. Всё, после этого компонент знает какая там бизнес-логика по умолчанию и позволит поменять её при большой необходимости.

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


                1. yaroslav-emelyanov Автор
                  06.01.2024 18:34

                  Полезный комментарий, благодарю.


  1. RuGrof
    06.01.2024 18:34

    На это нам намекает хук renderHook для тестирования хуков. Иначе говоря, наш хук привязан к "экосистеме" компонента.

    Это же просто метод для тестирования хуков, после "исправления" просто добавилась другая зависимость.
    Хуки для того и вводили, чтоб избавится от миксинов и хоков. А тут вместо обычного, понятно всем компонента используется connect чтоб соединить всё, так ещё и надо идти смотреть его доку, чтоб понять, что тут происходит.
    Из плюсов, читаемость вещь субъективная, процесс тестирование чет особо проще то и не стал это вот точно, хуки прекрасно повторно используются и без этих костылей.
    (Картинка, раньше было лучше)
    Опять таки, что делать в этом "магическом" подходе, с хуками которые нужны для UI.
    Или почему не выбрать redux-saga оно тут больше подходит в плане декомпозиции.
    Немного истории: https://legacy.reactjs.org/docs/hooks-intro.html#motivation


    1. yaroslav-emelyanov Автор
      06.01.2024 18:34

      Согласен читаемость вещь субъективная, поэтому некоторые плюсы могут стать минусами для другого разработчика. Тут хотелось сделать акцент на возможности декомпозиции, а инструмент вторичен (+ redux-saga менее популярный вариант). Спасибо за ссылку


      1. karevn
        06.01.2024 18:34

        Приведите, пожалуйста, реальный пример использования такой возможности декомпозиции, которую нельзя сделать через только хуки


        1. yaroslav-emelyanov Автор
          06.01.2024 18:34

          По моим примерам из статьи видно, что сделать можно всё через хуки. Я хотел добиться результата, где в мою логику не протекает что-то связанное с render циклом компонента. Это сделает связь между представлением (отображением) и логикой менее сильной, что не может сделать хук.


          1. karevn
            06.01.2024 18:34

            Вместо примера вы ответили абстрактными рассуждениями, а я его не просто так попросил


  1. sovaz1997
    06.01.2024 18:34
    +2

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

    И в чем же проблема оставить один хук и один компонент, который использует этот хук? Если у вас поменяется ViewModel и ее внешний интерфейс, вы в любом случае будете делать какие-то изменения в отображении/передаче параметров. Сейчас же у вас вместо простейшего решения из компонента и хука - слайс redux-а, компонент отображения, компонент, который занимается связью redux-слайса и отображения. Во что это превратится, если вы напишите что-то более серьезное, чем counter, думаю, говорить не нужно.

    Я не вижу проблемы. Можно сделать 2 юнита - компонент (отображение) и хук (его view-model). То есть то же самое, что вы предлагаете, но намного проще. Зачем redux запихивать, в чём его преимущество? Да и в принципе, для решения конкретно этой задачи и в подавляющем большинстве задач хватит хуков реакта.


    1. yaroslav-emelyanov Автор
      06.01.2024 18:34

      Проблема в том, что я бы хотел видеть чистом виде вёрстку и в чистом виде логику без sideeffects от компонента. Связь конечно будет между ViewModel и внешним интерфейсом, но это не значит, что стоит всё писать в одном месте, наращивая её. Не могу не согласится, что использование redux выглядит чрезмерным, но именно он помог сделать такого рода деление (в данном случае это его единственное преимущество).


      1. sovaz1997
        06.01.2024 18:34

        Почему в одном месте-то?
        2 файла, один для логики, другой для JSX.
        Что тогда вы брали инфу из пропсов, что сейчас вы берете инфу из хука в одной строке. Только с хуком у вас нет дополнительного компонента-обертки и redux-а. Всё намного проще и также тестируется. Просто мокаете не пропсы, а хук. И всё. А хук отдельно тестируете. Если это действительно необходимо, потому что всё покрывать тестами тоже плохо (ИМХО).


        1. yaroslav-emelyanov Автор
          06.01.2024 18:34

          Может, я не корректно выразился и ввёл вас в заблуждение. То, что вы предлагаете — безусловно работает и является хорошим решением, которое я описал в рамках компонента `Bloc`. Но моя идея в том, чтобы убрать из логики что-то связанное с жизненным циклом компонента. Поэтому я не стал останавливаться на этом варианте, но и не обошёл его стороной.


          1. sovaz1997
            06.01.2024 18:34

            А зачем это нужно? В чем преимущество будет отойти от жизненного цикла компонента?


            1. yaroslav-emelyanov Автор
              06.01.2024 18:34

              Будет меньше факторов влияющих на работу логики. Написание хуков накладывает некоторые ограничения. Навскидку

              • Вы не можете менять порядок хуков между перерисовками

              • Где то требуется мемоизация, так как это может сказаться на перерисовке

              • При тестировании надо вызвать перерисовку хука через утилиту act

              • Хуки нельзя вызывать из классовых компонентов или в других местах кроме render функции

              Поэтому я искал способ избавится от примеси чего то компонентного в логике, что сделает её более чистой, простой и более переиспользуемой.


  1. Alesh
    06.01.2024 18:34
    +1

    Мне одному показалось, что статья на тему "Как сделать из мухи слона"? Хотя может и заблуждаюсь, до конца дочитать не осилил, возможно в конце раскрыли замысел)


    1. strokoff
      06.01.2024 18:34
      +4

      Не одному) раздуть так каунтер это надо уметь.


      1. yaroslav-emelyanov Автор
        06.01.2024 18:34

        Что правда, то правда ????


  1. Dron007
    06.01.2024 18:34

    Эмм, подождите-ка, у вас в слайсе name: "count". Это значит, что в сторе данные будут храниться по ключу "count", то есть у вас может быть всего один такой элемент. Это именно то, что вы ожидаете? Он вроде универсальный на вид, хотя понимаю, что это просто пример. Обычно в стор кидают данные общие для страницы - состояние футера, там, или свёрнутости панелей.

    Я понимаю ваше стремление отделить логику от всего, включая хуки и вообще неприятие хуков. Сам долго упирался, но вариантов особо нет. Возможно, через несколько лет произойдёт очередное переосмысление, а-ля "ООП это сложно, давайте откажемся от классов" и появится что-то другое. Надеюсь, этим уже займётся ИИ. Но и сейчас есть варианты получше.

    Во-первых, зачем вам connect()? Он считается устаревающим:

    connect still works and is supported in React-Redux 8.x. However, we recommend using the hooks API as the default.

    Как выше отметил @devlev, вы можете просто подключать слайс и использовать

    const dispatch = useDispatch()
    ...
    dispatch(increment())

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

    Это всё если вам надо хранить значение в общем сторе. Если же компонент универсальный, из Design System, date picker какой-нибудь со своим состоянием, то к стору его точно привязывать не стоит. И тут если хочется разделять логику и отображение, вполне разумно остановиться на уровне разделения типа вашего Bloc с хуками и Display c отображением. Причём не уверен, что нужно хук setCount выделять отдельно просто ради модульности, только если он самодостаточен и используется в разных компонентах. А можно и рендерер не выделять.

    Я так понимаю, весь сыр-бор из-за тестов. Тут пойдёт субъективное. А надо ли для всего этого писать юнит-тесты? Во многом это будет тестирование Реакта и простейших действий, чуть ли не арифметики. Я сталкивался с тем, что тесты аж никак не выявляют ошибки, потому что ошибочное значение на автомате оказывается и в ожидаемом и в генерируемом значении. Тесты это очень утомительно и часто всё делается довольно бездумно, на автомате. Эффективность таких тестов сомнительна. По-моему лучше это делать просто наглядными примерами где-то в Сторибуке, ну, там и автоматически можно проверять функционал, но я с этим не разбирался пока. Мне кажется так ошибки выявлять намного реальнее, пусть даже не автоматически. Конечно при рефакторинге придётся визуально оценивать не сломалось ли что-то и пробегаться по примерам. Можно добавить интеграционные тесты на Cypress каком-нибудь. Я вот честно не сталкивался практически с тем, чтобы появлялись ошибки, которые бы обнаруживались такими низкоуровневыми тестами, а труда на их поддержание надо очень много. Только для действительно сложных компонентов, обработчик JSON схем я прописал нечто типа юнит-теста прямо в Сторибуке, так было проще его писать. Но, повторюсь, это субъективно и вашему проекту может не подходить.


    1. yaroslav-emelyanov Автор
      06.01.2024 18:34

      Да, я понимаю, что проще использовать хук. Но задумка была пощупать другой подход.
      По поводу селекторов, надо посмотреть как это пишется в slice. Но написание селектора - это вторично по отношению к теме.

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

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


  1. tema1411
    06.01.2024 18:34

    Надеюсь, я никогда не попаду на проект, после вас))


    1. yaroslav-emelyanov Автор
      06.01.2024 18:34
      +1

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


  1. catile
    06.01.2024 18:34

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


    1. yaroslav-emelyanov Автор
      06.01.2024 18:34

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


  1. xadd
    06.01.2024 18:34

    Мы в одном проекте применяли разделение View/ViewModel через создание хука useViewModel, который клался рядом с компонентом (component.tsx + component.vm.ts). Внутри которого использовались useSelector, useDispatch и другие хуки, и с возвратом текущего стейта и методов для самого компонента.


  1. mcpander
    06.01.2024 18:34

    Мы на проекте делали ровно обратное, с редакса с сагами, хоками, классами и тд переписали всё на рекойл и хуки, всё стало читабельнее, масштабируемее и кода раза в 2 меньше. Смысл пробовать подходы, от которых разработчики фреймворка отказались некоторое время назад?