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

18. RxJS как стейт-менеджер

Привет! Яша здесь. Redux тяжёлый? Context API теряет в производительности? MobX магия? Попробуй BehaviorSubject — минимальный, предсказуемый и мощный паттерн управления состоянием на чистом RxJS. Никаких дополнительных библиотек!


Идея проста: BehaviorSubject хранит текущее состояние и уведомляет всех подписчиков об изменениях:

import { BehaviorSubject, Observable } from 'rxjs';
import { map, distinctUntilChanged, pluck } from 'rxjs/operators';
// Тип стейта
interface AppState {
todos: Todo[];
filter: 'all' | 'active' | 'done';
loading: boolean;
}
// Действия (типизированный union)
type Action =
| { type: 'ADD_TODO'; payload: string }
| { type: 'TOGGLE_TODO'; payload: number }
| { type: 'REMOVE_TODO'; payload: number }
| { type: 'SET_FILTER'; payload: AppState['filter'] }
| { type: 'SET_LOADING'; payload: boolean };
// Начальное состояние
const initialState: AppState = {
todos: [],
filter: 'all',
loading: false,
};
// Сам стор
class TodoStore {
private state$ = new BehaviorSubject<AppState>(initialState);
private dispatch$ = new Subject<Action>();
constructor() {
// Редьюсер — обрабатывает действия через scan()
this.dispatch$.pipe(
scan(this.reducer, initialState)
).subscribe(this.state$);
}
// Редьюсер — чистая функция, как в Redux
private reducer(state: AppState, action: Action): AppState {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [
...state.todos,
{ id: Date.now(), text: action.payload, done: false }
],
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(t =>
t.id === action.payload ? { ...t, done: !t.done } : t
),
};
case 'REMOVE_TODO':
return {
...state,
todos: state.todos.filter(t => t.id !== action.payload),
};
case 'SET_FILTER':
return { ...state, filter: action.payload };
default:
return state;
}
}
// Публичный API
dispatch(action: Action) {
this.dispatch$.next(action);
}
// Селекторы — производные Observable
get todos$(): Observable<Todo[]> {
return this.state$.pipe(
map(s => s.todos),
distinctUntilChanged()
);
}
get filteredTodos$(): Observable<Todo[]> {
return this.state$.pipe(
map(s => {
if (s.filter === 'active') return s.todos.filter(t => !t.done);
if (s.filter === 'done') return s.todos.filter(t => t.done);
return s.todos;
}),
distinctUntilChanged()
);
}
get completedCount$(): Observable<number> {
return this.todos$.pipe(map(todos => todos.filter(t => t.done).length));
}
}
export const store = new TodoStore();

КонцепцияReduxRxJS Store
ХранилищеcreateStore(reducer)new BehaviorSubject(state)
Действияdispatch(action)subject.next(action)
Редьюсер(state, action) => statescan((state, action) => state)
СелекторыcreateSelectorpipe(map(...), distinctUntilChanged())
MiddlewareapplyMiddleware(thunk)pipe(mergeMap(...))
DevToolsRedux DevToolstap(state => console.log(state))
Размер~7KB0KB (уже есть!)

Главное отличие: в Redux стор — это синхронный объект. В RxJS — это реактивный поток. Это значит, что компоненты могут подписываться на конкретные части стейта, а не на весь стор целиком.


import { useEffect, useState, useRef, useMemo } from 'react';
import { Observable, BehaviorSubject } from 'rxjs';
import { distinctUntilChanged, map } from 'rxjs/operators';
// Хук для подключения к BehaviorSubject стору
function useSelector<T, R>(
store: BehaviorSubject<T>,
selector: (state: T) => R
): R {
const selected$ = useMemo(
() => store.pipe(map(selector), distinctUntilChanged()),
[store, selector]
);
const [value, setValue] = useState<R>(() => selector(store.getValue()));
useEffect(() => {
const sub = selected$.subscribe(setValue);
return () => sub.unsubscribe();
}, [selected$]);
return value;
}
// Компонент использует только нужный срез стейта
function TodoCounter() {
const total = useSelector(todoStore, s => s.todos.length);
const done = useSelector(todoStore, s => s.todos.filter(t => t.done).length);
const loading = useSelector(todoStore, s => s.loading);
return (
<div>
{loading ? 'Загрузка...' : `${done}/${total} выполнено`}
</div>
);
}

distinctUntilChanged() — ключевой оператор: компонент не перерендерится, если его срез стейта не изменился.


В отличие от Redux Thunk, в RxJS асинхронность — это просто операторы:

import { Subject, merge, from } from 'rxjs';
import { mergeMap, map, catchError, startWith } from 'rxjs/operators';
// Действия с эффектами
const loadTodos$ = new Subject<void>();
// "Эффект" — обрабатывает асинхронный экшен
const todosEffect$ = loadTodos$.pipe(
mergeMap(() =>
from(fetch('/api/todos').then(r => r.json())).pipe(
map(todos => ({ type: 'LOAD_TODOS_SUCCESS' as const, payload: todos })),
startWith({ type: 'SET_LOADING' as const, payload: true }),
catchError(err => [{
type: 'LOAD_TODOS_ERROR' as const,
payload: err.message
}])
)
)
);
// Подписываемся и диспатчим результаты
todosEffect$.subscribe(action => store.dispatch(action));
// Вызвать загрузку:
loadTodos$.next();

Ошибка 1: Мутировать стейт напрямую

// ❌ Мутация — подписчики не получат обновление!
const state = store.getValue();
state.todos.push({ id: 1, text: 'task', done: false });
store.next(state); // ссылка та же — distinctUntilChanged пропустит!
// ✅ Всегда создавай новый объект
store.next({
...state,
todos: [...state.todos, { id: 1, text: 'task', done: false }],
});

Ошибка 2: Подписываться на весь стор без селектора

// ❌ Перерендер при ЛЮБОМ изменении стейта
useEffect(() => {
const sub = appStore$.subscribe(state => setTodos(state.todos));
return () => sub.unsubscribe();
}, []);
// ✅ Только релевантные изменения
useEffect(() => {
const sub = appStore$.pipe(
map(s => s.todos),
distinctUntilChanged()
).subscribe(setTodos);
return () => sub.unsubscribe();
}, []);


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