Задача: компоненту необходимо получить сторонние данные, которые он не может получить через пропсы.
Проблема: разные источники данных могут иметь разные 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>);
});
Последствия:
- Мок для разработки и тестирования. Я создаю хук и сразу возвращаю из него фейковые данные, которые дают возможность, например, разрабатывать без готового бекенда.
- Скрывает все нужные детали доступа к сторонним данным от компонента, который знает только про сам домен
Задача: компонент реализует контрол с интерактивность и должен отобразить процесс и результат выполнения функции, связанной с интерактивностью. Например, это может быть кнопка удаления (с запросом на сервер) или даже просто отправка формы.
Проблема: выполнение некоторой асинхронной функции сопровождается изменением состояния в компоненте (статус, сохранение результата или ошибки), что создает императивную логику, которую крайне сложно покрыть тестами. Кроме того, если необходимо переместить вызов функции родителю, появляется дополнительная рутина по переносу соответствующих кусков состояния, что добавляет хрупкости и требует больше усилий.
// пример управления асинхронной функцией и состоянием компонента
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 состояния, потому что его можно проверить без понятия времени в выполнении теста.
-
Переносить работу с асинхронной функцией (например родителю) становится проще, потому что все необходимые состояния спрятаны в одном месте.
Почему вы решили использовать useCallback вместо useMemo в useAction?