Перейти к содержимому

27. batch, untrack и createRoot

Привет! 👋 Это продвинутый урок о низкоуровневых примитивах Solid.js, которые дают тебе точный контроль над реактивностью: когда обновления объединять, когда не подписываться, и как работает модель владения (ownership model).

Думай об этих примитивах как о педалях в машине: большинство времени ты просто едешь (createSignal/createEffect), но иногда нужно нажать на сцепление (untrack), газ с тормозом одновременно (batch) или перезапустить двигатель (createRoot).


По умолчанию каждое изменение сигнала немедленно уведомляет все зависимые компоненты. batch() откладывает все уведомления до конца блока:

import { createSignal, createMemo, createEffect, batch } from 'solid-js';
const [firstName, setFirstName] = createSignal('Яша');
const [lastName, setLastName] = createSignal('Иванов');
const fullName = createMemo(() => `${firstName()} ${lastName()}`);
createEffect(() => {
console.log('fullName изменился:', fullName());
});
// ❌ БЕЗ batch — два отдельных уведомления
setFirstName('Максим'); // → эффект запускается: "Максим Иванов"
setLastName('Петров'); // → эффект запускается: "Максим Петров"
// Итог: 2 обновления DOM (промежуточное состояние "Максим Иванов" мелькнуло)
// ✅ С batch — одно уведомление
batch(() => {
setFirstName('Максим'); // ← не уведомляет
setLastName('Петров'); // ← не уведомляет
});
// После batch: эффект запускается ОДИН РАЗ: "Максим Петров"
// Итог: 1 обновление DOM
// Пример 1: Обновление нескольких связанных полей формы
function resetForm() {
batch(() => {
setName('');
setEmail('');
setPhone('');
setErrors({});
setTouched({});
setSubmitting(false);
});
// Один рендер вместо 6!
}
// Пример 2: Загрузка данных с сервера
async function fetchUserData(id: number) {
const data = await api.getUser(id);
batch(() => {
setUser(data.user);
setPermissions(data.permissions);
setPreferences(data.preferences);
setLoading(false);
setError(null);
});
}
// Пример 3: Оптимистичное обновление
function optimisticToggle(itemId: number) {
batch(() => {
setItems(items => items.map(i =>
i.id === itemId ? { ...i, done: !i.done } : i
));
setLastUpdated(Date.now());
setPendingCount(n => n + 1);
});
}
// Важно: batch — синхронный! Await внутри не работает
batch(async () => {
setLoading(true);
await fetch('/api/data'); // ← batch закончится ДО await!
setData(result); // ← это обновление уже вне batch
});

untrack() позволяет читать сигнал внутри реактивного контекста, не создавая зависимости:

import { createSignal, createEffect, untrack } from 'solid-js';
const [count, setCount] = createSignal(0);
const [logEnabled, setLogEnabled] = createSignal(true);
// ❌ Оба сигнала создают зависимость
createEffect(() => {
if (logEnabled()) {
console.log('Счётчик:', count()); // Запускается при изменении ОБОИХ
}
});
// ✅ Читаем count() без подписки
createEffect(() => {
if (logEnabled()) { // ← зависимость от logEnabled
// count() читается, но эффект НЕ зависит от него
console.log('Счётчик:', untrack(() => count()));
}
});
// Теперь эффект запустится только при изменении logEnabled!
// Ещё пример: снимок значения
createEffect(() => {
const current = count(); // ← зависимость: эффект запустится при изменении
const snapshot = untrack(() => expensiveSignal()); // ← читаем без подписки
console.log('Изменился count:', current, 'snapshot:', snapshot);
});
const [a, setA] = createSignal(0);
const [b, setB] = createSignal(0);
// ❌ Потенциальный бесконечный цикл!
createEffect(() => {
setB(a() + 1); // Читаем a — зависимость
// Если b тоже влияет на a — будет цикл
});
// ✅ Читаем предыдущее значение b без зависимости
createEffect(() => {
const newB = a() + untrack(() => b()); // b не создаёт зависимость
setB(newB);
});
// Реальный случай: счётчик без подписки на себя
const [history, setHistory] = createSignal<number[]>([]);
const [count, setCount] = createSignal(0);
createEffect(() => {
const c = count(); // ← зависимость
setHistory(untrack(() => [...history(), c])); // ← history без зависимости
});

Solid использует дерево владения для автоматической очистки ресурсов:

Дерево владения:
Родительский компонент (Owner)
├── createSignal → уничтожается вместе с родителем
├── createEffect → уничтожается вместе с родителем
│ ├── createSignal (дочерний) → уничтожается когда эффект перезапускается
│ └── onCleanup → вызывается при перезапуске или уничтожении
└── Дочерний компонент (Child Owner)
└── createEffect → уничтожается когда дочерний компонент удалён
import { createRoot, getOwner, runWithOwner } from 'solid-js';
// getOwner — получить текущего владельца
function createManagedEffect() {
const owner = getOwner(); // Получаем текущий owner
// Создаём эффект в другом контексте, но с нашим владельцем
runWithOwner(owner, () => {
createEffect(() => {
// Этот эффект будет уничтожен вместе с owner
console.log('Управляемый эффект');
});
});
}

createRoot создаёт независимое реактивное дерево, которое не привязано ни к какому компоненту и не уничтожается автоматически:

import { createRoot, createSignal, createEffect } from 'solid-js';
// Случай 1: Синглтон-стор (живёт всё время приложения)
const { count, increment } = createRoot(() => {
const [count, setCount] = createSignal(0);
return {
count,
increment: () => setCount(n => n + 1),
};
});
// Этот реактивный контекст живёт вечно — никто им не владеет
// Случай 2: Управляемое время жизни
const dispose = createRoot((dispose) => {
const [data, setData] = createSignal(null);
createEffect(() => {
console.log('data:', data());
});
// Возвращаем функцию очистки
return dispose;
});
// Позже — явно уничтожаем
setTimeout(() => {
dispose(); // Все сигналы и эффекты внутри уничтожены
}, 5000);
// Случай 3: Тестирование сигналов вне компонентов
function testSignal() {
createRoot(() => {
const [count, setCount] = createSignal(0);
const doubled = createMemo(() => count() * 2);
setCount(5);
console.assert(doubled() === 10, 'doubled должен быть 10');
});
}

Позволяют создавать реактивные вычисления в произвольном контексте владения:

import { createSignal, createEffect, getOwner, runWithOwner } from 'solid-js';
// Паттерн: отложенное создание эффекта
function ComponentWithDeferredEffect() {
const [count, setCount] = createSignal(0);
const owner = getOwner(); // Сохраняем владельца
// Создаём эффект после setTimeout (нет активного owner!)
setTimeout(() => {
runWithOwner(owner!, () => {
// Этот эффект привязан к компоненту, а не к setTimeout
createEffect(() => {
console.log('count из defer:', count());
});
});
}, 100);
return <button onClick={() => setCount(n => n + 1)}>+</button>;
}
// Паттерн: фабрика реактивных объектов
function createReactiveResource<T>(fetchFn: () => Promise<T>) {
const owner = getOwner()!;
let dispose: () => void;
const { signal, cleanup } = createRoot((d) => {
dispose = d;
const [data, setData] = createSignal<T | null>(null);
runWithOwner(owner, () => {
createEffect(() => {
fetchFn().then(setData);
});
});
return { signal: data, cleanup: d };
});
return signal;
}

🚨 catchError() и onError(): обработка ошибок в эффектах

Заголовок раздела «🚨 catchError() и onError(): обработка ошибок в эффектах»
import { createEffect } from 'solid-js';
// catchError из 'solid-js'
// Обработка ошибок в синхронных реактивных вычислениях
import { catchError } from 'solid-js';
function SafeComponent() {
const [data] = createResource(fetchData);
const result = catchError(
() => processData(data()), // Потенциально выбрасывает ошибку
(error) => {
console.error('Ошибка в реактивном вычислении:', error);
return null; // Fallback значение
}
);
return <div>{result()}</div>;
}
// onError: обработчик ошибок в дереве компонентов
import { onError } from 'solid-js';
function ParentComponent() {
// Ловим ошибки от ВСЕХ дочерних компонентов
onError((error) => {
console.error('Ошибка в дочернем компоненте:', error);
// Можно отправить в Sentry и т.д.
});
return <ChildComponent />;
}
// ErrorBoundary — декларативный вариант
import { ErrorBoundary } from 'solid-js';
function App() {
return (
<ErrorBoundary
fallback={(error, reset) => (
<div>
<p>Ошибка: {error.message}</p>
<button onClick={reset}>Попробовать снова</button>
</div>
)}
>
<RiskyComponent />
</ErrorBoundary>
);
}

// ❌ batch с async — не работает как ожидается
batch(async () => {
setA(1);
await somePromise; // batch ЗАКАНЧИВАЕТСЯ здесь, уведомления отправлены
setB(2); // Это уже отдельное обновление, вне batch
});
// ✅ batch только для синхронного кода
async function asyncUpdate() {
const data = await somePromise;
batch(() => { // ← batch после await
setA(data.a);
setB(data.b);
});
}
// ❌ untrack в неправильном месте
const doubled = createMemo(() =>
untrack(() => count()) * 2 // ← count не подписан! Memo никогда не обновится
);
// ✅ untrack только для "вторичных" значений
createEffect(() => {
const primary = primarySignal(); // ← зависимость
const secondary = untrack(() => secondarySignal()); // ← без зависимости
doSomething(primary, secondary);
});
// ❌ createRoot без dispose — утечка памяти
createRoot(() => {
createEffect(() => { /* heavy computation */ });
// Нет dispose — эффект живёт вечно!
});
// ✅ Сохраняй dispose для управления временем жизни
let disposeRoot: () => void;
createRoot((dispose) => {
disposeRoot = dispose;
createEffect(() => { /* heavy computation */ });
});
// Позже:
// disposeRoot();

🎯 Playground: Интерактивные демо batch, untrack, createRoot

Заголовок раздела «🎯 Playground: Интерактивные демо batch, untrack, createRoot»