Частые ошибки
На пальцах
Девять причин, по которым приложение на Continuum чаще всего «не работает»: как выглядит симптом, почему так происходит и в чём исправление — обычно в одну строку. Добавьте страницу в закладки на первую неделю.
1. Сайд-эффекты внутри map / accum
Симптом: работает через раз, ломается после рефакторинга; значения оказываются null, когда меньше всего ждёшь.
Функции, которые вы передаёте в map, accum, filter, at, должны быть чистыми: приняли данные — вернули данные. Чтение DOM, запуск других событий, fetch — ничему из этого там не место. Транзакционные гарантии (атомарные обновления, отсутствие глитчей) построены на этом допущении.
// ❌ чтение DOM внутри редьюсера
const todos = submits.accum<Todo[]>([], (e, acc) => {
const input = (e.currentTarget as HTMLFormElement).elements.namedItem("todo");
// `currentTarget` гарантированно существует только ПОКА событие
// диспатчится — сегодня это случайно работает, завтра сломается.
return [...acc, createTodo((input as HTMLInputElement).value)];
});// ✅ грязная работа на границе, в сеть — чистые данные
const texts = stream<string>();
const todos = texts.accum<Todo[]>([], (text, acc) => [
...acc,
createTodo(text),
]);
const onSubmit = (e: SubmitEvent) => {
e.preventDefault();
const input = (e.currentTarget as HTMLFormElement).elements.namedItem("todo");
if (input instanceof HTMLInputElement && input.value.trim()) {
texts.fire(input.value.trim());
input.value = "";
}
};Правило на память: обработчик извлекает, сеть преобразует. Если редьюсеру нужно что-то из DOM — это что-то должно было стать полезной нагрузкой события.
2. Забытый preventDefault() у формы
Симптом: сабмит формы «ничего не делает» — или страница мигает, и всё состояние сбрасывается.
Дефолтное поведение браузера на submit — полная навигация. Ваше событие срабатывает, состояние обновляется — а через миллисекунду страница перезагружается и уносит всё с собой.
// ❌ страница перезагружается, приложение стартует с нуля
<form onSubmit={submits.fire}>
// ✅
<form onSubmit={(e) => { e.preventDefault(); submits.fire(e); }}>Это поведение браузера, а не Continuum — но выглядит оно всегда как «туду не добавляется».
3. .sample() в JSX
Симптом: значение отрисовалось один раз и больше не обновляется.
sample() читает текущее значение прямо сейчас и возвращает обычный, нереактивный результат. Компонент выполняется один раз, так что отсэмплированное значение заморожено навсегда. Кладите в JSX сам State — в этом весь смысл:
// ❌ отрисует начальное значение и никогда не изменится
<div>{count.sample()}</div>
// ✅ живая привязка: этот текстовый узел обновляется при каждом изменении
<div>{count}</div>Место sample() — в обработчиках, где нужно «значение в момент клика». А для «прочитай и измени» есть update() — он ещё и корректно компонуется внутри batch (каждый апдейтер видит значение, застейдженное этим же моментом):
<button onClick={() => count.update((n) => n + 1)}>+1</button>4. Тернарник вместо <Show>
Симптом: условие вычислилось один раз, и ветка больше не переключается (или код вообще не проходит проверку типов).
Компонент выполняется один раз, поэтому обычный if/тернарник выбирает ветку один раз. Для реактивного ветвления есть <Show> (а также when/dyn):
// ❌ `list` — это State, у него нет `.length`,
// а даже `list.sample().length` проверился бы лишь однажды
{
list.sample().length > 0 ? <TodoList /> : <Empty />;
}
// ✅ производное условие + реактивная область
const hasItems = list.map((xs) => xs.length > 0);
<Show when={hasItems} fallback={() => <Empty />}>
{() => <TodoList />}
</Show>;Обратите внимание: оба ребёнка — thunk'и (() => …). Это сознательно: жадный JSX построил бы обе ветки сразу, а thunk позволяет фреймворку строить только живую.
5. onChange там, где нужен onInput
Симптом: текстовое состояние обновляется только при потере фокуса.
Continuum вешает нативные DOM-слушатели, а нативное событие change у текстового поля срабатывает на blur, а не на каждый символ. Это отличается от React, который переопределяет onChange.
// ❌ срабатывает на blur
<input value={text} onChange={(e) => text.set((e.target as HTMLInputElement).value)} />
// ✅ срабатывает на каждый символ — или просто bindInput
<input value={text} onInput={(e) => text.set((e.target as HTMLInputElement).value)} />
<input {...bindInput(text, text.set)} />6. Ожидание, что at увидит новое значение в том же моменте
Симптом: снимок, взятый «одновременно» с обновлением, читает прошлое значение — выглядит как ошибка на единицу.
Это не баг — это задержка hold, ключевое правило модели: внутри одного момента каждый State ещё держит значение, которое было до момента. Новое значение становится видимым после закрытия момента. Именно это делает петли состояния (accum, обратную связь через hold) корректно определёнными, а не бесконечными.
const src = stream<number>();
const held = src.hold(0);
const pairs = held.at(src, (prev, now) => [now, prev]);
src.fire(1); // pairs видит [1, 0] — `prev` это значение ДО этого момента
src.fire(2); // pairs видит [2, 1]Если нужно «значение с учётом этого обновления» — выводите его (map, accum), а не снимайте снимок. Подробно: Транзакции и время.
7. Модульное состояние без root()
Симптом: Error: Continuum: hold() creates state or an effect, and those need an owner… — прямо при импорте модуля.
Чистые производные (map, combine, filter) — формулы: их можно объявлять где угодно, они подключаются по первому слушателю и спят без слушателей. А hold, accum, .on, perform, resource — процессы: состоянию и эффектам нужен владелец, который однажды их остановит. Внутри компонента владелец есть автоматически; на уровне модуля время жизни объявляется явно:
// ❌ уровень модуля: некому владеть свёрткой — обучающая ошибка
export const unread = notifications.accum(0, (_n, c) => c + 1);
// ✅ явный корень: состояние живёт до dispose корня (обычно — всю страницу)
import { root } from "@continuum-js/frp";
export const unread = root(() => notifications.accum(0, (_n, c) => c + 1));Сами источники (state, stream) и формулы над ними экспортируются свободно — владелец нужен только состоянию и эффектам.
8. Состояние замерло после ухода скоупа
Симптом: значение перестало обновляться, хотя источник продолжает стрелять; sample() отдаёт одно и то же.
Когда скоуп уничтожается (переключился Show, размонтировался компонент, вызван dispose у root/scope), его hold/accum/.on замирают на последнем значении: процесс отцепляется от источника, sample() навсегда отвечает финальным значением. Это не утечка и не ошибка — это объявленное время жизни: кто построил состояние, тот его и останавливает.
Если значение должно переживать компонент — поднимите его к живущему дольше владельцу: в родительский компонент, или в модуль под root() (см. №7). Читать замершее значение безопасно; ждать от него обновлений — нет.
9. fetch внутри map
Симптом: задублированные запросы, unhandled rejection, гонки при быстром вводе.
map — для чистых преобразований. У ввода-вывода своя граница: perform (или resource из @continuum-js/std) запускает эффект после закрытия момента и возвращает результат данными — включая ошибки, завёрнутые в Result, а не выброшенные в ваш граф:
// ❌ эффект внутри чистого комбинатора
const results = queries.map((q) => fetch(`/api?q=${q}`)); // Stream<Promise> — и что дальше?
// ✅ IO на границе, результаты — данными
const results = perform(queries, (q) =>
fetch(`/api?q=${q}`).then((r) => r.json()),
);
// results: Stream<Result<Data, unknown>> — разбирайте как данные, без try/catchПолная история, включая отмену и нумерацию запросов: Асинхронность.
Мета-правило, покрывающее половину этой страницы: компонент выполняется один раз. Всё, что вы вычисляете обычным JavaScript в теле компонента, вычисляется однажды и замирает. Всё, что должно жить, обязано быть State, Stream или их производной — а как только вы ловите себя на том, что тянетесь из чистого комбинатора наружу (в DOM, в сеть, к другому событию), переносите этот код на границу: в обработчик, в perform или в onMount/onCleanup.