паттерны композиции в react для агентов [перевод]
вольный перевод статьи от Vercel Engineering "React Composition Patterns v1.0.0"
версия 1.0.0
январь 2026
примечание:
этот документ в первую очередь для llm и агентов, которые поддерживают, генерируют или рефакторят react-код с использованием композиции. людям он тоже может быть полезен, но ориентирован на автоматизацию и единообразие.
о чём речь
паттерны композиции для гибких и поддерживаемых react-компонентов. как не плодить булевы пропсы, используя составные компоненты, подъём состояния и композицию внутренностей. эти паттерны облегчают работу и людям, и ai-агентам по мере роста кодовой базы.
1. архитектура компонентов
влияние: высокое
фундаментальные паттерны для структурирования компонентов без разрастания пропсов.
1.1 не плоди булевы пропсы
влияние: критичное (предотвращает появление неподдерживаемых вариантов компонента)
не добавляй булевы пропсы типа isThread, isEditing, isDMThread для кастомизации поведения компонента. каждый новый булев пропс удваивает количество возможных состояний и создаёт не поддерживаемую со временем условную логику. вместо этого используй композицию.
❌ неправильно: булевы пропсы создают экспоненциальную сложность
function Composer({
onSubmit,
isThread,
channelId,
isDMThread,
dmId,
isEditing,
isForwarding,
}) {
return (
<form>
<Header />
<Input />
{isDMThread ? (
<AlsoSendToDMField id={dmId} />
) : isThread ? (
<AlsoSendToChannelField id={channelId} />
) : null}
{isEditing ? (
<EditActions />
) : isForwarding ? (
<ForwardActions />
) : (
<DefaultActions />
)}
<Footer onSubmit={onSubmit} />
</form>
);
}
✅ правильно: композиция убирает условные операторы
// обычный композер для канала
function ChannelComposer() {
return (
<Composer.Frame>
<Composer.Header />
<Composer.Input />
<Composer.Footer>
<Composer.Attachments />
<Composer.Formatting />
<Composer.Emojis />
<Composer.Submit />
</Composer.Footer>
</Composer.Frame>
);
}
// композер для треда — добавляет поле "также отправить в канал"
function ThreadComposer({ channelId }) {
return (
<Composer.Frame>
<Composer.Header />
<Composer.Input />
<AlsoSendToChannelField id={channelId} />
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
<Composer.Submit />
</Composer.Footer>
</Composer.Frame>
);
}
// композер для редактирования — другие кнопки в футере
function EditComposer() {
return (
<Composer.Frame>
<Composer.Input />
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
<Composer.CancelEdit />
<Composer.SaveEdit />
</Composer.Footer>
</Composer.Frame>
);
}
каждый вариант явно показывает, что он рендерит. можно шарить внутренности без единого монолитного родителя.
1.2 используй составные компоненты (compound components)
влияние: высокое (гибкая композиция без проброса пропсов)
строй сложные компоненты как составные с общим контекстом. каждый подкомпонент получает доступ к общему состоянию через контекст, а не через пропсы. потребитель сам собирает нужные ему части.
❌ неправильно: монолитный компонент с render-пропсами
function Composer({
renderHeader,
renderFooter,
renderActions,
showAttachments,
showFormatting,
showEmojis,
}) {
return (
<form>
{renderHeader?.()}
<Input />
{showAttachments && <Attachments />}
{renderFooter ? (
renderFooter()
) : (
<Footer>
{showFormatting && <Formatting />}
{showEmojis && <Emojis />}
{renderActions?.()}
</Footer>
)}
</form>
);
}
✅ правильно: составные компоненты с общим контекстом
const ComposerContext = createContext(null);
function ComposerProvider({ children, state, actions, meta }) {
return (
<ComposerContext.Provider value={{ state, actions, meta }}>
{children}
</ComposerContext.Provider>
);
}
function ComposerFrame({ children }) {
return <form>{children}</form>;
}
function ComposerInput() {
const {
state,
actions: { update },
meta: { inputRef },
} = use(ComposerContext);
return (
<TextInput
ref={inputRef}
value={state.input}
onChangeText={(text) => update((s) => ({ ...s, input: text }))}
/>
);
}
function ComposerSubmit() {
const {
actions: { submit },
} = use(ComposerContext);
return <Button onPress={submit}>отправить</Button>;
}
// экспортируем как составной компонент
const Composer = {
Provider: ComposerProvider,
Frame: ComposerFrame,
Input: ComposerInput,
Submit: ComposerSubmit,
Header: ComposerHeader,
Footer: ComposerFooter,
Attachments: ComposerAttachments,
Formatting: ComposerFormatting,
Emojis: ComposerEmojis,
};
использование:
<Composer.Provider state={state} actions={actions} meta={meta}>
<Composer.Frame>
<Composer.Header />
<Composer.Input />
<Composer.Footer>
<Composer.Formatting />
<Composer.Submit />
</Composer.Footer>
</Composer.Frame>
</Composer.Provider>
потребитель сам собирает ровно то, что ему нужно. никаких скрытых условностей. состояние, действия и мета внедряются через dependency injection родительским провайдером, что позволяет использовать одну и ту же структуру компонента в разных местах.
2. управление состоянием
влияние: среднее
паттерны для подъёма состояния и организации общего контекста.
2.1 отделяй управление состоянием от ui
влияние: среднее (позволяет менять реализацию состояния без изменения ui)
провайдер должен быть единственным местом, которое знает, как управляется состояние. ui-компоненты потребляют интерфейс контекста — им не важно, пришло состояние из useState, zustand или синхронизации с сервером.
❌ неправильно: ui привязан к реализации состояния
function ChannelComposer({ channelId }) {
// ui-компонент знает о глобальной реализации состояния
const state = useGlobalChannelState(channelId);
const { submit, updateInput } = useChannelSync(channelId);
return (
<Composer.Frame>
<Composer.Input
value={state.input}
onChange={(text) => updateInput(text)}
/>
<Composer.Submit onPress={() => submit()} />
</Composer.Frame>
);
}
✅ правильно: управление состоянием изолировано в провайдере
// провайдер управляет всеми деталями состояния
function ChannelProvider({ channelId, children }) {
const { state, update, submit } = useGlobalChannel(channelId);
const inputRef = useRef(null);
return (
<Composer.Provider
state={state}
actions={{ update, submit }}
meta={{ inputRef }}
>
{children}
</Composer.Provider>
);
}
// ui-компонент знает только об интерфейсе контекста
function ChannelComposer() {
return (
<Composer.Frame>
<Composer.Header />
<Composer.Input />
<Composer.Footer>
<Composer.Submit />
</Composer.Footer>
</Composer.Frame>
);
}
// использование
function Channel({ channelId }) {
return (
<ChannelProvider channelId={channelId}>
<ChannelComposer />
</ChannelProvider>
);
}
разные провайдеры — один и тот же ui:
// локальное состояние для эфемерных форм
function ForwardMessageProvider({ children }) {
const [state, setState] = useState(initialState);
const forwardMessage = useForwardMessage();
return (
<Composer.Provider
state={state}
actions={{ update: setState, submit: forwardMessage }}
>
{children}
</Composer.Provider>
);
}
// глобальное синхронизированное состояние для каналов
function ChannelProvider({ channelId, children }) {
const { state, update, submit } = useGlobalChannel(channelId);
return (
<Composer.Provider state={state} actions={{ update, submit }}>
{children}
</Composer.Provider>
);
}
один и тот же Composer.Input работает с обоими провайдерами, потому что зависит только от интерфейса контекста, а не от реализации.
2.2 определяй generic-интерфейсы для dependency injection
влияние: высокое (состояние, которое можно подставлять в разных сценариях)
определи generic-интерфейс для контекста компонента из трёх частей: state, actions и meta. этот интерфейс — контракт, который может реализовать любой провайдер. одни и те же ui-компоненты работают с совершенно разными реализациями состояния.
основной принцип: поднимай состояние, компонуй внутренности, делай состояние подставляемым (dependency injection).
❌ неправильно: ui привязан к конкретной реализации состояния
function ComposerInput() {
// жёсткая привязка к конкретному хуку
const { input, setInput } = useChannelComposerState();
return <TextInput value={input} onChangeText={setInput} />;
}
✅ правильно: generic-интерфейс позволяет dependency injection
// определяем ОБЩИЙ интерфейс, который может реализовать любой провайдер
interface ComposerState {
input: string
attachments: Attachment[]
isSubmitting: boolean
}
interface ComposerActions {
update: (updater: (state: ComposerState) => ComposerState) => void
submit: () => void
}
interface ComposerMeta {
inputRef: React.RefObject<TextInput>
}
interface ComposerContextValue {
state: ComposerState
actions: ComposerActions
meta: ComposerMeta
}
const ComposerContext = createContext<ComposerContextValue | null>(null)
ui-компоненты потребляют интерфейс, а не реализацию:
function ComposerInput() {
const {
state,
actions: { update },
meta,
} = use(ComposerContext);
// этот компонент работает с ЛЮБЫМ провайдером, реализующим интерфейс
return (
<TextInput
ref={meta.inputRef}
value={state.input}
onChangeText={(text) => update((s) => ({ ...s, input: text }))}
/>
);
}
разные провайдеры реализуют один интерфейс:
// провайдер A: локальное состояние для эфемерных форм
function ForwardMessageProvider({ children }) {
const [state, setState] = useState(initialState);
const inputRef = useRef(null);
const submit = useForwardMessage();
return (
<ComposerContext.Provider
value={{
state,
actions: { update: setState, submit },
meta: { inputRef },
}}
>
{children}
</ComposerContext.Provider>
);
}
// провайдер B: глобальное синхронизированное состояние для каналов
function ChannelProvider({ channelId, children }) {
const { state, update, submit } = useGlobalChannel(channelId);
const inputRef = useRef(null);
return (
<ComposerContext.Provider
value={{
state,
actions: { update, submit },
meta: { inputRef },
}}
>
{children}
</ComposerContext.Provider>
);
}
один и тот же составной ui работает с обоими:
// работает с ForwardMessageProvider (локальное состояние)
<ForwardMessageProvider>
<Composer.Frame>
<Composer.Input />
<Composer.Submit />
</Composer.Frame>
</ForwardMessageProvider>
// работает с ChannelProvider (глобальное синхронизированное состояние)
<ChannelProvider channelId="abc">
<Composer.Frame>
<Composer.Input />
<Composer.Submit />
</Composer.Frame>
</ChannelProvider>
кастомный ui вне компонента может доступ к состоянию и действиям:
function ForwardMessageDialog() {
return (
<ForwardMessageProvider>
<Dialog>
{/* сам композер */}
<Composer.Frame>
<Composer.Input placeholder="можете добавить сообщение" />
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
</Composer.Footer>
</Composer.Frame>
{/* кастомный ui ВНЕ композера, но ВНУТРИ провайдера */}
<MessagePreview />
{/* кнопки внизу диалога */}
<DialogActions>
<CancelButton />
<ForwardButton />
</DialogActions>
</Dialog>
</ForwardMessageProvider>
);
}
// эта кнопка живёт ВНЕ Composer.Frame, но всё равно может отправить форму!
function ForwardButton() {
const {
actions: { submit },
} = use(ComposerContext);
return <Button onPress={submit}>переслать</Button>;
}
// этот превью живёт ВНЕ Composer.Frame, но читает состояние композера!
function MessagePreview() {
const { state } = use(ComposerContext);
return <Preview message={state.input} attachments={state.attachments} />;
}
важна граница провайдера, а не визуальная вложенность. компонентам, которым нужно общее состояние, не обязательно находиться внутри Composer.Frame. им достаточно быть внутри провайдера.
ForwardButton и MessagePreview визуально не внутри блока композера, но всё равно могут достучаться до его состояния и действий. в этом и сила подъёма состояния в провайдеры.
ui — это переиспользуемые кусочки, которые ты компонуешь. состояние подставляется провайдером. меняешь провайдер — ui остаётся тем же.
2.3 поднимай состояние в провайдеры
влияние: высокое (состояние становится доступным за пределами компонента)
выноси управление состоянием в отдельные компоненты-провайдеры. это позволяет компонентам за пределами основного ui читать и менять состояние без проброса пропсов и неудобных ref-ов.
❌ неправильно: состояние заперто внутри компонента
function ForwardMessageComposer() {
const [state, setState] = useState(initialState);
const forwardMessage = useForwardMessage();
return (
<Composer.Frame>
<Composer.Input />
<Composer.Footer />
</Composer.Frame>
);
}
// проблема: как эта кнопка получит доступ к состоянию композера?
function ForwardMessageDialog() {
return (
<Dialog>
<ForwardMessageComposer />
<MessagePreview /> {/* нужен доступ к состоянию композера */}
<DialogActions>
<CancelButton />
<ForwardButton /> {/* нужно вызвать submit */}
</DialogActions>
</Dialog>
);
}
❌ неправильно: useEffect для синхронизации состояния
function ForwardMessageDialog() {
const [input, setInput] = useState("");
return (
<Dialog>
<ForwardMessageComposer onInputChange={setInput} />
<MessagePreview input={input} />
</Dialog>
);
}
function ForwardMessageComposer({ onInputChange }) {
const [state, setState] = useState(initialState);
useEffect(() => {
onInputChange(state.input); // синхронизация при каждом изменении 😬
}, [state.input]);
}
❌ неправильно: чтение состояния из ref при отправке
function ForwardMessageDialog() {
const stateRef = useRef(null);
return (
<Dialog>
<ForwardMessageComposer stateRef={stateRef} />
<ForwardButton onPress={() => submit(stateRef.current)} />
</Dialog>
);
}
✅ правильно: состояние поднято в провайдер
function ForwardMessageProvider({ children }) {
const [state, setState] = useState(initialState);
const forwardMessage = useForwardMessage();
const inputRef = useRef(null);
return (
<Composer.Provider
state={state}
actions={{ update: setState, submit: forwardMessage }}
meta={{ inputRef }}
>
{children}
</Composer.Provider>
);
}
function ForwardMessageDialog() {
return (
<ForwardMessageProvider>
<Dialog>
<ForwardMessageComposer />
<MessagePreview />{" "}
{/* кастомные компоненты могут читать состояние и действия */}
<DialogActions>
<CancelButton />
<ForwardButton />{" "}
{/* кастомные компоненты могут читать состояние и действия */}
</DialogActions>
</Dialog>
</ForwardMessageProvider>
);
}
function ForwardButton() {
const { actions } = use(Composer.Context);
return <Button onPress={actions.submit}>переслать</Button>;
}
ForwardButton живёт за пределами Composer.Frame, но всё ещё имеет доступ к действию submit, потому что находится внутри провайдера. даже будучи отдельным компонентом, он может достучаться до состояния и действий композера извне.
главная мысль: компонентам, которым нужно общее состояние, не обязательно быть визуально вложенными друг в друга — достаточно быть внутри одного провайдера.
3. паттерны реализации
влияние: среднее
конкретные техники для реализации составных компонентов и контекстных провайдеров.
3.1 создавай явные варианты компонентов
влияние: среднее (самодокументируемый код, никаких скрытых условностей)
вместо одного компонента с кучей булевых пропсов создавай явные компоненты-варианты. каждый вариант собирает из кусочков то, что ему нужно. код сам себя документирует.
❌ неправильно: один компонент, много режимов
// что на самом деле рендерит этот компонент?
<Composer
isThread
isEditing={false}
channelId="abc"
showAttachments
showFormatting={false}
/>
✅ правильно: явные варианты
// сразу ясно, что рендерится
<ThreadComposer channelId="abc" />
// или
<EditMessageComposer messageId="xyz" />
// или
<ForwardMessageComposer messageId="123" />
каждая реализация уникальна, явна и самодостаточна. но они могут использовать общие части.
реализация:
function ThreadComposer({ channelId }) {
return (
<ThreadProvider channelId={channelId}>
<Composer.Frame>
<Composer.Input />
<AlsoSendToChannelField channelId={channelId} />
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
<Composer.Submit />
</Composer.Footer>
</Composer.Frame>
</ThreadProvider>
);
}
function EditMessageComposer({ messageId }) {
return (
<EditMessageProvider messageId={messageId}>
<Composer.Frame>
<Composer.Input />
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
<Composer.CancelEdit />
<Composer.SaveEdit />
</Composer.Footer>
</Composer.Frame>
</EditMessageProvider>
);
}
function ForwardMessageComposer({ messageId }) {
return (
<ForwardMessageProvider messageId={messageId}>
<Composer.Frame>
<Composer.Input placeholder="можете добавить сообщение" />
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
<Composer.Mentions />
</Composer.Footer>
</Composer.Frame>
</ForwardMessageProvider>
);
}
каждый вариант явно показывает:
- какой провайдер/состояние используется
- какие ui-элементы включает
- какие действия доступны
никаких комбинаций булевых пропсов для анализа. никаких невозможных состояний.
3.2 предпочитай композицию через children вместо render-пропсов
влияние: среднее (более чистая композиция, лучшая читаемость)
используй children для композиции вместо пропсов с renderX. children читаются лучше, естественно компонуются и не требуют изучения сигнатур колбэков.
❌ неправильно: render-пропсы
function Composer({ renderHeader, renderFooter, renderActions }) {
return (
<form>
{renderHeader?.()}
<Input />
{renderFooter ? renderFooter() : <DefaultFooter />}
{renderActions?.()}
</form>
);
}
// использование неудобное и неповоротливое
return (
<Composer
renderHeader={() => <CustomHeader />}
renderFooter={() => (
<>
<Formatting />
<Emojis />
</>
)}
renderActions={() => <SubmitButton />}
/>
);
✅ правильно: составные компоненты с children
function ComposerFrame({ children }) {
return <form>{children}</form>;
}
function ComposerFooter({ children }) {
return <footer className="flex">{children}</footer>;
}
// использование гибкое
return (
<Composer.Frame>
<CustomHeader />
<Composer.Input />
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
<SubmitButton />
</Composer.Footer>
</Composer.Frame>
);
когда render-пропсы уместны:
// render-пропсы хороши, когда нужно передать данные обратно
<List
data={items}
renderItem={({ item, index }) => <Item item={item} index={index} />}
/>
используй render-пропсы, когда родитель должен предоставить дочернему компоненту данные или состояние. используй children при композиции статической структуры.
4. react 19 api
влияние: среднее
только для react 19+. не используй forwardRef; используй use() вместо useContext().
4.1 изменения api в react 19
влияние: среднее (более чистые определения компонентов и использование контекста)
⚠️ только react 19+. пропусти этот раздел, если используешь react 18 или ниже.
в react 19 ref стал обычным пропсом (обёртка forwardRef больше не нужна), а use() заменяет useContext().
❌ неправильно: forwardRef в react 19
const ComposerInput = forwardRef((props, ref) => {
return <TextInput ref={ref} {...props} />;
});
✅ правильно: ref как обычный проп
function ComposerInput({ ref, ...props }) {
return <TextInput ref={ref} {...props} />;
}
❌ неправильно: useContext в react 19
const value = useContext(MyContext);
✅ правильно: use вместо useContext
const value = use(MyContext);
use() можно вызывать условно, в отличие от useContext().