Когда я проходил онбординг в нескольких командах, я повидал три варианта использования сервиса:
Прямой импорт инстанса ↓
class MyService { myMethod(): Promise {...}}export const myService = new MyService()
С инверсией ↓
interface MyService { myMethod(): Promise}class MyServiceImpl implements MyService { myMethod(): Promise {...}}export const myService: MyService = new MyServiceImpl()
Реализация на статик методах ↓
export class MyService { static myMethod(): Promise {...}}
Все эти три способа очень похожи и приводят нас к одинаковым проблемам. В статье разберём их и поговорим о том, почему «дикие» глобальные синглтоны в React — это антипаттерн, и пошагово пройдём путь эволюции архитектуры от простых хуков до предсказуемого DI на React Context.
В чём главная боль глобальных синглтонов
Главный минус импортируемых синглтонов — их жизненный цикл полностью оторван от рантайма самого React‑приложения. Когда инстанс создаётся прямо в теле модуля (export const myService = new MyService()), он неявно рождается в глобальном скоупе в тот момент, когда браузер впервые читает и выполняет этот JS‑файл. То есть, момент его создания размыт и зависит исключительно от порядка импортов в кодовой базе.
Чтобы контролировать старт данных, разработчики добавляют метод init(). Да, найти его вызовы через поиск в IDE не составит труда, но сходу становится непонятно, где инициализацию проводить и когда стоит почистить данные. И вся эта логика, как правило, размазана на несколько компонентов.
Помимо этого, глобальные синглтоны приносят кучу проблем:
-
Жёсткая связь. Компоненты намертво привязаны к конкретному файлу инстанса. Замена реализации превращается в ад рефакторинга.
-
Невозможность дублирования UI. Нельзя отрендерить два компонента или формы рядышком. Они будут делить один инстанс синглтона на двоих, ломая данные друг другу.
-
Утечки памяти. Глобальные сторы живут вечно. Пользователь разлогинился, а в них болтаются его старые данные. Если забыли отписаться при размонтировании компонента, подписка останется в памяти навсегда.
-
Ад во время тестирования. Состояние синглтона сохраняется между тестами. Вам придётся вручную писать методы сброса состояния и вызывать их в
beforeEachдля каждого теста, иначе они начнут случайным образом падать из‑за влияния друг на друга. -
Слепые мутации. Любой файл в проекте может импортировать инстанс и изменить его состояние. Найти, какой именно компонент прямо сейчас сломал стейт, становится сложно.
Шаг 1: Прячем реализацию за хуком
Первое подсознательное желание разработчика, который столкнулся с минусами прямого импорта инстанса в UI — изолировать компоненты. Мы пишем кастомный хук, который скрывает под собой импорт синглтона:
import { myServiceImpl, MyService } from '@app/services';export const useMyService = (): MyService => { return myServiceImpl;};
Теперь UI‑компонент вызывает хук и не знает, откуда берётся сервис.
В чем плюс?
UI‑компоненты стали чище и больше не импортируют инстансы напрямую.
Главный плюс этого шага — мы зафиксировали сигнатуру (интерфейс) для UI. Компоненту теперь плевать, что происходит внутри useMyService(). Мы можем прямо сейчас полностью переписать внутренности этого хука, сделать инверсию и подменять реализации, а UI‑компоненты этого даже не заметят! Декларация const myService = useMyService() в компоненте останется абсолютно неизменной.
В чём минус?
Конкретно в этой базовой реализации под капотом мы по‑прежнему намертво привязаны к глобальному инстансу myService в файловой системе. От необходимости инициализировать состояние (тот самый .init()) мы никуда не делись. Но в текущем виде этот процесс скрыт внутри бизнес‑логики: абсолютно непонятно, в какой точке приложения и в какой момент времени сервис «прогревается». Нам всё ещё тяжело изолировать тесты, и мы по‑прежнему зажаты в тиски одного инстанса на всё приложение. Из‑за этого два независимых компонента на одном экране так и остались несбыточной мечтой.
Эволюция. Шаг 2: Управляем жизненным циклом через React Context
Чтобы исправить минусы «Шага 1» и дать приложению контролируемый жизненный цикл сервисов/сторов, мы переносим создание инстансов строго в одну централизованную точку, привязанную к React. Тем самым мы сделаем самопальное внедрение зависимостей на базе React Context. Инициализация сторов никуда не исчезает (вызов init()), но она наконец‑то встаёт на свои места.
Регистрируем корневые сервисы‑синглтоны
Создаем фабрику, которая будет инициировать наши общие сервисы строго в момент старта приложения, и складываем их в Map или обычный JS‑объект, где ключом будет сам класс или токен в виде строки или символа. Тут каждый волен сделать то, что он хочет.
// Объект с символами (или строками), который служит уникальными идентификаторами export const Services = { ApiService: Symbol.for('ApiService'), AuthService: Symbol.for('AuthService'),};
import React, { createContext, useContext, useState } from 'react';import { TYPES } from './di-types';import { AuthService } from './services/AuthService';import { ApiService } from './services/ApiService';// Контекст хранит карту: Токен (Symbol) -> Инстанс сервисаconst DIContext = createContext<Map | null>(null);export const DIProvider: React.FC = ({ children }) => { const [services] = useState(() => { const container = new Map(); const apiService = new ApiService(); const authService = new AuthService(apiService); // Регистрируем инстансы строго по их токенам! container.set(TYPES.ApiService, apiService); container.set(TYPES.AuthService, authService); return container; }); useEffect(() => { const apiService = services.get(TYPES.ApiService); const authService = services.get(TYPES.AuthService); apiService.init() authService.init() return () => { apiService.destroy() authService.destroy() }; }, [services]); return {children};};export function useRootServices() { const context = useContext(DIContext); if (!context) throw new Error('useRootServices должен использоваться внутри DIProvider'); return context;}
Прокачиваем доменные хуки
Теперь наши доменные хуки из «Шага 1» слегка переписываются, а компоненты — нет, потому что им не важна реализация.
Сценарий А: нужен общий глобальный стейт (Режим Singleton)
Хук просто забирает созданный инстанс из нашего центрального контекста.
// hooks/useAuthService.tsimport { useRootServices } from '@app/hooks';import { AuthService } from '@app/services';import { Services } from '@app/tokens';export function useAuthService(): AuthService { const services = useRootServices(); return services.get(Services.AuthService);}
Сценарий Б: нужен изолированный стейт под каждый компонент (Режим Transient)
А что если нам нужно отрендерить два компонента рядом, где у каждого будет своё состояние? Тогда мы меняем логику внутри хука сервиса, он сам создаёт свежий инстанс класса при монтировании через useState и гарантированно очищает ресурсы при его размонтировании через useEffect. Никаких утечек памяти в RxJS.
import { useState, useEffect } from 'react';import { ChatService } from '@app/services';import { isDestroyable } from '@app/utils'export function useChatService(): ChatService { const [instance] = useState(() => new ChatService()); useEffect(() => { instance.init() return () => { instance.destroy() } ; }, [instance]); return instance;}
Красота в UI‑компонентах
UI‑компоненты теперь стали декларативными и полностью независимыми от деталей реализации DI‑системы.
import { useAuthService } from '@app/hooks';import { useRxBind } from 'utils';export const UserProfile = () => { const authService = useAuthService(); const user = useRxBind(authService.user$); return Привет, {user.name}!;};
Что мы получили в итоге
Проделав этот путь эволюции, мы не избавились от сложности управления состоянием, однако наш код стал гораздо более прозрачным и контролируемым:
-
Явный рантайм вместо скрытого. Инициализация стейта никуда не исчезла, но она наконец‑то встала на свои места. Синглтоны гарантированно и явно рождаются в корневом провайдере при старте приложения, а транзиент‑сервисы — локально в хуках при монтировании компонентов. Объект всегда рождается сразу готовым к работе. Да, рутовые сервисы всё ещё живут долго, и за очисткой RxJS‑стримов (например, при логауте) нужно следить. Но теперь у нас есть одно централизованное место, куда можно повесить метод очистки стейта, вместо того чтобы вылавливать утечки по куче файлов.
-
Слабая связанность. Компоненты завязаны на хуки, а не на конкретные файлы инстансов в файловой системе. Если завтра нам понадобится заменить
AuthServiceнаAdvancedAuthService, мы сделаем это строго в одном файле. UI‑слой по всему проекту даже не шелохнется. -
Предсказуемость для тестов и дублирования. Теперь мы можем без костылей отрендерить два чата рядом на одном экране, просто вызвав изолированный хук, а в тестах — легко подсунуть фейковый контекст с моками.
Ручной DI через Context API и доменные хуки — это отличный шаг к прозрачности архитектуры. Конечно, если ваше приложение разрастётся до десятков сервисов, ручное прокидывание зависимостей в конструкторах внутри DIProvider начнёт превращаться в монотонный бойлерплейт. Но прелесть слоя наших кастомных хуков (useAuthService, useChatService) в том, что они полностью скрыли от UI детали реализации. И мы всё это сможем переписать на любой взрослый DI‑контейнер.
Но что самое прекрасное — благодаря первому шагу и скрытой реализации мы практически не затронем UI‑слой.
Источник: habr.com