Перевод статьи подготовлен в преддверии старта курса «React.js Developer».





Чем полезны хуки?


Прежде чем я расскажу, в чем и почему разочаровался, я хочу официально заявить, что, вообще-то, я фанат хуков.

Я часто слышу, что хуки создали для замены классовых компонентов. К сожалению, пост «Введение в хуки», опубликованный на официальном сайте React, рекламирует это нововведение, прямо скажем, неудачно:

Хуки — нововведение в React 16.8, которое позволяет использовать состояние и другие возможности React без написания классов.

Посыл, который я здесь вижу, звучит примерно так: «Классы — это не круто!». Маловато, чтобы мотивировать на использование хуков. На мой взгляд, хуки позволяют решать вопросы сквозной функциональности более элегантно, чем прежние подходы: миксины, компоненты высшего порядка и рендер-пропсы.

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

А что не так с классовыми компонентами?


Есть какая-то непостижимая красота в stateless-компоненте (т. е. компоненте без внутреннего состояния), который принимает на входе пропсы и возвращает React-элемент. Это чистая функция, то есть функция без побочных эффектов.

export const Heading: React.FC<HeadingProps> = ({ level, className, tabIndex, children, ...rest }) => {
  const Tag = `h${level}` as Taggable;

  return (
    <Tag className={cs(className)} {...rest} tabIndex={tabIndex}>
      {children}
    </Tag>
  );
};

К сожалению, отсутствие побочных эффектов ограничивает возможности использования компонентов без внутреннего состояния. В конце концов, без манипулирования состоянием не обойтись. В React это означает, что побочные эффекты добавляются к классовым компонентам, которые имеют состояние (stateful-компоненты). Их еще называют компонентами-контейнерами. Они выполняют побочные эффекты и передают пропсы чистым функциям — stateless-компонентам.

Хорошо известно о некоторых проблемах, связанных с основанными на классах событиями жизненного цикла. Многие недовольны тем, что приходится повторять логику в методах componentDidMount и componentDidUpdate.

async componentDidMount() {
  const response = await get(`/users`);
  this.setState({ users: response.data });
};

async componentDidUpdate(prevProps) {
  if (prevProps.resource !== this.props.resource) {
    const response = await get(`/users`);
    this.setState({ users: response.data });
  }
};

Рано или поздно все разработчики сталкиваются с этой проблемой.

Этот код побочного эффекта можно выполнить в одном компоненте с помощью хука эффекта.

const UsersContainer: React.FC = () => {
  const [ users, setUsers ] = useState([]);
  const [ showDetails, setShowDetails ] = useState(false);

 const fetchUsers = async () => {
   const response = await get('/users');
   setUsers(response.data);
 };

 useEffect( () => {
    fetchUsers(users)
  }, [ users ]
 );

 // etc.

Хук useEffect заметно облегчает жизнь, но он лишает той чистой функции — stateless-компонента, — которой мы пользовались раньше. Это первое, что меня разочаровало.

Очередная парадигма JavaScript, которую нужно знать


Мне 49 лет, и я фанат React. После разработки приложения на ember с этим безумием в виде наблюдателей и вычисляемых свойств я всегда буду питать теплые чувства к однонаправленному потоку данных.

Проблема с хуком useEffect и ему подобными в том, что на всем ландшафте JavaScript он больше нигде не используется. Он необычный и вообще со странностями. Я вижу только один способ укротить его — использовать этот хук на практике и страдать. И никакие примеры со счетчиками не побудят меня самозабвенно кодить ночь напролет. Я фрилансер и пользуюсь не только React, но и другими библиотеками, и я уже устал следить за всеми этими нововведениями. Стоит только подумать, что нужно установить плагин eslint, который наставит меня на путь истинный, как эта новая парадигма начинает меня напрягать.

Массивы зависимостей — это ад


Хук useEffect может принимать необязательный второй аргумент, который называется массивом зависимостей и позволяет выполнять обратный вызов эффекта, когда это нужно вам. Чтобы определить наличие изменений, React сравнивает значения друг с другом методом Object.is. Если какие-то элементы изменились с момента последнего цикла рендеринга, то эффект будет применяться к новым значениям.

Сравнение отлично подходит для обработки примитивных типов данных. Но если один из элементов является объектом или массивом, могут возникнуть проблемы. Object.is сравнивает объекты и массивы по ссылке, и ничего с этим не сделаешь. Пользовательский алгоритм сравнения применить не удастся.

Проверка объектов по ссылке — это известный камень преткновения. Рассмотрим упрощенный вариант проблемы, с которой я недавно столкнулся.

const useFetch = (config: ApiOptions) => {
  const  [data, setData] = useState(null);

  useEffect(() => {
    const { url, skip, take } = config;
    const resource = `${url}?$skip=${skip}&take=${take}`;
    axios({ url: resource }).then(response => setData(response.data));
  }, [config]); // <-- will fetch on each render

  return data;
};

const App: React.FC = () => {
  const data = useFetch({ url: "/users", take: 10, skip: 0 });
  return <div>{data.map(d => <div>{d})}</div>;
};

В строке 14 в функцию useFetch при каждом рендере будет передаваться новый объект, если мы не сделаем так, чтобы каждый раз использовался один и тот же объект. В этом сценарии надо бы проверять поля объекта, а не ссылку на него.

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

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

Само существование таких плагинов, как use-deep-object-compare и use-memo-one, говорит о том, что проблема (или по крайней мере неразбериха) действительно есть.

React полагается на порядок вызова хуков


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

Но что если в данных есть ссылки на сайты с пагинацией, и мы хотим перезапустить эффект, когда пользователь щелкнет по ссылке? Вот простой пример использования useFetch:

const useFetch = (config: ApiOptions): [User[], boolean] => {
  const [data, setData] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const { skip, take } = config;

    api({ skip, take }).then(response => {
      setData(response);
      setLoading(false);
    });
  }, [config]);

  return [data, loading];
};

const App: React.FC = () => {
  const [currentPage, setCurrentPage] = useState<ApiOptions>({
    take: 10,
    skip: 0
  });

  const [users, loading] = useFetch(currentPage);

  if (loading) {
    return <div>loading....</div>;
  }

  return (
    <>
      {users.map((u: User) => (
        <div>{u.name}</div>
      ))}
      <ul>
        {[...Array(4).keys()].map((n: number) => (
          <li>
            <button onClick={() => console.log('что дальше?')}>{n + 1}</button>
          </li>
        ))}
      </ul>
    </>
  );
};

В строке 23 хук useFetch будет вызван один раз при первом рендере. В строках 35–38 мы рендерим кнопки пагинации. Но как мы бы вызвали хук useFetch из обработчика событий для этих кнопок?

В правилах хуков четко написано:

Не используйте хуки внутри циклов, условных операторов или вложенных функций.Вместо этого всегда используйте хуки только на верхнем уровне React-функций.

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

Вот так делать нельзя:

<button onClick={() => useFetch({ skip: n + 1 * 10, take: 10 })}>
  {n + 1}
</button>

Вызов хука useFetch из обработчика событий нарушает правила хуков, потому что меняется порядок их вызова при каждом рендере.

Возврат исполняемой функции из хука


Я знаком с двумя решениями этой проблемы. Они опираются на один и тот же подход, и мне нравятся оба. Плагин react-async-hook возвращает из хука функцию execute:

import { useAsyncCallback } from 'react-async-hook';

const AppButton = ({ onClick, children }) => {
  const asyncOnClick = useAsyncCallback(onClick);
  return (
    <button onClick={asyncOnClick.execute} disabled={asyncOnClick.loading}>
      {asyncOnClick.loading ? '...' : children}
    </button>
  );
};

const CreateTodoButton = () => (
  <AppButton
    onClick={async () => {
      await createTodoAPI('new todo text');
    }}
  >
    Create Todo
  </AppButton>
);

Вызов хука useAsyncCallback вернет объект с ожидаемыми свойствами «загрузка», «ошибка» и «результат», а также функцию execute, которую можно вызвать из обработчика событий.

React-hooks-async — это плагин с похожим подходом. В нем используется функция useAsyncTask.

Вот полный пример с упрощенной версией useAsyncTask:
?
const createTask = (func, forceUpdateRef) => {
  const task = {
    start: async (...args) => {
      task.loading = true;
      task.result = null;
      forceUpdateRef.current(func);
      try {
        task.result = await func(...args);
      } catch (e) {
        task.error = e;
      }
      task.loading = false;
      forceUpdateRef.current(func);
    },
    loading: false,
    result: null,
    error: undefined
  };
  return task;
};

export const useAsyncTask = (func) => {
  const forceUpdate = useForceUpdate();
  const forceUpdateRef = useRef(forceUpdate);
  const task = useMemo(() => createTask(func, forceUpdateRef), [func]);

  useEffect(() => {
    forceUpdateRef.current = f => {
      if (f === func) {
        forceUpdate({});
      }
    };
    const cleanup = () => {
      forceUpdateRef.current = () => null;
    };
    return cleanup;
  }, [func, forceUpdate]);

  return useMemo(
    () => ({
      start: task.start,
      loading: task.loading,
      error: task.error,
      result: task.result
    }),
    [task.start, task.loading, task.error, task.result]
  );
};

Функция createTask возвращает объект «задание» в следующем виде.

interface Task {
  start: (...args: any[]) => Promise<void>;
  loading: boolean;
  result: null;
  error: undefined;
}

У задания есть состояния загрузка, ошибка и результат, которые мы ожидаем. Но функция возвращает еще и функцию start, которую можно вызвать позже. Задание, созданное с помощью функции createTask, не влияет на обновление. Обновление запускается функциями forceUpdate и forceUpdateRef в useAsyncTask.

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

Но мы потеряли возможность вызвать хук при первом запуске функционального компонента. Хорошо, что плагин react-hooks-async содержит функцию useAsyncRun — это облегчает задачу:

export const useAsyncRun = (
  asyncTask: ReturnType<typeof useAsyncTask>,
  ...args: any[]
) => {
  const { start } = asyncTask;
  useEffect(() => {
    start(...args);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [asyncTask.start, ...args]);
  useEffect(() => {
    const cleanup = () => {
      // тут делаем сброс
    };
    return cleanup;
  });
};

Функция start будет выполняться при изменении любого из аргументов args. Теперь код с хуками выглядит так:

const App: React.FC = () => {
  const asyncTask = useFetch(initialPage);
  useAsyncRun(asyncTask);

  const { start, loading, result: users } = asyncTask;

  if (loading) {
    return <div>loading....</div>;
  }

  return (
    <>
      {(users || []).map((u: User) => (
        <div>{u.name}</div>
      ))}

      <ul>
        {[...Array(4).keys()].map((n: number) => (
          <li key={n}>
            <button onClick={() => start({ skip: 10 * n, take: 10 })}>
              {n + 1}
            </button>
          </li>
        ))}
      </ul>
    </>
  );
};

Согласно правилам хуков, мы используем хук useFetch в начале функционального компонента. Функция useAsyncRun вызывает API в самом начале, а функцию start мы используем в обработчике onClick для кнопок пагинации.

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

Контроль хуков в прикладных программах


В прикладных программах все должно работать так, как задумано. Если вы планируете отслеживать связанные с компонентами проблемы И взаимодействие пользователей с определенными компонентами, можете использовать LogRocket.



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

LogRocket записывает все действия и состояния из хранилища Redux. Это набор инструментов для вашего приложения, которые позволяют записывать запросы/ответы с заголовками и телами. Они записывают HTML и CSS на странице, обеспечивая попиксельное воспроизведение даже для самых сложных одностраничных приложений.

LogRocket предлагает современный подход к отладке React-приложений — попробуйте бесплатно.

Заключение


Думаю, что пример с useFetch лучше всего объясняет, почему я разочарован хуками.

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

Замыкания (вроде тех, что передаются в useEffect и useCallback) могут захватить старые версии пропсов и значения состояний. Это случается, например, когда во входном массиве по какой-то причине отсутствует одна из захваченных переменных — могут возникнуть сложности.

Устаревшее состояние, которое возникает после выполнения кода в замыкании, — одна из проблем, которую призван решить линтер хуков. На Stack Overflow накопилось много вопросов об устаревшем состоянии в хуке useEffect и подобных. Я обертывал функции в useCallback и крутил массивы зависимостей и так, и эдак, чтобы избавиться от проблемы с устаревшим состоянием или бесконечным повторением рендера. Иначе нельзя, но это слегка раздражает. Это реальная проблема, которую приходится решать, чтобы доказать, чего ты стоишь.

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

Надеюсь, я просто недопонял этот подход. Если это так, напишите об этом в комментариях.


Читать ещё: