Часть 6. Композиция приложения без хуков

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

Проблемы с хуками

Этот пост, наверное, будет самым субъективным из всей серии.

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

Высокая «заразность» и ограничения

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

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

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

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

Вендор-лок и инструменты

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

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

Неявные зависимости и текущие абстракции

Хуки поощряют склеивание данных и поведения вместе. Композиция хуков превращается в композицию сайд-эффектов, а это, например, одна из главных проблем того же ООП.

Скрытые взаимосвязи и влияние эффектов друг на друга сложно уложить в голове, из-за чего контролировать поведение программы становится сложнее.

Детали реализации хуков зачастую абстрагированы избыточно или наоборот недостаточно. Один хук может содержать функциональность из разных уровней абстракции, что заставляет «мысленно прыгать» между разными уровнями при чтении. Это повышает когнитивную нагрузку и затуманивает взаимодействие частей приложения.

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

const useUser = () => {
	const { data, isLoading } = useSWR(['/users', id], fetchUser);
	const role = useRoles(data);
	const session = useStore((s) => s.session);
	return { ...data, session, role };
};

Нам потребуется замокать fetch (или useSWR), настроить провайдер для стора, проверить, из чего состоит useRoles, чтобы при необходимости замокать его или его зависимости.

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

В итоге в голове приходится держать код самого хука, но и кучу других аспектов:

Скрытая сложность хуков может быть слишком высокой, а натуральных ограничителей для неё нет
Скрытая сложность хуков может быть слишком высокой, а натуральных ограничителей для неё нет

Запутанная ментальная модель

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

Они, вроде, похожи на функции, но ведут себя по-другому. Условия ререндера усложняют представление о том, как работает перерисовка компонента. Сама концепция хуков, вроде, устоявшаяся, но детали и правила могут меняться кардинально от версии к версии.

Это опять же снижает доверие к стабильности API и усложняет обучение.

Дисклеймер

Всё это не значит, что на хуках невозможно написать хороший и грамотный код. Возможно, конечно.

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

Сейчас для меня хук — это способ композиции разной функциональности. Я думаю о них, как об «инжекторах» сервисов, функций, данных, которые запускают перерисовку компонентов. Если функциональность не связана напрямую с UI-состоянием или перерисовкой компонентов, то я сперва подумаю, могу ли написать её без применения хуков.

Композиция без хуков

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

Сервис запросов к API хуки не использует, поэтому его мы оставим без изменений, а вот стор немного изменим. Вместо контекста мы воспользуемся библиотекой Zustand — это стейт-менеджер, который чем-то напоминает Redux, но попроще и не требует провайдеров.

Сервис хранилища данных

После установки Zustand в проект, мы можем описать с его помощью базовую реализацию стора:

// infrastructure/store.ts

export const converter = createStore<Converter>(() => ({
	// ...Дефолтные значения модели.
}));

Далее опишем композицию — то есть, как этот сервис будет реализовывать порты приложения, объявленные ранее:

// infrastructure/store.composition.ts

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

export const readConverter: ReadConverter = converter.getState;
export const saveConverter: SaveConverter = converter.setState;

// Входные порты реализуем мимо ядра,
// так как в селекторах данных нет доменной логики:

export const useBaseValue: SelectBaseValue = () => useStore(converter, (vm) => vm.baseValue);
export const useQuoteCode: SelectQuoteCode = () => useStore(converter, (vm) => vm.quoteCode);
export const useQuoteValue: SelectQuoteValue = () => useStore(converter, (vm) => vm.quoteValue);

Селекторы данных мы оставим без изменений. Это как раз те самые «реактивные данные», которые должны перерисовывать UI, поэтому предоставлять их через хуки как раз имеет смысл.

Реализацию выходных портов с другой стороны будут использовать юзкейсы, которые мы реализуем в виде функций. Поэтому readConverter и saveConverter будут ссылками на функции чтения и записи, а не хуков.

Композиция юзкейсов

Обновим композицию юзкейсов, чтобы они использовали функции readConverter и saveConverter напрямую:

// core/updateBaseValue.composition

// ...
import { readConverter, saveConverter } from '../../infrastructure/store';

export const useUpdateBaseValue: Provider<UpdateBaseValue> = () => {
	return useCallback(
		(value) => updateBaseValue(value, { readConverter, saveConverter }),
		[readConverter, saveConverter]
	);
};

Так как импортируемые функции не изменят своих ссылок, мы можем убрать useCallback:

// core/updateBaseValue.composition

import { readConverter, saveConverter } from '../../infrastructure/store';

export const useUpdateBaseValue: Provider<UpdateBaseValue> = () => {
	return (value) => updateBaseValue(value, { readConverter, saveConverter });
};

После чего станет понятно, что создавать в хуке лишнюю лямбду и подставлять зависимости в функцию updateBaseValue в рантайме больше не имеет смысла. Вместо этого мы воспользуемся запеканием зависимостей и подготовим весь юзкейс заранее.

Сейчас код функции updateBaseValue выглядит так:

// core/updateBaseValue

const stub = {} as Dependencies;

export const updateBaseValue: UpdateBaseValue = (
	rawValue,
	{ readConverter, saveConverter }: Dependencies = stub
) => {
	// ...
};

Мы «вывернем» её тип, чтобы функцию можно было частично применить, указав зависимости. Вытащим аргумент с зависимостями, поставим его на первое место и сделаем функцию «каррированной»:

// core/updateBaseValue

export const createUpdateBaseValue =
	({ readConverter, saveConverter }: Dependencies): UpdateBaseValue =>
	(rawValue) => {
		// ...
	};

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

// core/updateBaseValue.composition

export const updateBaseValue: UpdateBaseValue = createUpdateBaseValue({
	readConverter,
	saveConverter
});

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

Композиция компонентов

Так как юзкейс теперь — это просто функция, компоненты могут использовать его напрямую:

// ui/BaseValueInput

type BaseValueInputDeps = {
	// Используем напрямую функцию:
	updateBaseValue: UpdateBaseValue;
	useBaseValue: SelectBaseValue;
};

// В самом компоненте уберём вызов хука `useUpdateBaseValue`
// и будем использовать напрямую переданную функцию.

Композиция компонента сама по себе изменится не сильно:

// ui/BaseValueInput.composition

// ...Импортируем функцию:
import { updateBaseValue } from '../../core/updateBaseValue';

export const BaseValueInput = () =>
	// ...И передаём её в регистрации:
	Component({ updateBaseValue, useBaseValue });

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

Композиция тестов

Так как мы не трогаем логику, в тестах нам достаточно обновить лишь подготовку стабов и моков:

// core/updateBaseValue.test

const readConverter = () => ({ ...converter });
const saveConverter = vi.fn();
const updateBaseValue = createUpdateBaseValue({
	readConverter,
	saveConverter
});

// ui/BaseValueInput.test

const updateBaseValue = vi.fn();
const useBaseValue = () => 42;
const dependencies = {
	updateBaseValue,
	useBaseValue
};

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

Обновление котировок

Те же самые операции мы можем проделать и с юзкейсом обновления котировок. Сперва «вывернем» функцию юзкейса:

// core/refreshRates

export const createRefreshRates =
	({ fetchRates, readConverter, saveConverter }: Dependencies): RefreshRates =>
	async () => {
		//...
	};

Затем частично применим её, передав в качестве зависимостей свеже созданные функции для работы со стором:

// core/refreshRates.composition

import { readConverter, saveConverter } from '../../infrastructure/store';
import { fetchRates } from '../../infrastructure/api';

export const refreshRates: RefreshRates = createRefreshRates({
	fetchRates,
	readConverter,
	saveConverter
});

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

Тогда мы можем переписать asCommand так, чтобы он превращал функцию юзкейса в хук, возвращающий интерфейс { result, execute }:

// shared/infrastructure/cqs

export const asCommand =
	<F extends AsyncFn>(command: F): Provider<Command<F>> =>
	() => {
		// ...

		const execute = async () => {
			// ...
		};

		return { result, execute };
	};

Компонент в этом случае продолжит зависеть от хука:

// ui/RefreshRates

type RefreshRatesProps = {
	useRefreshRates: Provider<Command<RefreshRates>>;
};

…Но при композиции мы можем скармливать компоненту обычную функцию:

// ui/RefreshRates.composition

import { refreshRates } from '../../core/refreshRates';
import { asCommand } from '~/shared/infrastructure/cqs';

export const RefreshRates = () => Component({ useRefreshRates: asCommand(refreshRates) });

Другие инструменты

В этом примере в качестве стейт-менеджера мы выбрали Zustand, потому что он подходит для работы с объектами, где могут быть связаны несколько полей. В других приложениях нам могли бы потребоваться другие инструменты, типа Jotai или MobX.

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

В следующий раз

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

Ссылки

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

Реакт, хуки, компоненты

Работа с состоянием

Абстракция и декомпозиция

Инструменты для работы с состоянием

Прочее

Другие части серии