shoy
v1.5.2
Published
Lightweight state management with fast hashing for automatic deduplication and history
Downloads
63
Maintainers
Readme
Shoy
⚠️ BETA WARNING: This project is currently in BETA and is NOT recommended for production use until it reaches stable status. The API may change without notice.
State as Content-Addressed Versions. Stores are git-like repos where updates are content-addressed diffs using fast hashing. Components "checkout" commits via selectors. It's versioned, debuggable, and sync-friendly.
Installation
pnpm add shoy
# or
yarn add shoy
# or
npm install shoyRequirements:
⚛️ React >= 16.0.0
🟢 Node.js >= 18
Basic Usage
Creating a Store
import { Shoy } from 'shoy';
interface AppState {
count: number;
user: {
name: string;
age: number;
};
}
const initialState: AppState = {
count: 0,
user: {
name: 'Alice',
age: 30,
},
};
const store = new Shoy<AppState>(initialState);Advanced Store Configuration
Configure the store with options for history management and error handling:
const store = new Shoy(initialState, {
maxHistory: 50,
onError: (error, context) => {
console.error(`Error in ${context}:`, error);
},
});Options:
maxHistory: Maximum number of historical states to keep. Set to0(default) for no history, or any positive number to enable time-travel debugging.onError: Custom error handler callback that receives(error: Error, context: string).
React Integration
Reading State with useGet
Subscribe to specific parts of the state with different selector patterns:
import { useGet, useApply } from 'shoy';
function Counter() {
const state = useGet(store, (s) => s);
const userName = useGet(store, (s) => s.user.name);
const isAdult = useGet(store, (s) => s.user.age >= 18);
const count = useGet(store, (s) => s.count);
return <div>Count: {count}</div>;
}Updating State with useApply
Get a stable callback function for state updates with various update patterns:
function CounterControls() {
const apply = useApply(store);
const increment = () => {
apply({ count: store.current.count + 1 });
};
const setUser = () => {
apply({ user: { name: 'Bob', age: 25 } });
};
const incrementByTwo = () => {
apply((prev) => ({
count: prev.count + 2,
}));
};
const reset = () => {
apply({
count: 0,
user: { name: 'Alice', age: 30 },
});
};
return (
<div>
<button onClick={increment}>Increment</button>
<button onClick={setUser}>Set User</button>
<button onClick={incrementByTwo}>+2</button>
<button onClick={reset}>Reset</button>
</div>
);
}Advanced Features
Undo/Redo (Time-Travel)
Navigate through state history when maxHistory > 0:
Basic Undo/Redo:
Make changes to the state, then use undo() to go back and redo() to jump forward:
const store = new Shoy({ count: 0 }, { maxHistory: 20 });
store.apply({ count: 1 });
store.apply({ count: 2 });
store.apply({ count: 3 });
console.log(store.current);
store.undo();
console.log(store.current);
store.undo();
console.log(store.current);
store.redo();
console.log(store.current);
console.log(store.history);Full React Component with Undo/Redo Buttons:
A complete component with undo/redo functionality. Users can click any hash button to jump directly to that state:
import { useMemo } from 'react';
import { Shoy, useGet, useApply } from 'shoy';
function CounterWithUndoRedo() {
const store = useMemo(() => new Shoy({ count: 0 }, { maxHistory: 10 }), []);
const count = useGet(store, (s) => s.count);
const apply = useApply(store);
const history = useGet(store, () => store.history);
const increment = () => apply({ count: count + 1 });
const undo = () => store.undo();
const redo = () => store.redo();
const canUndo = history.indexOf(store.rootHash) > 0;
const canRedo = history.indexOf(store.rootHash) < history.length - 1;
return (
<div>
<h2>Count: {count}</h2>
<p>Position: {history.indexOf(store.rootHash) + 1} of {history.length}</p>
<button onClick={increment}>+</button>
<button onClick={undo} disabled={!canUndo}>Undo</button>
<button onClick={redo} disabled={!canRedo}>Redo</button>
<div>
{history.map((hash) => (
<button
key={hash}
onClick={() => store.revert(hash)}
style={{ fontWeight: hash === store.rootHash ? 'bold' : 'normal' }}
>
{hash.slice(0, 8)}
</button>
))}
</div>
</div>
);
}Manual State Access
Access and subscribe to state changes outside of React components:
const currentState = store.current;
const unsubscribe = store.subscribe((hash) => {
console.log('State changed, new hash:', hash);
});
unsubscribe();Manual State Updates
Update state programmatically without React:
store.apply({ count: 42 });
store.apply((prev) => ({
count: prev.count + 1,
user: { ...prev.user, age: prev.user.age + 1 },
}));
const newHash = store.apply({ count: 100 });
console.log('New state hash:', newHash);API Reference
React Hooks
useGet<S, R>(store, selector)
React hook that subscribes to state changes and returns the selected value.
function useGet<S, R>(
store: Shoy<S>,
selector: (state: S) => R
): RParameters:
store- The Shoy store instanceselector- Function that selects a portion of the state
Returns: The selected value from state
Example:
const count = useGet(store, (s) => s.count);
const userName = useGet(store, (s) => s.user.name);useApply<S>(store)
React hook that returns a stable function to apply state patches.
function useApply<S>(
store: Shoy<S>
): (patch: Patch<S>) => HashParameters:
store- The Shoy store instance
Returns: A function that applies patches and returns the new hash
Example:
const apply = useApply(store);
apply({ count: 10 });
apply((prev) => ({ count: prev.count + 1 }));Store Constructor
new Shoy<S>(initialState, options?)
Creates a new Shoy store instance.
class Shoy<S> {
constructor(
initialState: S,
options?: Options
)
}Parameters:
initialState- The initial state valueoptions- Optional configuration objectmaxHistory?: number- Maximum history versions (default:0)onError?: (error: Error, context: string) => void- Error handler callback
Example:
const store = new Shoy({ count: 0 }, { maxHistory: 50 });Store Methods & Properties
store.apply(patch)
Applies a patch to the state and returns the new state hash.
apply(patch: Patch<S>): HashParameters:
patch- Either a partial state object or a function(prev: S) => Partial<S>
Returns: The new state hash
Example:
const hash = store.apply({ count: 42 });
store.apply((prev) => ({ count: prev.count + 1 }));store.current
Synchronously returns the current state.
get current(): SExample:
const currentState = store.current;
console.log(currentState.count);store.subscribe(callback)
Subscribes to state changes and returns an unsubscribe function.
subscribe(callback: (hash: Hash) => void): () => voidParameters:
callback- Function called when state changes, receives the new hash
Returns: Unsubscribe function
Example:
const unsubscribe = store.subscribe((hash) => {
console.log('State changed:', hash);
});
unsubscribe();store.history
Returns an array of all available state hashes (only when maxHistory > 0).
get history(): Hash[]Example:
const store = new Shoy(initialState, { maxHistory: 20 });
const hashes = store.history; // ['hash1', 'hash2', ...]store.revert(hash)
Reverts the state to a previous version by hash.
revert(hash: Hash): booleanParameters:
hash- The hash of the state to revert to
Returns: true if successful, false otherwise
Example:
const success = store.revert('abc123');
if (success) {
console.log('Reverted successfully');
}store.undo()
Undoes the last state change (goes back to previous state in history).
undo(): booleanReturns: true if undo was successful, false if at beginning of history or history disabled
Example:
store.apply({ count: 1 });
store.apply({ count: 2 });
store.undo(); // count is now 1store.redo()
Redoes a previously undone state change.
redo(): booleanReturns: true if redo was successful, false if at end of history or history forked
Example:
store.undo(); // go back
store.redo(); // go forward againNote: Redo only works if history hasn't been forked (no new changes after undo).
Performance
Shoy store is optimized for performance:
- ~0.001ms update speed (synchronous hashing)
- Only 10 re-renders (5 stores, 50 components)
- Low memory overhead (fast hashes + deduplication)
- Best for versioned diffs and primitives
- Scalable and efficient
Patches support both replacement and deep merging; selectors skip unchanged diffs, making it ideal for multi-store needs without the overhead of proxies, atoms, machines, or full-tree diffing.
Advantages
Unique Features:
- Content-addressed hashing - Deterministic state IDs for deduplication
- Automatic deduplication - Identical states share memory
- Built-in time-travel - Replay, undo/redo, debugging
- Git-like versioning - See full state history
- Zero-config setup - Works out of the box
- Micro-bundle - Smallest React state library
Best For:
- Undo/redo functionality - Built-in time-travel debugging
- State debugging - Automatic version history
- Optimistic UI updates - Hash-based deduplication
- Collaborative editing / CRDTs - Foundation provided (you add transport layer)
- State synchronization - Foundation provided (you add network layer)
- Audit trails / compliance - Foundation provided (you add persistence)
- Offline-first apps - Foundation provided (you add storage layer)
How It Works
Shoy uses content-addressed versioning inspired by Git:
- Every state change computes a deterministic hash of the entire state
- Hashes serve as unique identifiers (like Git commits)
- Identical states produce identical hashes (automatic deduplication)
- Enables time-travel debugging when
maxHistory > 0 - Perfect for state synchronization between devices/apps
The hash algorithm is fast (DJB2-based) and deterministic, making it perfect for deduplication, debugging, and sync scenarios without cryptographic security requirements.
What Shoy Provides vs What You Build
✅ Built-In (Out of the Box):
Undo/Redo, Deterministic Hashing, Auto Deduplication, and Version History:
const store = new Shoy({ count: 0 }, { maxHistory: 10 });
store.apply({ count: 1 });
store.apply({ count: 2 });
store.undo();
const storeA = new Shoy({ a: 1, b: 2 });
const storeB = new Shoy({ b: 2, a: 1 });
console.log(storeA.rootHash === storeB.rootHash);
const h1 = store.apply({ count: 100 });
const h2 = store.apply({ count: 100 });
console.log(h1 === h2);
console.log(store.history);⚠️ Foundation Provided (You Add the Layer):
Not included: WebSocket/HTTP transport, conflict resolution, network retry logic, data persistence, and multi-device sync. Example of building sync on top of Shoy:
function setupSync(store) {
const socket = new WebSocket('ws://sync-server');
socket.onmessage = (event) => {
const { hash, state } = JSON.parse(event.data);
if (!store.versions.has(hash)) {
store.versions.set(hash, state);
}
};
store.subscribe((hash) => {
const state = store.current;
socket.send(JSON.stringify({ hash, state }));
});
}Key Point: Shoy gives you Git-like content-addressed storage. You build the transport layer.
TypeScript Support
Shoy is fully written in TypeScript and provides complete type inference:
interface MyState {
items: string[];
filter: string;
}
const store = new Shoy<MyState>({ items: [], filter: '' });
const filter = useGet(store, s => s.filter);
const apply = useApply(store);Contributing
Contributions are welcome! Please see our Contributing Guide for details.
When contributing, remember:
- Use
pnpm commitinstead ofgit commit(Conventional Commits required) - All PRs to
mainmust be approved - Follow the branch naming convention:
feat/,fix/,docs/, etc.
License
MIT
Author
Anton Kalik
- Email: [email protected]
- Website: https://idedy.com
