@qtpy/state-management-react
v0.2.104
Published
1. [Основная идея и архитектура](#основная-идея-и-архитектура) 1.1. [Что такое `createReactStore`](#основная-идея-и-архитектура) 1.2. [Как устроены подписки и хуки](#как-устроены-подписки-и-хуки) 1.3. [Преимущества и особенности подхода](#п
Maintainers
Readme
Документация по createReactStore
Основная идея и архитектура
1.1. Что такоеcreateReactStore
1.2. Как устроены подписки и хуки
1.3. Преимущества и особенности подходаAPI
createReactStore
2.1.store.reloadComponents(cacheKeys)
2.2.store.useStore(paths, options?)
2.3.store.useField(path, options?)
2.4.store.useEffect(paths, effect, options?)Примеры использования
3.1. Типовое состояниеUserStore
3.2. КомпонентProfileРеализация игры 15-Puzzle
4.1. Логика хранилища и действий
4.2. КомпонентTile
4.3. КомпонентPuzzleGame
Основная идея и архитектура
createReactStore — это обёртка над @qtpy/state-management-observable, Адаптирована для React. Создаёт хранилище, которое одновременно использует систему подписок и систему Proxy. Эти системы работают параллельно и независимо друг от друга — подписки не связаны с Proxy. Хранилище предоставляет удобные React-хуки для взаимодействия с состоянием.
Как устроены подписки и хуки
useStore— подписка на массив путей (строк или Accessor-ов), возвращающая их текущие значения и обновляющая компонент при изменении.useField— хук для работы с одним путём (строкой или Accessor), возвращающий[value, setValue], гдеsetValueимеет метод.quiet()для "тихого" обновления.useEffect(реализован какuseStoreEffect) — аналогuseEffect, но срабатывает при изменении значений по указанным путям.reloadComponents— ручная инвалидизацияcacheKeysдля форсированной перерисовки подписчиков.
Преимущества и особенности подхода:
- Используется
createObservableStoreиз@qtpy/state-management-observable. - Реализация React-хуков основана на
useSyncExternalStore, с хранением путей и кешей черезuseRefдля минимизации лишних подписок и ререндеров. - Подписка осуществляется через массив путей вида
Array<string | Accessor<any>>, что позволяет отслеживать вложенные и вычисляемые значения.
Такой подход сохраняет преимущество "чистого" ядра ObservableStore (подписки по пути, middleware, batching, undo/redo, async), но даёт удобный интерфейс React-хуков с минимальным boilerplate.
1. API createReactStore
1.6. store.reloadComponents(pathOrAccessor[])
Инвалидирует указанные cacheKeys, чтобы подписанные компоненты перерисовались.
store.reloadComponents(["user.preferences.theme"]); // через строковый путь
store.reloadComponents([($) => $.user.preferences.theme]); // через селектор1.7. store.useStore(pathOrAccessor[], options?)
Хук React для подписки на массив значений.
paths— список путей (строк или Accessor).options.cacheKeys?— опциональные ключи кеша для ручной инвалидизации.
const [name, age] = userStore.useStore(["user.name", "user.age"]);
// или через селектор
const [name, age] = userStore.useStore([($) => $.user.name, ($) => $.user.age]); 1.8. store.useField(pathOrAccessor, options?)
Хук React для одного значения. Возвращает кортеж [value, setValue], где setValue — функция с методом .quiet().
const [count, setCount] = counterStore.useField("counter.value");
// или через селектор
const [count, setCount] = counterStore.useField(($) => counter.value);
setCount(42); // обычное обновление
setCount.quiet(43); // тихое обновление (без ререндеров)1.9. store.useEffect(pathOrAccessor[], effect, options?)
Хук, вызывающий effect, если изменилось хотя бы одно из значений по путям.
userStore.useEffect(["user.age"], ([age]) => {
console.log("Возраст изменился:", age);
});
// или через селектор
userStore.useEffect([($) => $.user.age], ([age]) => {
console.log("Возраст изменился:", age);
});2. Пример использования хуков
2.1. Типовое состояние UserStore
type UserState = {
user: { name: string; age: number };
online: boolean;
};
export const userStore = createReactStore<UserState>({
user: { name: "Alice", age: 30 },
online: false,
});2.2. Компонент Profile
const Profile: React.FC = () => {
const [name, isOnline] = userStore.useStore(["user.name", "online"]);
const [age, setAge] = userStore.useField("user.age");
userStore.useEffect(["user.age"], ([age]) => {
console.log("Возраст пользователя изменился:", age);
});
return (
<div>
<h2>
{name} {isOnline ? "🟢" : "🔴"}
</h2>
<p>Возраст: {age}</p>
<button onClick={() => setAge((a) => a + 1)}>+</button>
</div>
);
};Здесь:
- При изменении
user.nameилиonlineкомпонент сразу ререндерится. - Хук
useField("user.age")даётageиsetAge(обновление черезstore.update("user.age", newAge)). useEffect(["user.age"], callback)будет вызываться при каждом изменении возраста.
3. Реализация игры 15-Puzzle
Ниже приведён полный пример игры «15-Puzzle», построенной на createReactStore. Все пути задаются строками вида "board.0.0", но мы можем также использовать Accessor-функции.
3.1. Инициализация хранилища и логика
// store.ts
import { createReactStore } from "@qtpy/state-management-react";
export type PuzzleState = {
board: (number | null)[][]; // 4×4 поле
moves: number; // счётчик ходов
isSolved: boolean; // флаг «решена ли»
};
export const puzzleStore = createReactStore<PuzzleState>({
board: [
[1, 2, 3, 4],
[5, 6, 7, 8],
[9, 10, 11, 12],
[13, 14, 15, null],
],
moves: 0,
isSolved: false,
});
/** Проверка, решена ли головоломка */
export const checkSolved = (board: (number | null)[][]): boolean => {
const flat = board.flat();
for (let i = 0; i < flat.length - 1; i++) {
if (flat[i] !== i + 1) return false;
}
return true;
};
/** Поиск координат пустой ячейки */
export const findEmptyTile = (board: (number | null)[][]) => {
for (let r = 0; r < board.length; r++) {
for (let c = 0; c < board[r].length; c++) {
if (board[r][c] === null) {
return { row: r, col: c };
}
}
}
throw new Error("Пустая ячейка не найдена");
};
/** Проверка возможности перемещения плитки */
export const canMoveTile = (
row: number,
col: number,
empty: { row: number; col: number }
): boolean => {
return (
(Math.abs(row - empty.row) === 1 && col === empty.col) ||
(Math.abs(col - empty.col) === 1 && row === empty.row)
);
};
/** Сдвиг плитки и обновление состояния */
export const moveTile = (row: number, col: number) => {
const board = puzzleStore.get("board")!;
const empty = findEmptyTile(board);
if (!canMoveTile(row, col, empty)) return;
puzzleStore.batch(() => {
// 1) Увеличиваем счётчик
puzzleStore.$.moves += 1;
// 2) Меняем местами значения в board
const tileValue = board[row][col]!;
puzzleStore.update(`board.${row}.${col}`, null);
// или через селектор
puzzleStore.update(
($, t) => $.board[t(empty.row)][t(empty.col)],
tileValue
);
// 3) Проверяем, решена ли головоломка
const newBoard = puzzleStore.get(() => $.board)!;
puzzleStore.update(($) => $.isSolved, checkSolved(newBoard));
});
};
/** Перемешивание плиток */
export const shuffleTiles = () => {
puzzleStore.batch(() => {
const flat = puzzleStore.get("board")!.flat();
const shuffled = [...flat].sort(() => Math.random() - 0.5);
// Собираем новое поле 4×4
const newBoard: (number | null)[][] = [];
shuffled.forEach((val, i) => {
const r = Math.floor(i / 4);
if (!newBoard[r]) newBoard[r] = [];
newBoard[r][i % 4] = val;
});
puzzleStore.update("board", newBoard);
puzzleStore.update("moves", 0);
puzzleStore.update("isSolved", false);
});
};3.2. Компонент плитки Tile.tsx
// Tile.tsx
import { memo } from "react";
import { puzzleStore, moveTile } from "./store";
export const Tile = memo(({ row, col }: { row: number; col: number }) => {
// Подписываемся только на эту ячейку
const [value] = puzzleStore.useStore([($, t) => $.board[t(row)][t(col)]]);
// Подписываемся на флаг решения
const [isSolved] = puzzleStore.useField(($) => $.isSolved);
return (
<button
onClick={() => moveTile(row, col)}
disabled={value === null || isSolved}
className="tile"
>
{value}
</button>
);
});useStore([($, t) => $.board[t(row)][t(col)]])— подписка на конкретное полеpuzzleStore.$.board[t(row)][t(col).useField(($) => $.isSolved)— кортеж[isSolved, setSolved], но мы здесь только читаем и отключаем кнопку, если головоломка решена.
3.3. Основной компонент PuzzleGame.tsx
// PuzzleGame.tsx
import React from "react";
import { puzzleStore, shuffleTiles } from "./store";
import { Tile } from "./Tile";
import "./styles.css";
export const PuzzleGame: React.FC = () => {
// Подписываемся сразу на два значения: number of moves и флаг isSolved
const [moves, isSolved] = puzzleStore.useStore(["moves", "isSolved"]);
return (
<div className="puzzle-game">
<h1>15-Puzzle</h1>
<div className="controls">
<button onClick={shuffleTiles}>Shuffle</button>
<span>Moves: {moves}</span>
</div>
{isSolved && <div className="victory">🎉 You won!</div>}
<div className="board">
{Array.from({ length: 4 }).map((_, row) => (
<div key={row} className="row">
{Array.from({ length: 4 }).map((_, col) => (
<Tile key={`${row}-${col}`} row={row} col={col} />
))}
</div>
))}
</div>
</div>
);
};- При нажатии на кнопку
ShuffleвызываетсяshuffleTiles, который перемешивает поле и сбрасывает счётчики. - Каждый
<Tile>рендерится отдельно и следит только за своим полем и флагомisSolved.
Итоги
Структура. Мы создали
createReactStore, используем массивы строкstringили функцийAccessor<any>.Хуки.
useStore(paths, { cacheKeys? })— подписка на несколько полей.useField(path, { cacheKeys? })— подписка на одно поле с функцией для обновления.useStoreEffect(paths, effect, { cacheKeys? })— какuseEffect, но срабатывает при изменении списка путей.reloadComponents(cacheKeys)— вручную инвалидирует подписки по переданным ключам.
Игра 15-Puzzle демонстрирует:
- Как описать тип состояния и инициализировать его.
- Как подписать компонент плитки только на нужное поле.
- Как подписать главный компонент сразу на несколько значений.
- Как использовать
batchдля групповых обновлений, чтобы минимизировать ререндеры.
Таким образом, createReactStore предоставляет полный набор реактивных инструментов для построения динамических React-приложений с минимальным количеством шаблонного кода.
