Здравствуйте, товарищи! На Хабре вышла хорошая статья «Нужен ли здесь useEffect?» Виктора Горбачёва ( @Viktoriko ) : 12 сценариев из code review, в которых эффект либо не нужен, либо нужен не такой. Разбор я особо конечно не могу понять, так как пишу только на…
$mol’e и читал статью как этнограф. Каждое демо начинается с вопроса «а эффект здесь вообще нужен?», а мне этот вопрос не задать ведь в моле нет API эффектов. Стало интересно переписать. Переписал. Получилась страница с демками и этот текст.
Два места вместо трёх
У кода в React три места выполнения. Рендер для чистых вычислений, обработчик события для действий пользователя, эффект для синхронизации с внешней системой. И два вопроса перед каждым useEffect: можно ли это значение вычислить, и почему этот код должен выполниться.
В моле мест два. Вычисление, то есть обычный метод компонента: если результат стоит кэшировать, вешаешь на него @$mol_mem, и значение пересчитывается только когда меняется что-то из прочитанного внутри. И действие: декоратор @$mol_action, на который в view.tree вешается клик. Рендер отдельным местом не считается, он сам такое же вычисление. Синхронизация с внешним миром, забегая вперёд, тоже.
Пройдём по всем 12 демо. Нумерация и названия частей как у Виктора
Часть I. Не храните то, что можно вычислить
01. Производное состояние
fullName в useState, синхронизируемый эффектом — антипаттерн, правильно вычислять его во время рендера. В моле правильный вариант единственный, производное состояние просто негде хранить:
full_name() { return `${ this.first_name() } ${ this.last_name() }` }
Метод, даже без mem. Кто прочитал full_name, тот перерисуется при смене имени. Запомните это совпадение: «хороший» React-вариант и мол-вариант здесь один и тот же код, дальше так будет ещё не раз.
02. Кэш дорогого вычисления
Дорогой расчёт в React мемоизируют через useMemo, перечислив зависимости в массиве. В моле это метод из прошлого демо, только с декоратором:
@ $mol_mem visible_todos() { // какой-то измеримо дорогой расчёт const active = this.filter() === 'active' return this.todos().filter( t => active ? !t.done : true ) }
Пересчёт идёт только при смене фильтра или списка, в демо это видно по console.log. Дилемма «лишний useState или useMemo» не возникает, mem и есть мемоизация. Массива зависимостей тоже нет: mem запоминает, что читал filter() и todos(), по факту чтения.
03. Полный сброс состояния при смене сущности
Сброс черновика при смене userId в React решается пересозданием поддерева через key. В моле состояние можно хранить сразу по ключу:
@ $mol_mem_key comment( user: string, next?: string ) { return next ?? '' } @ $mol_mem current_comment( next?: string ) { return this.comment( this.user(), next ) }
У каждого пользователя свой черновик. Переключился на Бориса, вернулся к Анне а её недописанный комментарий на месте. В React-версии черновик при возврате не выживал, там это отдельная фича, которую надо проектировать, а тут оно само получается.
04. Корректировка состояния при смене данных
Виктор советует хранить selected_Id, а объект вычислять через find, без эффекта. Спорить не о чем, его правильный вариант уже готовый мол-код:
selection() { return this.items().find( i => i.id === this.selected_id() ) ?? null }
Удалили выбранный элемент, selection сам стал null, без дополнительного обновления состояния.
Часть II. События — не эффекты
05. Общая логика обработчиков
Уведомление «Добавлено в корзину» через эффект от product.isInCart срабатывает и тогда, когда корзина восстановилась из хранилища. Общую логику Виктор выносит в обычную функцию, и это ровно то, что пишется в моле:
buy() { this.cart_count( this.cart_count() + 1 ) this.status( `В корзине товаров: ${ this.cart_count() }` ) } @ $mol_action to_cart() { this.buy() } @ $mol_action buy_now() { this.buy() this.status( 'Переходим к оплате…' ) }
Вывод в статье мне нравится: не всякое переиспользование требует хука. В моле хуков нет, так что и соблазна не возникает)
06. Отправка формы
Сигнальное состояние, эффект на него, а в React 19 Actions и useActionState на два десятка строк. В моле запрос пишется прямо в действии, синхронным по виду кодом:
@ $mol_action submit() { this.sending( true ) this.$.$mol_wait_timeout( 800 ) // тут был бы запрос this.count( this.count() + 1 ) this.result( `Создан пользователь #${ this.count() }` ) this.sending( false ) }
Под капотом файбер (fiber): на время ожидания метод приостанавливается, кнопка задизейблена через sending, потом появляется результат. useActionState кстати неплохой API, pending и результат лежат рядом с причиной. Просто в моле это свойство любого действия, а не отдельная фича форм.
07. Цепочки эффектов
Игру с картами, где три эффекта триггерят друг друга, Виктор сворачивает в одну чистую функцию перехода. Мол-версия выглядит так же:
@ $mol_action place_card( gold: boolean ) { this.game( this.next_state( this.game(), gold ) ) }
В React такой каскад собрать легко, он даже работает, поэтому и попадает в прод. В моле его нельзя собрать: эффектов нет, а состояние меняется в действиях, одним переходом.
08. Уведомление родителя
Ребёнок сообщает родителю через onChange, и вопрос лишь в том, звать колбэк из эффекта (плохо) или из обработчика (хорошо). В моле этот код не пишется вовсе, связь описывается в view.tree:
<= Toggle $mol_check_box checked? <=> light_on? false <= Status $mol_view sub / <= light_status
<=> это двусторонний биндинг: тумблер и статус читают одну ячейку light_on. Ни колбэка, ни двух кругов обновления, ни выбора между controlled и uncontrolled. Если код выглядит “страшно” или непонятно, то гляньте сюда view.tree это просто краткая запись для нашего любимого JS
код выше будет представлен так
light_on(next){ if(next !== undefined) return next; return false; } Toggle(){ const obj = new this.$.$mol_check_box(); (obj.checked) = (next) => ((this.light_on(next))); return obj; } Status(){ const obj = new this.$.$mol_view(); (obj.sub) = () => ([(this.light_status())]); return obj; } sub(){ return "\t"; }
Часть III. Внешний мир и жизненный цикл
09. Инициализация приложения
Глава про пустой массив зависимостей, Strict Mode с двойным вызовом и guard-флаг в точке входа. В моле её заменяет одно свойство рантайма: всё лениво. Никакой мем не вычисляется, пока его кто-нибудь не прочитает.
@ $mol_mem visits() { return Number( this.$.$mol_state_local.value( 'bog_effects_visits' ) ?? 0 ) }
В демо панель со счётчиком не считается, пока её не раскрыли. Вопрос «когда выполнить инициализацию ?» превращается в «кому нить нужны эти данные ?». Если никому то она не выполнится вообще
10. Подписка на внешний store
useSyncExternalStore раскладывает подписку на subscribe, снапшот и серверный снапшот, плюс требование стабилизировать subscribe, иначе переподписка. В моле внешний источник оборачивается в обычное реактивное свойство:
@ $mol_mem online( next?: boolean ) { if( next !== undefined ) return next const win = this.$.$mol_dom_context win.ononline = ()=> this.online( true ) win.onoffline = ()=> this.online( false ) return win.navigator.onLine }
Подписка происходит при первом чтении, дальше браузер пушит значение через гетеро-сеттер, читатели перерисовываются. Часы в демо ещё короче: $mol_state_time.now(1000) из коробки, причём таймер там живёт, только пока часы кто-то читает исходник на 25 строк всего.
11. useEffectEvent
Моё любимое демо. Соединение с чатом зависит от roomId, но не должно пересоздаваться от смены theme, и чтобы вынести тему из зависимостей эффекта, в React 19.2 появился отдельный API. В моле зависимости не декларируются, они собираются по факту чтения:
@ $mol_mem connection() { const room = this.room() const conn = Object.assign( new $mol_object2, { room } ) conn.destructor = ()=> console.log( 'disconnect', room ) return conn } notice() { return `Комната «${ this.connection().room }», тема: ${ this.theme() }` }
Тело connection читает только room, значит соединение зависит только от комнаты. Тема читается в notice, её смена перерисовывает уведомление, не трогая соединение. А при смене комнаты старое соединение закрывает деструктор. На демо-странице есть счётчик переподключений, пощёлкайте тему и посмотрите на него.
useEffectEvent это ручное исключение из ручного списка зависимостей. Когда списка нет, исключать не из чего.
Часть IV. Загрузка серверных данных
12. Четыре подхода к fetching
Здесь Виктор честно пишет: загрузка по query это настоящая синхронизация с сервером, эффект не концептуальная ошибка, опасна наивная реализация. Гонка ответов, отмена, кэш, ретраи. Дальше четыре подхода на выбор: ручной хук с AbortController, TanStack Query, use() с Suspense, RTK Query.
Мол-вариант один:
@ $mol_mem search() { const q = this.query().trim().toLowerCase() if( !q ) return [] this.$.$mol_wait_timeout( 600 ) // имитация сети запрос через $mol_fetch return this.products().filter( p => p.toLowerCase().includes( q ) ) }
Пока «сеть» отвечает, вычисление приостановлено, ожидание мол показывает сам. Пользователь напечатал новую букву — устаревшее вычисление отменяется, а с ним и запрос: $mol_fetch в деструкторе дёргает AbortController (исходник). Гонка невозможна, кэш это сам mem. В демо есть счётчик завершённых «запросов»: печатайте хоть по буквам, он почти не двигается.
TanStack Query добавляет инвалидация по мутациям, оптимистичные апдейты, ретраи, devtools. Мол закрывает гонки, отмену и кэш самим рантаймом, а вот эти плюшки TanStack, если понадобятся, собираешь руками.
Чек-лист
Статья Виктора заканчивается таблицей на 12 строк: задача, что проверить перед тем как писать эффект, номер демо. Хорошая таблица, я бы повесил такую рядом с доской, если бы писал на React.
Моя версия целиком:
Задача | Что делать |
|---|---|
Вычислить значение | метод, если дорого — с |
Отреагировать на действие пользователя | метод с |
Это упрощение не ради красивого финала, все 12 сценариев действительно можно разложить на эти две строки. Дерево решений не нужно.
У Виктора есть мысль, которая мне нравится: повторяющееся замечание на review надо превращать в правило команды. Это работает в любом стеке. Просто любопытно наблюдать, сколько таких правил исчезает, когда реактивность лежит в основании фреймворка, а не прикручивается к нему сверху. ( беря реакт, вы собираете свой фреймворк, по сути, а надо ли ? )
Все 12 демо живут на странице: b-on-g.github.io/bog/effects, исходники тут. Потыкайте с открытой консолью, самое интересное в ней)
