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 (открыт/закрыт), промежуточные значения форм, локальные вычисления. Не выносить наружу если никто другой не использует.
2️⃣ Паттерн: Поднятое состояние (Lifted State)
Заголовок раздела «2️⃣ Паттерн: Поднятое состояние (Lifted State)»Когда несколько компонентов должны разделять одно состояние — поднимай сигнал в общего родителя:
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 компонента-сиблинга, нет глубокой вложенности, нет необходимости в глобальном доступе.
3️⃣ Паттерн: Service Store (Singleton)
Заголовок раздела «3️⃣ Паттерн: Service Store (Singleton)»Для глобального состояния создаём модуль-сервис — синглтон с сигналами и методами:
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> );}Когда использовать: глобальное состояние (авторизация, тема, язык), бизнес-логика которая нужна в разных частях приложения.
4️⃣ Паттерн: Flux-подобный (Actions + Reducer)
Заголовок раздела «4️⃣ Паттерн: Flux-подобный (Actions + Reducer)»Для сложной логики с предсказуемыми переходами состояний:
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;}
// Stateconst [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 };}6️⃣ Паттерн: Undo/Redo
Заголовок раздела «6️⃣ Паттерн: Undo/Redo»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() станет неактуальным
// ✅ Вычисляй производные через createMemoconst 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>; // ✅}