- обновлена модель слоёв на app → compositions → business → infra → ui → shared - добавлены правила composition modules и providers-сегмента - обновлены правила монорепозитория для слоя compositions - переписаны React-примеры под page-level композицию - добавлен пример вариантов структуры compositions
10 KiB
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.
Принципы
- Владение. Page-level store, provider и business composition принадлежат page composition module.
- Обычные сегменты. Provider, hooks, stores и types лежат в обычных сегментах модуля:
providers/,hooks/,stores/,types/. - Публичный контракт. Page composition экспортирует только безопасные hooks, provider и типы, которые нужны другим composition modules или
app. - Сборка снаружи business. Business-модули не используют page-level providers. Cross-domain зависимости передаются только через аргументы фабрики.
- Без 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 по правилам фреймворка, но не реализует их внутри себя.