Files
docs/projects/slm-design/canons/examples/react/composition-provider.md
S.Gromov 89cc873c19
All checks were successful
CI/CD Pipeline / build (push) Successful in 44s
CI/CD Pipeline / docker (push) Successful in 1m17s
CI/CD Pipeline / deploy (push) Successful in 8s
docs: обновить архитектуру SLM compositions
- обновлена модель слоёв на app → compositions → business → infra → ui → shared
- добавлены правила composition modules и providers-сегмента
- обновлены правила монорепозитория для слоя compositions
- переписаны React-примеры под page-level композицию
- добавлен пример вариантов структуры compositions
2026-05-26 23:46:11 +03:00

10 KiB
Raw Blame History

title, description
title description
Композиция через Provider Пример page-level Provider для composition modules в React-проекте

Композиция через Provider

Раздел показывает, как page composition может владеть provider, store и business composition, которые нужны layout, screen и другим composition modules.

Идея

Page composition хранит состояние и композицию бизнес-доменов на уровне страницы. Layout и screen не импортируют друг друга: они получают доступ к page-level данным через публичный API page composition.

В примере page composition владеет scope-контрактом страницы, но не экспортирует готовый ProfilePage, потому что layout и screen импортируют hooks из pages/profile. Дерево страницы собирается в app или в отдельном entry-point composition module.

Принципы

  1. Владение. Page-level store, provider и business composition принадлежат page composition module.
  2. Обычные сегменты. Provider, hooks, stores и types лежат в обычных сегментах модуля: providers/, hooks/, stores/, types/.
  3. Публичный контракт. Page composition экспортирует только безопасные hooks, provider и типы, которые нужны другим composition modules или app.
  4. Сборка снаружи business. Business-модули не используют page-level providers. Cross-domain зависимости передаются только через аргументы фабрики.
  5. Без deep imports. Layout и screen импортируют hooks только из public API page composition.

Структура модулей

compositions/pages/profile/
├── profile-business-composition.ts
├── providers/
│   └── profile-page.provider.tsx
├── hooks/
│   ├── use-profile-page-store.hook.ts
│   └── use-profile-business-composition.hook.ts
├── stores/
│   └── profile-page.store.ts
├── types/
│   └── profile-page-state.type.ts
└── index.ts

compositions/layouts/profile-main/
├── profile-main.layout.tsx
└── index.ts

compositions/screens/profile/
├── profile.screen.tsx
└── index.ts

Тип состояния страницы

Файл: compositions/pages/profile/types/profile-page-state.type.ts.

export type ProfilePageState = {
  title: string
  isSidebarOpen: boolean
  setSidebarOpen: (value: boolean) => void
}

Store страницы

Файл: compositions/pages/profile/stores/profile-page.store.ts.

import { createStore } from 'zustand/vanilla'
import type { ProfilePageState } from '../types/profile-page-state.type'

export const createProfilePageStore = () =>
  createStore<ProfilePageState>((set) => ({
    title: 'Profile',
    isSidebarOpen: false,
    setSidebarOpen: (value) => set({ isSidebarOpen: value }),
  }))

createProfilePageStore не экспортируется через public API модуля. Это внутренняя деталь создания состояния.

Business composition страницы

Файл: compositions/pages/profile/profile-business-composition.ts.

import { authFactory } from '@/business/auth'
import { profileFactory } from '@/business/profile'

export const createProfileBusinessComposition = () => {
  const auth = authFactory()
  const profile = profileFactory({ auth })

  return { auth, profile }
}

Business composition собирается на слое compositions, а не внутри business-модулей.

Provider страницы

Файл: compositions/pages/profile/providers/profile-page.provider.tsx.

import { createContext, useRef, type ReactNode } from 'react'
import type { StoreApi } from 'zustand/vanilla'
import { createProfileBusinessComposition } from '../profile-business-composition'
import { createProfilePageStore } from '../stores/profile-page.store'
import type { ProfilePageState } from '../types/profile-page-state.type'

type ProfileBusinessComposition = ReturnType<typeof createProfileBusinessComposition>

type ProfilePageProviderValue = {
  store: StoreApi<ProfilePageState>
  business: ProfileBusinessComposition
}

export const ProfilePageContext = createContext<ProfilePageProviderValue | null>(null)

type Props = {
  children: ReactNode
}

export const ProfilePageProvider = ({ children }: Props) => {
  const valueRef = useRef<ProfilePageProviderValue | null>(null)

  if (!valueRef.current) {
    valueRef.current = {
      store: createProfilePageStore(),
      business: createProfileBusinessComposition(),
    }
  }

  return (
    <ProfilePageContext.Provider value={valueRef.current}>
      {children}
    </ProfilePageContext.Provider>
  )
}

Context object остаётся технической деталью provider и не должен использоваться внешними модулями напрямую. Наружу экспортируются hooks доступа.

Hooks доступа

Файл: compositions/pages/profile/hooks/use-profile-page-store.hook.ts.

import { useContext } from 'react'
import { useStore } from 'zustand'
import { ProfilePageContext } from '../providers/profile-page.provider'
import type { ProfilePageState } from '../types/profile-page-state.type'

export const useProfilePageStore = <T,>(selector: (state: ProfilePageState) => T) => {
  const ctx = useContext(ProfilePageContext)

  if (!ctx) {
    throw new Error('useProfilePageStore must be used within ProfilePageProvider')
  }

  return useStore(ctx.store, selector)
}

Файл: compositions/pages/profile/hooks/use-profile-business-composition.hook.ts.

import { useContext } from 'react'
import { ProfilePageContext } from '../providers/profile-page.provider'

export const useProfileBusinessComposition = () => {
  const ctx = useContext(ProfilePageContext)

  if (!ctx) {
    throw new Error('useProfileBusinessComposition must be used within ProfilePageProvider')
  }

  return ctx.business
}

Layout использует page-level store

Файл: compositions/layouts/profile-main/profile-main.layout.tsx.

import type { ReactNode } from 'react'
import { useProfilePageStore } from '@/compositions/pages/profile'

type Props = {
  children: ReactNode
}

export const ProfileMainLayout = ({ children }: Props) => {
  const title = useProfilePageStore((state) => state.title)
  const isSidebarOpen = useProfilePageStore((state) => state.isSidebarOpen)

  return (
    <div data-sidebar-open={isSidebarOpen}>
      <header>{title}</header>
      <main>{children}</main>
    </div>
  )
}

Layout импортирует hook из public API page composition. Он не импортирует screen и не лезет во внутренние файлы pages/profile.

Screen использует business composition

Файл: compositions/screens/profile/profile.screen.tsx.

import { useProfileBusinessComposition } from '@/compositions/pages/profile'

export const ProfileScreen = () => {
  const { profile } = useProfileBusinessComposition()
  const { useCurrentProfile, ProfileCard } = profile
  const currentProfile = useCurrentProfile()

  return <ProfileCard profile={currentProfile} />
}

Screen получает готовые доменные API из page composition и не собирает граф фабрик самостоятельно.

Публичный API page composition

Файл: compositions/pages/profile/index.ts.

export { ProfilePageProvider } from './providers/profile-page.provider'
export { useProfilePageStore } from './hooks/use-profile-page-store.hook'
export { useProfileBusinessComposition } from './hooks/use-profile-business-composition.hook'

export type { ProfilePageState } from './types/profile-page-state.type'

Внутренние createProfilePageStore, createProfileBusinessComposition и ProfilePageContext не экспортируются через public API.

Если нужен готовый ProfilePage, его лучше собрать в отдельном entry-point composition module или прямо в роутере. Не смешивайте в одном public API и готовую page composition, и hooks, которые импортируют её дочерние layout/screen modules: это может создать runtime-цикл.

Подключение в app

В React Router можно собрать дерево прямо в route config:

import { ProfilePageProvider } from '@/compositions/pages/profile'
import { ProfileMainLayout } from '@/compositions/layouts/profile-main'
import { ProfileScreen } from '@/compositions/screens/profile'

export const profileRoute = {
  path: '/profile',
  element: (
    <ProfilePageProvider>
      <ProfileMainLayout>
        <ProfileScreen />
      </ProfileMainLayout>
    </ProfilePageProvider>
  ),
}

В Next App Router композиция может быть физически разложена по файлам app, но реализация остаётся в compositions.

// app/(profile)/layout.tsx
import { ProfilePageProvider } from '@/compositions/pages/profile'
import { ProfileMainLayout } from '@/compositions/layouts/profile-main'

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <ProfilePageProvider>
      <ProfileMainLayout>{children}</ProfileMainLayout>
    </ProfilePageProvider>
  )
}
// app/(profile)/page.tsx
import { ProfileScreen } from '@/compositions/screens/profile'

export default function Page() {
  return <ProfileScreen />
}

app размещает готовые composition modules по правилам фреймворка, но не реализует их внутри себя.