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

28. Кастомные примитивы

Одна из самых мощных концепций Solid.js — создание собственных реактивных примитивов. Примитив — это функция, которая инкапсулирует реактивную логику и может переиспользоваться в любом компоненте так же легко, как встроенные createSignal или createEffect.

Кастомный примитив — обычная функция (обычно с префиксом create или make), которая:

  • Использует встроенные примитивы внутри себя
  • Возвращает реактивные данные или методы управления
  • Работает только внутри реактивного контекста (компонент или другой примитив)
// Простейший кастомный примитив
function createCounter(initial = 0) {
const [count, setCount] = createSignal(initial);
return {
count,
increment: () => setCount(c => c + 1),
decrement: () => setCount(c => c - 1),
reset: () => setCount(initial),
};
}
// Использование в компоненте — как будто встроенный примитив
function Counter() {
const { count, increment, decrement } = createCounter(10);
return <button onClick={increment}>{count()}</button>;
}

Библиотека @solid-primitives ввела важное соглашение:

  • create* — создаёт реактивные данные, должен вызываться в реактивном контексте (компонент/эффект). Жизненный цикл управляется владельцем (Owner).
  • make* — создаёт что-то с ручным управлением жизненным циклом, можно вызвать вне контекста.
// create — привязан к жизненному циклу компонента, очищается автоматически
function createEventListener(
target: EventTarget,
event: string,
handler: EventListener
) {
createEffect(() => {
target.addEventListener(event, handler);
onCleanup(() => target.removeEventListener(event, handler));
});
}
// make — ручное управление, возвращает dispose-функцию
function makeEventListener(
target: EventTarget,
event: string,
handler: EventListener
) {
target.addEventListener(event, handler);
return () => target.removeEventListener(event, handler); // вызывай сам!
}

Один из самых полезных примитивов — сигнал, синхронизированный с localStorage:

function createLocalStorage<T>(key: string, initialValue: T) {
const stored = localStorage.getItem(key);
const initial = stored !== null ? JSON.parse(stored) : initialValue;
const [value, setValue] = createSignal<T>(initial);
// Каждый раз при изменении — сохраняем в localStorage
createEffect(() => {
localStorage.setItem(key, JSON.stringify(value()));
});
return [value, setValue] as const;
}
// Использование — как обычный createSignal, но персистентный!
function Settings() {
const [theme, setTheme] = createLocalStorage('theme', 'dark');
return (
<select value={theme()} onChange={e => setTheme(e.target.value)}>
<option value="dark">Тёмная</option>
<option value="light">Светлая</option>
</select>
);
}

Часто нужно реагировать на изменения сигнала не сразу, а с задержкой:

function createDebounce<T>(source: Accessor<T>, delay: number): Accessor<T> {
const [debounced, setDebounced] = createSignal<T>(source());
let timer: ReturnType<typeof setTimeout>;
createEffect(() => {
const value = source(); // создаём подписку
clearTimeout(timer);
timer = setTimeout(() => setDebounced(() => value), delay);
onCleanup(() => clearTimeout(timer));
});
return debounced;
}
function Search() {
const [query, setQuery] = createSignal('');
const debouncedQuery = createDebounce(query, 300);
// Срабатывает только через 300мс после последнего ввода
createEffect(() => {
if (debouncedQuery()) fetchResults(debouncedQuery());
});
return <input onInput={e => setQuery(e.target.value)} />;
}
function createMediaQuery(query: string): Accessor<boolean> {
const mql = window.matchMedia(query);
const [matches, setMatches] = createSignal(mql.matches);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener('change', handler);
onCleanup(() => mql.removeEventListener('change', handler));
return matches;
}
function ResponsiveLayout() {
const isMobile = createMediaQuery('(max-width: 768px)');
const prefersReducedMotion = createMediaQuery('(prefers-reduced-motion: reduce)');
return (
<div>
<Show when={isMobile()} fallback={<DesktopNav />}>
<MobileNav />
</Show>
</div>
);
}

@solid-primitives — коллекция из 100+ готовых примитивов. Каждый пакет устанавливается отдельно:

Окно терминала
npm install @solid-primitives/timer
npm install @solid-primitives/storage
npm install @solid-primitives/media
npm install @solid-primitives/event-listener
npm install @solid-primitives/intersection-observer
npm install @solid-primitives/fetch
npm install @solid-primitives/keyboard
ПакетКлючевые примитивы
@solid-primitives/timercreateTimer, makeTimer, createInterval
@solid-primitives/storagecreateLocalStorage, createSessionStorage, makePersisted
@solid-primitives/mediacreateMediaQuery, createBreakpoints
@solid-primitives/fetchcreateFetch с кешированием и повторами
@solid-primitives/keyboardcreateKeyHold, useKeyDownList
@solid-primitives/scrollcreateScrollPosition, useWindowScrollPosition
@solid-primitives/intersection-observercreateIntersectionObserver, createViewportObserver
@solid-primitives/gesturecreateGesture, свайпы, зум
import { createTimer } from '@solid-primitives/timer';
function Stopwatch() {
const [running, setRunning] = createSignal(false);
const [elapsed, setElapsed] = createSignal(0);
// delay может быть сигналом! false = пауза
createTimer(
() => setElapsed(e => e + 100),
() => running() ? 100 : false,
setInterval
);
return (
<div>
<span>{(elapsed() / 1000).toFixed(1)}с</span>
<button onClick={() => setRunning(r => !r)}>
{running() ? 'Пауза' : 'Старт'}
</button>
</div>
);
}

Настоящая сила — в том, что примитивы можно компоновать:

// Примитив высшего уровня, использующий другие примитивы
function createPersistentDebounced<T>(
key: string,
initial: T,
delay = 500
) {
const [value, setValue] = createLocalStorage(key, initial);
const debounced = createDebounce(value, delay);
const isDesktop = createMediaQuery('(min-width: 1024px)');
return {
value,
debounced,
isDesktop,
setValue,
};
}

Хорошо типизированный примитив на порядок улучшает DX. Используй Accessor, Setter и дженерики:

import type { Accessor, Setter } from 'solid-js';
// Tuple как у createSignal — удобно деструктурировать
function createToggle(
initial = false
): [get: Accessor<boolean>, actions: {
toggle: () => void;
setTrue: () => void;
setFalse: () => void;
set: Setter<boolean>;
}] {
const [value, set] = createSignal(initial);
return [value, {
toggle: () => set(v => !v),
setTrue: () => set(true),
setFalse: () => set(false),
set,
}];
}
// Использование
const [isOpen, { toggle, setFalse }] = createToggle();
// ❌ Вызов createSignal вне реактивного контекста
const counter = createCounter(); // Error: Calling signal outside owner
// ✅ Внутри компонента
function App() {
const counter = createCounter(); // OK
}
// ❌ Утечка памяти — нет onCleanup!
function createBadListener(el: Element, event: string, fn: EventListener) {
createEffect(() => {
el.addEventListener(event, fn);
// Забыли onCleanup — слушатель не удаляется при размонтировании!
});
}
// ✅ Правильно — всегда убираем за собой
function createGoodListener(el: Element, event: string, fn: EventListener) {
createEffect(() => {
el.addEventListener(event, fn);
onCleanup(() => el.removeEventListener(event, fn));
});
}

Три кастомных примитива в действии — useLocalStorage, useDebounce, useMediaQuery: