Skip to content

Instantly share code, notes, and snippets.

@alexeyraspopov
Last active July 8, 2023 17:42
Show Gist options
  • Star 13 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save alexeyraspopov/75f467f2f8776b5591a8d1cbc9c15258 to your computer and use it in GitHub Desktop.
Save alexeyraspopov/75f467f2f8776b5591a8d1cbc9c15258 to your computer and use it in GitHub Desktop.
Небольшие полезные паттерны для React и хуков

Data Injection

Задача: компоненту необходимо получить сторонние данные, которые он не может получить через пропсы.

Проблема: разные источники данных могут иметь разные API, которые влекут за собой необходимость реализации дополнительных аспектов в рамках компонента: useState/useEffect, обработка loading state, доступ к асинхронным API, etc.

Решение: Каждый раз когда компоненту нужны сторонние данные, создавай отдельный модуль с кастомным хуком, предназначеным для этих данных.

// UserProfileResource.js
export function useUserProfile() {
  return { name: 'Ann', age: 23 };
}

Использование хука достаточно очевидное:

// UserProfile.js
import React from 'react';
import { useUserProfile } from './UserProfileResource';

export default function UserProfile() {
  let profile = useUserProfile();
  return <p>{profile.name}</p>;
}

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

// __tests__/UserProfile-test.js
jest.mock('../UserProfileResource');
import React from 'react';
import ShallowRenderer from 'react-test-renderer/shallow';
import UserProfile from '../UserProfile';
import { useUserProfile } from '../UserProfileResource';

test('profile rendering', () => {
  let renderer = new ShallowRenderer();

  useUserProfile.mockReturnValue({ name: 'Liza', age: 28 });
  renderer.render(<UserProfile />);

  let result = renderer.getRenderOutput();
  expect(result).toEqual(<p>Liza</p>);
});

Последствия:

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

Declarative Action Callback

Задача: компонент реализует контрол с интерактивность и должен отобразить процесс и результат выполнения функции, связанной с интерактивностью. Например, это может быть кнопка удаления (с запросом на сервер) или даже просто отправка формы.

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

// пример управления асинхронной функцией и состоянием компонента
function SubmitForm() {
  let [status, setStatus] = useState('Idle');
  let [result, setResult] = useState(null);
  let [error, setError] = useState(null);
  return (
    <button
      onClick={async () => {
        try {
          setStatus('Pending');
          let result = await submitForm();
          setStatus('Success');
          setResult(result);
        } catch (error) {
          setStatus('Failure');
          setError(error);
        }
      }}
    >
      Submit
    </button>
  );
}

Решение: написать хук, которые инкапсулирует порядок изменения состояний и сохранение результата. Этот хук будет принимать нужную асинхронную функцию как параметр и возвращать пару [response, performAction] (примерно как в useState()).

// useAction "lib" implementation
let idle = Object.freeze({ type: 'Idle' });
let pending = Object.freeze({ type: 'Pending' });

function useAction(fn) {
  let [response, setResponse] = useState(idle);

  let action = useCallback(
    (...args) => {
      setResponse(pending);
      return Promise.resolve(fn(...args))
        .then(result => {
          setResponse({ type: 'Success', result })
          return result;
        })
        .catch(error => {
          setResponse({ type: 'Failure', error })
          throw error;
        });
    },
    [fn],
  );

  return [response, action];
}

Такой хук нужно реализовать всего один раз, и позже можно использовать везде. Его можно применять напрямую в самом компоненте, или использовать паттерн выше для возможности создания моков.

// SubmitFormResource.js
export function useSubmitAction() {
  return useAction(submitForm);
}

async function submitForm() {
  // любой асинхронный код, например запрос на сервер
}
// SubmitForm.js
import React from 'react';
import { useSubmitAction } from './SubmitFormResource';

export default function SubmitForm() {
  let [response, performSubmit] = useSubmitAction();
  return (
    <section>
      <button disabled={response.type === 'Pending'} onClick={performSubmit}>
        Submit
      </button>
      {response.type === 'Success' ? (
        <p>Operation was successful. Response: {response.result}.</p>
      ) : null}
      {response.type === 'Failure' ? (
        <p>An error occured: {response.error.message}.</p>
      ) : null}
    </section>
  );
}

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

jest.mock('../SubmitFormResource');
import React from 'react';
import ShallowRenderer from 'react-test-renderer/shallow';
import SubmitForm from '../SubmitForm';
import { useSubmitAction } from '../SubmitFormResource';

test('form rendering', () => {
  let renderer = new ShallowRenderer();
  let cb = jest.fn();
  let error = { message: 'Mocked error' };

  useSubmitAction.mockReturnValue([{ type: 'Failure', error }, cb]);
  renderer.render(<SubmitForm />);

  let result = renderer.getRenderOutput();
  expect(result).toMatchInlineSnapshot(`
    <section>
      <button
        disabled={false}
        onClick={[MockFunction]}
      >
        Submit
      </button>
      <p>
        An error occured:
        Mocked error
        .
      </p>
    </section>
  `);
});

Последствия:

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

  • Переносить работу с асинхронной функцией (например родителю) становится проще, потому что все необходимые состояния спрятаны в одном месте.

@dartmax
Copy link

dartmax commented Apr 30, 2020

Почему вы решили использовать useCallback вместо useMemo в useAction?

@alexeyraspopov
Copy link
Author

Почему вы решили использовать useCallback вместо useMemo в useAction?

@dartmax, useCallback() это тот же useMemo(), только для функций, просто чтобы на одну стрелку меньше писать :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment