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

24. Паттерны управления состоянием

🏗️ Паттерны управления состоянием в Solid.js

Заголовок раздела «🏗️ Паттерны управления состоянием в Solid.js»

Привет! 👋 Выбор паттерна управления состоянием — это архитектурное решение, которое определяет как данные текут через приложение. Solid.js очень гибок: от простейших локальных сигналов до полноценных Flux-подобных магазинов.

Думай о паттернах состояния как об инструментах в ящике: маленький винтик — обычная отвёртка (локальный сигнал), большой болт — гаечный ключ (service store), а когда нужно всё разобрать — набор инструментов (flux).


1️⃣ Паттерн: Локальное состояние (Local Signal)

Заголовок раздела «1️⃣ Паттерн: Локальное состояние (Local Signal)»

Самый простой и частый случай — состояние, которое нужно только одному компоненту:

import { createSignal, createMemo } from 'solid-js';
// Счётчик — всё внутри компонента
function Counter() {
const [count, setCount] = createSignal(0);
const doubled = createMemo(() => count() * 2);
return (
<div>
<p>Счётчик: {count()}</p>
<p>Удвоенный: {doubled()}</p>
<button onClick={() => setCount(n => n + 1)}>+</button>
</div>
);
}
// Форма с локальным состоянием
function LoginForm() {
const [email, setEmail] = createSignal('');
const [password, setPassword] = createSignal('');
const [errors, setErrors] = createSignal<Record<string, string>>({});
const isValid = createMemo(() =>
email().includes('@') && password().length >= 8
);
const handleSubmit = (e: Event) => {
e.preventDefault();
if (!isValid()) {
setErrors({ email: 'Неверный формат', password: 'Минимум 8 символов' });
return;
}
// submit...
};
return (
<form onSubmit={handleSubmit}>
<input value={email()} onInput={e => setEmail(e.target.value)} />
<input type="password" value={password()} onInput={e => setPassword(e.target.value)} />
<button disabled={!isValid()}>Войти</button>
</form>
);
}

Когда использовать: состояние UI (открыт/закрыт), промежуточные значения форм, локальные вычисления. Не выносить наружу если никто другой не использует.


Когда несколько компонентов должны разделять одно состояние — поднимай сигнал в общего родителя:

import { createSignal, Accessor, Setter } from 'solid-js';
// Дочерние компоненты получают accessor и setter через пропсы
function DisplayPanel({ count }: { count: Accessor<number> }) {
return <div>Текущее: {count()}</div>;
}
function ControlPanel({ setCount }: { setCount: Setter<number> }) {
return (
<div>
<button onClick={() => setCount(0)}>Сброс</button>
<button onClick={() => setCount(n => n - 1)}>−</button>
<button onClick={() => setCount(n => n + 1)}>+</button>
</div>
);
}
function HistoryPanel({ count }: { count: Accessor<number> }) {
const [history, setHistory] = createSignal<number[]>([]);
// Реагируем на изменения count и пишем в историю
createEffect(() => {
setHistory(h => [...h.slice(-9), count()]);
});
return (
<ul>
{history().map((v, i) => <li key={i}>{v}</li>)}
</ul>
);
}
// Родитель владеет состоянием — "single source of truth"
function App() {
const [count, setCount] = createSignal(0);
return (
<div>
<DisplayPanel count={count} />
<ControlPanel setCount={setCount} />
<HistoryPanel count={count} />
</div>
);
}

Когда использовать: 2-3 компонента-сиблинга, нет глубокой вложенности, нет необходимости в глобальном доступе.


Для глобального состояния создаём модуль-сервис — синглтон с сигналами и методами:

stores/userStore.ts
import { createSignal, createMemo } from 'solid-js';
import { createStore } from 'solid-js/store';
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user';
}
// Создаём сигналы/сторы ОДИН РАЗ на уровне модуля
const [user, setUser] = createSignal<User | null>(null);
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
// Вычисляемые значения
const isLoggedIn = createMemo(() => user() !== null);
const isAdmin = createMemo(() => user()?.role === 'admin');
// Методы для изменения состояния
async function login(email: string, password: string) {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error('Неверные данные');
const data = await res.json();
setUser(data.user);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}
function logout() {
setUser(null);
}
// Экспортируем как объект-сервис
export const userService = {
// Геттеры (не вызываем — передаём как Accessor)
user,
loading,
error,
isLoggedIn,
isAdmin,
// Методы
login,
logout,
};
// В компонентах — просто импортируем сервис
import { userService } from '~/stores/userStore';
function Header() {
return (
<header>
<Show when={userService.isLoggedIn()}>
<span>Привет, {userService.user()?.name}!</span>
<button onClick={userService.logout}>Выйти</button>
</Show>
</header>
);
}
function AdminPanel() {
return (
<Show when={userService.isAdmin()}>
<div>Панель администратора</div>
</Show>
);
}

Когда использовать: глобальное состояние (авторизация, тема, язык), бизнес-логика которая нужна в разных частях приложения.


Для сложной логики с предсказуемыми переходами состояний:

stores/cartStore.ts
import { createStore, produce } from 'solid-js/store';
interface CartItem {
id: number;
name: string;
price: number;
qty: number;
}
interface CartState {
items: CartItem[];
coupon: string | null;
discount: number;
}
// State
const [cart, setCart] = createStore<CartState>({
items: [],
coupon: null,
discount: 0,
});
// Actions (чистые операции)
const cartActions = {
addItem(item: Omit<CartItem, 'qty'>) {
const idx = cart.items.findIndex(i => i.id === item.id);
if (idx >= 0) {
setCart('items', idx, 'qty', n => n + 1);
} else {
setCart('items', [...cart.items, { ...item, qty: 1 }]);
}
},
removeItem(id: number) {
setCart('items', items => items.filter(i => i.id !== id));
},
updateQty(id: number, qty: number) {
if (qty <= 0) {
cartActions.removeItem(id);
return;
}
const idx = cart.items.findIndex(i => i.id === id);
if (idx >= 0) setCart('items', idx, 'qty', qty);
},
applyCoupon(code: string) {
const coupons: Record<string, number> = {
'SOLID10': 0.1,
'SOLID20': 0.2,
};
const discount = coupons[code.toUpperCase()];
if (discount) {
setCart({ coupon: code, discount });
} else {
throw new Error('Неверный купон');
}
},
clear() {
setCart({ items: [], coupon: null, discount: 0 });
},
};
// Вычисляемые значения (Selectors)
import { createMemo } from 'solid-js';
const subtotal = createMemo(() =>
cart.items.reduce((sum, item) => sum + item.price * item.qty, 0)
);
const total = createMemo(() =>
subtotal() * (1 - cart.discount)
);
const itemCount = createMemo(() =>
cart.items.reduce((sum, item) => sum + item.qty, 0)
);
export { cart, cartActions, subtotal, total, itemCount };

5️⃣ Паттерн: Оптимистичные обновления (Optimistic Updates)

Заголовок раздела «5️⃣ Паттерн: Оптимистичные обновления (Optimistic Updates)»

Обновляем UI немедленно, а при ошибке откатываемся:

import { createSignal } from 'solid-js';
import { createStore } from 'solid-js/store';
interface Todo {
id: number;
text: string;
completed: boolean;
optimistic?: boolean; // помечаем оптимистичные изменения
}
function createTodosStore() {
const [todos, setTodos] = createStore<Todo[]>([]);
async function toggleTodo(id: number) {
const todo = todos.find(t => t.id === id);
if (!todo) return;
// 1. Оптимистично обновляем UI
setTodos(t => t.id === id, { completed: !todo.completed, optimistic: true });
try {
// 2. Отправляем запрос на сервер
await fetch(`/api/todos/${id}`, {
method: 'PATCH',
body: JSON.stringify({ completed: !todo.completed }),
});
// 3. Успех — снимаем метку оптимистичного обновления
setTodos(t => t.id === id, 'optimistic', false);
} catch {
// 4. Ошибка — откатываемся обратно!
setTodos(t => t.id === id, { completed: todo.completed, optimistic: false });
}
}
async function addTodo(text: string) {
// Временный ID для оптимистичного UI
const tempId = -Date.now();
setTodos([...todos, { id: tempId, text, completed: false, optimistic: true }]);
try {
const res = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ text }),
});
const newTodo = await res.json();
// Заменяем временный элемент реальным
setTodos(t => t.id === tempId, { ...newTodo, optimistic: false });
} catch {
// Откат — удаляем оптимистичный элемент
setTodos(todos.filter(t => t.id !== tempId));
}
}
return { todos, toggleTodo, addTodo };
}

import { createSignal } from 'solid-js';
function createUndoableSignal<T>(initial: T, maxHistory = 50) {
const [history, setHistory] = createSignal<T[]>([initial]);
const [pointer, setPointer] = createSignal(0);
const current = () => history()[pointer()];
const push = (value: T) => {
// Убираем "будущие" состояния при новом действии
const newHistory = [...history().slice(0, pointer() + 1), value];
if (newHistory.length > maxHistory) newHistory.shift();
setHistory(newHistory);
setPointer(newHistory.length - 1);
};
const undo = () => {
if (pointer() > 0) setPointer(p => p - 1);
};
const redo = () => {
if (pointer() < history().length - 1) setPointer(p => p + 1);
};
const canUndo = () => pointer() > 0;
const canRedo = () => pointer() < history().length - 1;
return { current, push, undo, redo, canUndo, canRedo };
}
// Использование
function TextEditor() {
const { current, push, undo, redo, canUndo, canRedo } =
createUndoableSignal('');
return (
<div>
<button onClick={undo} disabled={!canUndo()}>↩ Undo</button>
<button onClick={redo} disabled={!canRedo()}>↪ Redo</button>
<textarea
value={current()}
onInput={(e) => push(e.target.value)}
/>
</div>
);
}

// ❌ Дублирование состояния — две истины
const [user, setUser] = createSignal<User | null>(null);
const [userName, setUserName] = createSignal(''); // Дубль!
// Если setUser(), userName() станет неактуальным
// ✅ Вычисляй производные через createMemo
const userName = createMemo(() => user()?.name ?? '');
// ❌ Мутация Store напрямую (не через setStore)
const [store, setStore] = createStore({ items: [] });
// store.items.push(item); // ❌ — реактивность не сработает!
setStore('items', [...store.items, item]); // ✅
// ❌ Синглтон-стор создан внутри компонента — пересоздаётся!
function MyComponent() {
// ❌ Создаётся при каждом рендере компонента!
const [globalState, setGlobalState] = createSignal(0);
return <div>{globalState()}</div>;
}
// ✅ Синглтон — на уровне модуля
const [globalState, setGlobalState] = createSignal(0); // ← вне компонента
function MyComponent() {
return <div>{globalState()}</div>; // ✅
}

🎯 Playground: Архитектурное сравнение паттернов

Заголовок раздела «🎯 Playground: Архитектурное сравнение паттернов»