12 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.
В примере ProfilePageState — только локальное UI-state страницы. Это не domain state и не product data cache. Доменное состояние описывается business-модулем, а concrete store/query hook передаётся его фабрике через adapter в compositions/business/{domain}.
В примере page composition владеет scope-контрактом страницы, но не экспортирует готовый ProfilePage, потому что layout и screen импортируют hooks из pages/profile. Дерево страницы собирается в отдельном entry-point composition module, который слой app только подключает.
Принципы
- Владение. Page-level store, provider и business composition принадлежат page composition module.
- Обычные сегменты. Provider, hooks, stores и types лежат в обычных сегментах модуля:
providers/,hooks/,stores/,types/. - Публичный контракт. Page composition экспортирует только безопасные hooks, provider и типы, которые нужны другим composition modules.
- Сборка снаружи business. Business-модули не используют page-level providers. Page composition вызывает builders из
compositions/business/{domain}и владеет lifecycle готового графа. - Без 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
├── ui/
│ ├── profile-error/
│ ├── profile-summary/
│ └── profile-summary-skeleton/
└── 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 { createAuthBusiness } from '@/compositions/business/auth'
import { createProfileBusiness } from '@/compositions/business/profile'
export const createProfileBusinessComposition = () => {
const authApi = createAuthBusiness()
const profileApi = createProfileBusiness({ authApi })
return { authApi, profileApi }
}
Page composition собирает нужный для страницы граф из per-domain builders. Реальные runtime-зависимости остаются в compositions/business/{domain}, а не внутри business.
Page composition не импортирует SDK, product storage, source hook или raw infra event для дополнительной настройки домена. Такое wiring принадлежит соответствующему integration module.
Provider страницы
Файл: compositions/pages/profile/providers/profile-page.provider.tsx.
'use client'
import { createContext, useEffect, useState, 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
}
const createProfilePageProviderValue = (): ProfilePageProviderValue => ({
store: createProfilePageStore(),
business: createProfileBusinessComposition(),
})
export const ProfilePageProvider = ({ children }: Props) => {
const [value] = useState(createProfilePageProviderValue)
useEffect(() => {
return value.business.authApi.startSessionInvalidationTracking()
}, [value.business.authApi])
return (
<ProfilePageContext.Provider value={value}>
{children}
</ProfilePageContext.Provider>
)
}
Context object остаётся технической деталью provider и не должен использоваться внешними модулями напрямую. Наружу экспортируются hooks доступа.
Lazy initializer может быть повторно вызван React Strict Mode в development. Поэтому store/business constructors не выполняют I/O и не запускают subscriptions. Domain-level lifecycle operations запускаются отдельно в effect и возвращают cleanup.
Hooks доступа
Файл: compositions/pages/profile/hooks/use-profile-page-store.hook.ts.
'use client'
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.
'use client'
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.
'use client'
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.
'use client'
import { useProfileBusinessComposition } from '@/compositions/pages/profile'
import { ProfileError } from './ui/profile-error'
import { ProfileSummary } from './ui/profile-summary'
import { ProfileSummarySkeleton } from './ui/profile-summary-skeleton'
export const ProfileScreen = () => {
const { profileApi } = useProfileBusinessComposition()
const currentProfile = profileApi.useCurrentProfile()
if (currentProfile.isLoading) {
return <ProfileSummarySkeleton />
}
if (currentProfile.error) {
return <ProfileError code={currentProfile.error.code} />
}
return currentProfile.data ? <ProfileSummary profile={currentProfile.data} /> : null
}
Screen получает готовые доменные API из page composition и не собирает граф фабрик самостоятельно. ProfileSummary — компонент screen composition, а не часть business/profile.
Публичный 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.
Готовое дерево собирай в отдельном entry-point composition module. Не смешивай в одном public API готовую page composition и hooks, которые импортируют её дочерние layout/screen modules: это может создать runtime-цикл.
Подключение в app
Entry composition связывает provider, layout и screen:
// compositions/entries/profile/profile.entry.tsx
'use client'
import { ProfilePageProvider } from '@/compositions/pages/profile'
import { ProfileMainLayout } from '@/compositions/layouts/profile-main'
import { ProfileScreen } from '@/compositions/screens/profile'
export const ProfileEntry = () => (
<ProfilePageProvider>
<ProfileMainLayout>
<ProfileScreen />
</ProfileMainLayout>
</ProfilePageProvider>
)
React Router config только подключает готовый entry:
import { ProfileEntry } from '@/compositions/entries/profile'
export const profileRoute = {
path: '/profile',
element: <ProfileEntry />,
}
Для Next App Router создай готовые layout/page entries в compositions, а framework files только подключают их.
// compositions/entries/profile/profile-layout.entry.tsx
'use client'
import { ProfilePageProvider } from '@/compositions/pages/profile'
import { ProfileMainLayout } from '@/compositions/layouts/profile-main'
import type { ReactNode } from 'react'
export const ProfileLayoutEntry = ({ children }: { children: ReactNode }) => {
return (
<ProfilePageProvider>
<ProfileMainLayout>{children}</ProfileMainLayout>
</ProfilePageProvider>
)
}
// compositions/entries/profile/profile-page.entry.tsx
'use client'
import { ProfileScreen } from '@/compositions/screens/profile'
export const ProfilePageEntry = () => <ProfileScreen />
// app/(profile)/layout.tsx
import { ProfileLayoutEntry } from '@/compositions/entries/profile'
export default ProfileLayoutEntry
// app/(profile)/page.tsx
import { ProfilePageEntry } from '@/compositions/entries/profile'
export default ProfilePageEntry
app размещает готовые entry composition modules по правилам фреймворка, но не реализует product tree внутри себя.