Pull to refresh

Как я пытался писать функциональные компоненты без хуков на react

Level of difficultyEasy
Reading time6 min
Views8.9K

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

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

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ылка на репозиторий с примерами

Tags:
Hubs:
Total votes 14: ↑9 and ↓5+4
Comments37

Articles