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

17. Кастомные хуки с RxJS

Яша снова здесь! В прошлом уроке мы подключали RxJS прямо в компонентах. Но это быстро превращается в лапшу. Решение — кастомные хуки. Они инкапсулируют подписочную логику, дают переиспользуемые кирпичики и делают компоненты чистыми и декларативными. Строим хуки как Lego!


Самый универсальный хук — принимает любой Observable и возвращает его последнее значение:

import { useState, useEffect } from 'react';
import { Observable } from 'rxjs';
// T — тип значений из потока
// I — тип начального значения (может отличаться от T | undefined)
function useObservable<T>(
observable$: Observable<T>,
initialValue?: T
): T | undefined {
const [value, setValue] = useState<T | undefined>(initialValue);
useEffect(() => {
const subscription = observable$.subscribe({
next: setValue,
error: (err) => console.error('[useObservable]', err),
});
return () => subscription.unsubscribe();
}, [observable$]); // зависимость: если поток меняется — перезапускаемся
return value;
}
// Использование:
const count = useObservable(counter$, 0);
const windowSize = useObservable(resize$);

⚠️ Важно: если observable$ — это новый объект при каждом рендере, эффект будет срабатывать бесконечно. Создавай стримы вне компонента или оборачивай в useRef/useMemo.


2. 🎯 useSubject: двустороннее управление потоком

Заголовок раздела «2. 🎯 useSubject: двустороннее управление потоком»

Хук для работы с Subject — позволяет и читать, и писать в поток:

import { useRef, useCallback, useEffect, useState } from 'react';
import { Subject, Observable } from 'rxjs';
interface UseSubjectResult<T> {
value: T | undefined;
next: (value: T) => void;
observable$: Observable<T>;
complete: () => void;
}
function useSubject<T>(initialValue?: T): UseSubjectResult<T> {
// useRef гарантирует, что Subject создаётся один раз
const subjectRef = useRef(new Subject<T>());
const [value, setValue] = useState<T | undefined>(initialValue);
useEffect(() => {
const sub = subjectRef.current.subscribe(setValue);
return () => sub.unsubscribe();
}, []);
const next = useCallback((val: T) => {
subjectRef.current.next(val);
}, []);
const complete = useCallback(() => {
subjectRef.current.complete();
}, []);
return {
value,
next,
observable$: subjectRef.current.asObservable(),
complete,
};
}
// Использование:
function SearchBox() {
const { value: query, next: setQuery } = useSubject<string>('');
const results = useObservable(
useMemo(() => search$(query ?? ''), [query])
);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

3. 🏪 useBehaviorSubject: реактивный стейт с историей

Заголовок раздела «3. 🏪 useBehaviorSubject: реактивный стейт с историей»

BehaviorSubject всегда хранит последнее значение и отдаёт его новым подписчикам. Идеален для состояния:

import { useRef, useState, useEffect, useCallback } from 'react';
import { BehaviorSubject } from 'rxjs';
import { distinctUntilChanged, map } from 'rxjs/operators';
function useBehaviorSubject<T>(initialValue: T) {
const subjectRef = useRef(new BehaviorSubject<T>(initialValue));
const [state, setState] = useState<T>(initialValue);
useEffect(() => {
const sub = subjectRef.current
.pipe(distinctUntilChanged()) // не рендерим если значение не изменилось
.subscribe(setState);
return () => sub.unsubscribe();
}, []);
const setValue = useCallback((next: T | ((prev: T) => T)) => {
const current = subjectRef.current.getValue();
const nextValue = typeof next === 'function'
? (next as (prev: T) => T)(current)
: next;
subjectRef.current.next(nextValue);
}, []);
// Доступ к сырому Subject для pipe()-цепочек в других местах
const getSubject = useCallback(() => subjectRef.current, []);
return [state, setValue, getSubject] as const;
}
// Использование:
const [count, setCount, getCount$] = useBehaviorSubject(0);
// Производный поток:
const doubled$ = getCount$().pipe(map(n => n * 2));

Строгая типизация делает хуки безопасными:

import { Observable, BehaviorSubject, Subject } from 'rxjs';
// Тип для результата useObservable
type UseObservableState<T> = {
value: T | undefined;
loading: boolean;
error: Error | null;
};
// Продвинутая версия с loading/error состояниями
function useObservableWithStatus<T>(
factory: () => Observable<T>,
deps: React.DependencyList = []
): UseObservableState<T> {
const [state, setState] = useState<UseObservableState<T>>({
value: undefined,
loading: true,
error: null,
});
useEffect(() => {
setState(prev => ({ ...prev, loading: true, error: null }));
const sub = factory().subscribe({
next: value => setState({ value, loading: false, error: null }),
error: error => setState(prev => ({ ...prev, loading: false, error })),
complete: () => setState(prev => ({ ...prev, loading: false })),
});
return () => sub.unsubscribe();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return state;
}
// Пример с типобезопасным хуком для API-запросов
interface User { id: number; name: string; }
function useUser(userId: number): UseObservableState<User> {
return useObservableWithStatus<User>(
() => fetchUser$(userId),
[userId]
);
}
// В компоненте:
const { value: user, loading, error } = useUser(42);

Ошибка 1: Observable в зависимостях — бесконечный цикл

// ❌ pipe() создаёт новый Observable на каждом рендере
function BadHook({ userId }: { userId: number }) {
const user$ = userStore$.pipe(map(state => state.users[userId]));
const user = useObservable(user$); // Бесконечный цикл!
}
// ✅ useMemo стабилизирует ссылку
function GoodHook({ userId }: { userId: number }) {
const user$ = useMemo(
() => userStore$.pipe(map(state => state.users[userId])),
[userId]
);
const user = useObservable(user$);
}

Ошибка 2: Subject снаружи компонента = один на всех

// ❌ Все экземпляры компонента шарят один Subject
const sharedSubject$ = new Subject<number>(); // Глобальный!
// ✅ useRef создаёт отдельный Subject для каждого экземпляра
function SafeComponent() {
const subject$ = useRef(new Subject<number>()).current;
}


Попробуйте примеры в интерактивном редакторе: