- обновлена модель слоёв на app → compositions → business → infra → ui → shared - добавлены правила composition modules и providers-сегмента - обновлены правила монорепозитория для слоя compositions - переписаны React-примеры под page-level композицию - добавлен пример вариантов структуры compositions
287 lines
10 KiB
Markdown
287 lines
10 KiB
Markdown
---
|
||
title: Композиция через Provider
|
||
description: Пример 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.
|
||
|
||
## Структура модулей
|
||
|
||
```text
|
||
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`.
|
||
|
||
```ts
|
||
export type ProfilePageState = {
|
||
title: string
|
||
isSidebarOpen: boolean
|
||
setSidebarOpen: (value: boolean) => void
|
||
}
|
||
```
|
||
|
||
## Store страницы
|
||
|
||
Файл: `compositions/pages/profile/stores/profile-page.store.ts`.
|
||
|
||
```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`.
|
||
|
||
```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`.
|
||
|
||
```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`.
|
||
|
||
```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`.
|
||
|
||
```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`.
|
||
|
||
```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`.
|
||
|
||
```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`.
|
||
|
||
```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:
|
||
|
||
```tsx
|
||
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`.
|
||
|
||
```tsx
|
||
// 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>
|
||
)
|
||
}
|
||
```
|
||
|
||
```tsx
|
||
// app/(profile)/page.tsx
|
||
import { ProfileScreen } from '@/compositions/screens/profile'
|
||
|
||
export default function Page() {
|
||
return <ProfileScreen />
|
||
}
|
||
```
|
||
|
||
`app` размещает готовые composition modules по правилам фреймворка, но не реализует их внутри себя.
|