React Server Components
React Server Components (RSC) делят React-дерево на две половины — код, работающий на сервере и никогда не уезжающий в браузер, и код, который гидрируется и работает в браузере. Triggery твёрдо во второй половине: каждому хуку (useEvent, useCondition, useAction, useInspect, useInspectHistory) нужны useEffect и реальный DOM, а они есть только на клиенте.
Эта страница — практическое руководство: где проходит граница 'use client', как файлы триггеров уживаются с серверными компонентами и как соединить server action с клиентским триггером, чтобы запись в БД зажгла уведомление в трёх компонентах подальше.
Базовое правило
Заголовок раздела «Базовое правило»Всё, что вызывает хук Triggery, должно жить в 'use client'-файле. Файлы модулей триггеров (*.trigger.ts) — чистые модули — импортируют createTrigger, объявляют конфиг, экспортируют объект триггера. Но потребляются они клиентскими компонентами, поэтому на практике их тоже стоит помечать 'use client', чтобы бандлер не пытался выполнить их на сервере.
'use client';
import { createTrigger } from '@triggery/core';
export const messageTrigger = createTrigger<{
events: { 'new-message': { author: string; text: string } };
conditions: { settings: { notifications: boolean } };
actions: { showToast: { title: string; body: string } };
}>({
id: 'message-received',
events: ['new-message'],
required: ['settings'],
handler({ event, conditions, actions }) {
if (!conditions.settings?.notifications) return;
actions.showToast?.({ title: event.payload.author, body: event.payload.text });
},
});Директива 'use client' сверху — подсказка бандлеру, что модуль принадлежит клиентскому бандлу. Самому триггеру — данным плюс замыканию-обработчику — на директиву всё равно, но явная пометка предотвращает частую ошибку: серверный компонент пытается вызвать createTrigger на сервере (это сработает, но впустую тратит CPU и создаёт серверный рантайм, которым никто не пользуется).
Где ставится провайдер рантайма
Заголовок раздела «Где ставится провайдер рантайма»TriggerRuntimeProvider — клиентский компонент. Оберни его в своём файле и размести сразу под корневым layout:
'use client';
import { createRuntime } from '@triggery/core';
import { TriggerRuntimeProvider } from '@triggery/react';
import { useState, type ReactNode } from 'react';
export function TriggeryRoot({ children }: { children: ReactNode }) {
const [runtime] = useState(() => createRuntime());
return <TriggerRuntimeProvider runtime={runtime}>{children}</TriggerRuntimeProvider>;
}import { TriggeryRoot } from '../components/triggery-root';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<TriggeryRoot>{children}</TriggeryRoot>
</body>
</html>
);
}Сам layout — серверный компонент. Внутри него граница TriggeryRoot помечает: «всё ниже — клиентское». Проп children при этом может всё ещё быть деревом серверных компонентов — и эти серверные компоненты могут содержать свои 'use client'-острова дальше вниз. RSC так и устроен; Triggery эту форму не меняет.
RootLayout ← server component
└── TriggeryRoot ← 'use client' boundary
├── ServerComponent ← still a server component (rendered into the boundary)
│ ├── ClientLeaf ← 'use client'; uses useEvent / useCondition / useAction
│ └── ServerLeaf ← server, no Triggery hooks here
└── ClientHeader ← 'use client'Компоненты, использующие хуки
Заголовок раздела «Компоненты, использующие хуки»Любой компонент, вызывающий useEvent, useCondition, useAction, useInspect или useInspectHistory, должен быть 'use client':
'use client';
import { useEvent, useCondition } from '@triggery/react';
import { messageTrigger } from '../triggers/message.trigger';
export function Chat({ channelId }: { channelId: string }) {
const fire = useEvent(messageTrigger, 'new-message');
useCondition(messageTrigger, 'activeChannelId', () => channelId, [channelId]);
return (
<button onClick={() => fire({ author: 'Alice', text: 'hi' })}>
send a fake message
</button>
);
}'use client';
import { useAction } from '@triggery/react';
import { toast } from 'sonner';
import { messageTrigger } from '../triggers/message.trigger';
export function NotificationLayer() {
useAction(messageTrigger, 'showToast', (payload) => {
toast.success(payload.title, { description: payload.body });
});
return null;
}Серверный компонент, который рендерит один из таких, — это нормально: серверные компоненты могут компоновать клиентские как детей. Чего нельзя — это вызывать хук из серверного компонента.
Серверные компоненты, диспатчащие в триггеры
Заголовок раздела «Серверные компоненты, диспатчащие в триггеры»У серверных компонентов нет доступа к рантайму. Они не могут импортировать useEvent (на сервере он бы упал) и не могут добраться до инстанса клиентского рантайма. Мост — это обычный HTTP-fetch с клиента в Route Handler или server action и клиентский слушатель события, который запускает триггер, когда fetch вернётся.
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
const body = await req.json();
await db.insertMessage(body); // your real persistence
return NextResponse.json({ ok: true, id: body.id });
}'use client';
import { useEvent } from '@triggery/react';
import { messageTrigger } from '../triggers/message.trigger';
export function Compose() {
const fire = useEvent(messageTrigger, 'new-message');
async function send(text: string) {
const res = await fetch('/api/messages', {
method: 'POST',
body: JSON.stringify({ author: 'Alice', text }),
});
const { id } = await res.json();
fire({ author: 'Alice', text, channelId: id });
}
return <button onClick={() => send('hi')}>send</button>;
}Триггер срабатывает на клиенте, после подтверждения сервера. Обработчик читает условия, зарегистрированные другими клиентскими компонентами, и зовёт действия, зарегистрированные другими клиентскими реакторами. У сервера про триггер нулевая осведомлённость.
В этом и есть граница V1: сервер может попросить клиент что-то сделать; залезть в клиентский рантайм напрямую он не может. Клиент управляет своей оркестрацией сам.
Server actions (Next.js / React 19)
Заголовок раздела «Server actions (Next.js / React 19)»С server actions всё устроено так же, как с route handlers:
'use server';
import { db } from '@/db';
export async function postMessage(payload: { author: string; text: string }) {
await db.insertMessage(payload);
return { ok: true as const, id: payload.author + '-' + Date.now() };
}'use client';
import { useEvent } from '@triggery/react';
import { useTransition } from 'react';
import { postMessage } from '../app/actions';
import { messageTrigger } from '../triggers/message.trigger';
export function Compose() {
const fire = useEvent(messageTrigger, 'new-message');
const [pending, startTransition] = useTransition();
function send() {
startTransition(async () => {
const result = await postMessage({ author: 'Alice', text: 'hi' });
if (result.ok) fire({ author: 'Alice', text: 'hi', channelId: result.id });
});
}
return <button onClick={send} disabled={pending}>send</button>;
}Форма та же — позови сервер, дождись результата, потом fire. Server action — это просто fetch с более приятной эргономикой.
Гибридные страницы: данные на сервере, оркестрация на клиенте
Заголовок раздела «Гибридные страницы: данные на сервере, оркестрация на клиенте»Типичная гибридная страница запрашивает данные в серверном компоненте и рендерит интерактивные кусочки как клиентские острова. Triggery встаёт чисто, потому что рантайм поднят выше островов:
import { db } from '@/db';
import { MessageList } from './message-list';
import { Compose } from '@/features/Compose';
import { NotificationLayer } from '@/features/NotificationLayer';
export default async function Page({ params }: { params: { channelId: string } }) {
const messages = await db.messages.findMany({ where: { channelId: params.channelId } });
return (
<main>
<MessageList initial={messages} channelId={params.channelId} />
<Compose />
<NotificationLayer />
</main>
);
}Страница рендерится целиком на сервере: HTML для MessageList, Compose, NotificationLayer уезжает вниз. При гидрации каждый 'use client'-остров подключается к провайдеру TriggeryRoot, размещённому в layout. Друг друга они находят через триггер — Compose запускает событие, NotificationLayer реагирует, MessageList обновляется через своё React-состояние. Сервер ничего из этого не видит.
Паттерн одной схемой
Заголовок раздела «Паттерн одной схемой»Browser Server
───────── ───────
RootLayout (server)
└── <TriggeryRoot> ← 'use client' boundary
└── <Outlet />
└── Page (server, async)
├── <MessageList initial={…}> 'use client'
├── <Compose /> 'use client'
└── <NotificationLayer /> 'use client'
(rendered HTML ships down, hydrates)
Browser side:
Compose fires 'new-message' ──microtask──▶ messageTrigger handler
handler reads conditions (settings, activeChannelId)
handler calls actions.showToast?(…)
NotificationLayer's useAction handler renders the toast.
All of this happens entirely client-side. The server has no visibility.Streaming и Suspense
Заголовок раздела «Streaming и Suspense»Streaming SSR в React 19 / Next App Router рендерит серверные компоненты потоком HTML-чанков; клиентские острова гидрируются по мере прихода их JS. С точки зрения Triggery:
- Провайдер монтируется, когда приходит его чанк. До этого хуки во вложенных островах не отрабатывали.
- После монтирования острова регистрируют условия и действия в своих
useEffect. Пока они этого не сделали, срабатывания либо пропускаются (requiredне выполнен), либо проходят без реактора. - Если запускаешь событие из клиентского компонента, который смонтирован раньше своего реактора, инспектор записывает «fired-but-unhandled». Это нормально — у триггера логика «нет реактора — ничего не делать». Следующее срабатывание после монтирования обоих запустит действия как обычно.
На практике это значит: размести <NotificationLayer />-реактор в layout, а не внутри маршрута страницы, который может уйти в Suspense. Реакторы дешёвые; пока действие не сработало, они ничего видимого не рендерят. Размещение в layout гарантирует, что они живы для каждого перехода между страницами.
Подводные камни
Заголовок раздела «Подводные камни»Server-only-модули
Заголовок раздела «Server-only-модули»'use server'-файл не может импортировать *.trigger.ts-модуль с 'use client' сверху — бандлер свалится. Если нужны общие типы между сервером и клиентом (например, интерфейс Message, который используется в payload’ах событий), положи их в types.ts без директивы и импортируй этот файл с обеих сторон.
getDefaultRuntime() на сервере
Заголовок раздела «getDefaultRuntime() на сервере»getDefaultRuntime() ленивый — создаёт глобальный рантайм при первом вызове. В RSC-окружении это значит, что серверный процесс тоже имеет рантайм по умолчанию — и он общий между запросами. Не регистрируй триггеры против рантайма по умолчанию в серверном коде; протечёт между запросами. Лекарство — то же, что и в тестах: передавай явный рантайм в createTrigger(config, runtime) или вызывай createTrigger только из 'use client'-модулей.
Рассинхроны гидрации
Заголовок раздела «Рассинхроны гидрации»useInspect и useInspectHistory возвращают undefined / [] на сервере и на первом клиентском рендере — одно и то же значение с обеих сторон, рассинхрона нет. Если делаешь дебаг-панель, которая показывает «no runs yet», пока список пуст, и таблицу — когда непуст, серверный HTML и первый гидрационный HTML идентичны. Обновления начинают приходить после первого commit’а эффектов; это клиентское обновление, а не рассинхрон гидрации.
Обработчики действий, трогающие server-only-API
Заголовок раздела «Обработчики действий, трогающие server-only-API»Так делать не надо. Обработчик действия — это замыкание, зарегистрированное через useAction, живёт в клиентском компоненте и работает в браузере. Если нужно писать в БД из обработчика — обработчик вызывает server action (или fetch), ждёт результат и передаёт его в следующий шаг. Server-only-API живут внутри server action.