Multiplayer is a Zustand middleware that adds real-time synchronization capabilities to your stores. When you wrap your store with the multiplayer middleware, every state change is automatically:
- Synchronized across all connected clients in real-time via WebSockets
- Persisted to a distributed database with atomic operations
- Shared with all connected users instantly
No WebSocket server needed! Multiplayer is built on top of HPKV's WebSocket API, so you don't need to set up or maintain any server infrastructure. Just create a free HPKV API key in a few clicks, configure your store options, and you're ready to go.
Think of it as adding a "sync engine" to your existing Zustand store - turning any local state into shared, collaborative state that multiple users can interact with simultaneously.
Transform any Zustand store into a real-time synchronized multiplayer experience with just one line of code.
// Before: Local Zustand store
const useStore = create((set) => ({
todos: {},
addTodo: (text) => set(state => ...)
}));
// After: Real-time multiplayer store
const useStore = create(
multiplayer((set) => ({
todos: {},
addTodo: (text) => set(state => ...)
}), { namespace: 'my-app' })
);
That's it! Your store now syncs in real-time across all connected clients. 🎉
Building real-time collaborative features is complex. You need WebSockets, conflict resolution, state persistence, and synchronization logic. Zustand Multiplayer handles all of this for you:
- 🔄 Instant Synchronization - State changes propagate to all clients in milliseconds
- 💾 Automatic Persistence - State survives page refreshes and reconnections
- 🎯 Selective Sync - Choose exactly what to share vs keep local
- ⚡ Optimized Performance - Granular updates, minimal network traffic
- 🔌 Works Everywhere - React, Node.js, vanilla JavaScript, Client, Server - anywhere Zustand works
npm install @hpkv/zustand-multiplayer zustand
Sign up at hpkv.io and get your API credentials from the dashboard.
// store.ts
import { create } from 'zustand';
import { multiplayer, WithMultiplayer } from '@hpkv/zustand-multiplayer';
interface AppState {
count: number;
increment: () => void;
}
export const useStore = create<WithMultiplayer<AppState>>()(
multiplayer(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}),
{
namespace: 'counter-app', // Unique identifier for your app
apiBaseUrl: process.env.NEXT_PUBLIC_HPKV_API_BASE_URL!,
tokenGenerationUrl: '/api/generate-token', // Your auth endpoint
}
)
);
Create an endpoint to generate tokens for client authentication:
// pages/api/generate-token.ts (Next.js) or server.js (Express)
import { TokenHelper } from '@hpkv/zustand-multiplayer';
const tokenHelper = new TokenHelper(
process.env.HPKV_API_KEY!,
process.env.HPKV_API_BASE_URL!
);
export default async function handler(req, res) {
// Add your authentication logic here
// const user = await authenticate(req);
// if (!user) return res.status(401).json({ error: 'Unauthorized' });
const response = await tokenHelper.processTokenRequest(req.body);
res.status(200).json(response);
}
// App.tsx
import { useStore } from './store';
function App() {
const { count, increment, multiplayer } = useStore();
return (
<div>
<h1>Count: {count}</h1>
<button onClick={increment}>+1</button>
<p>Open this page in multiple tabs to see real-time sync!</p>
<p>Status: {multiplayer.connectionState}</p>
</div>
);
}
That's it! Your app now syncs in real-time. Open it in multiple browser tabs to see the magic. ✨
A namespace is a unique identifier that determines which stores sync together. Think of it as a "room" where all stores with the same namespace share state.
// All stores with namespace 'team-dashboard' will sync together
{ namespace: 'team-dashboard' }
// Different namespaces = isolated data
{ namespace: 'team-dashboard' } // These sync together
{ namespace: 'user-settings' } // This is completely separate
Best Practices:
- Use descriptive, unique namespaces:
todo-app-v1
,game-room-${roomId}
- Version your namespaces when making breaking changes:
app-v1
→app-v2
- Use dynamic namespaces for isolated sessions:
meeting-${meetingId}
When creating a store using multiplayer, you either need to provide HPKV API key or a token generation url. As API key should never be exposed on client-side, for client-side usage always setup a token generation endpoint, but for server-side usage, you can use the API key directly.
See the documentation on how to set up the token generation endpoint in the Token Generation Guideline
Client-side (Web Apps):
// Never expose API keys in client code!
{
namespace: 'my-app',
apiBaseUrl: process.env.NEXT_PUBLIC_HPKV_API_BASE_URL,
tokenGenerationUrl: '/api/generate-token', // Secure backend endpoint
}
Server-side (Node.js):
// Safe to use API key directly on server
{
namespace: 'my-app',
apiBaseUrl: process.env.HPKV_API_BASE_URL,
apiKey: process.env.HPKV_API_KEY, // Direct API key usage
}
By default, multiplayer
syncs all the state with other clients, but it also allows you to control exactly what syncs and what stays local through sync
option:
const useStore = create(
multiplayer(
(set) => ({
// Shared data
sharedTodos: {},
teamSettings: {},
// Local data
draftText: '',
userPreferences: {},
// Actions...
}),
{
namespace: 'my-app',
// Only sync these fields
sync: ['sharedTodos', 'teamSettings'],
// Everything else stays local
}
)
);
The zFactor
controls how deeply nested objects are stored, affecting conflict resolution and performance:
-
Higher zFactor: More storage granularity, less conflicts, potentially more calls over network for state upodates
-
Lower zFactor: Less storage granularity, more chances of conflict, reduce calls over network for state updates
// Example state structure
{
users: {
user1: { name: 'Alice', score: 10 },
user2: { name: 'Bob', score: 20 }
}
}
zFactor: 0 (Atomic) zFactor: 1 (Default) zFactor: 2 (Granular)
┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐
│ Store entire │ │ Each user │ │ Each property │
│ 'users' object │ │ stored │ │ stored │
│ as one unit │ │ separately │ │ separately │
└─────────────────┘ └──────────────┘ └─────────────────┘
↓ ↓ ↓
users → {...} users:user1 → {...} users:user1:name → 'Alice'
users:user2 → {...} users:user1:score → 10
users:user2:name → 'Bob'
users:user2:score → 20
Adjust the zFactor to optimize the performance, conflict management and network saturation. If there are properties that usually are updated together, best is to adjust the zFactor in a way that stores all those properties in a single key, but if the properties are updated independently and concurrently by other users, adjust it to store each property in a single key.
If you don't set zFactor option, the default zFactor is 2 (three levels of storage granularity from root)
const usePollStore = create(
multiplayer(
(set) => ({
votes: {} as Record<string, number>,
vote: (option: string) => set((state) => {
state.votes[option] = (state.votes[option] || 0) + 1;
}),
}),
{ namespace: `poll-${pollId}` }
)
);
const usePresenceStore = create(
multiplayer(
(set) => ({
users: {} as Record<string, { name: string; cursor: { x: number; y: number } }>,
updateCursor: (userId: string, x: number, y: number) => set((state) => {
state.users[userId] = { ...state.users[userId], cursor: { x, y } };
}),
}),
{
namespace: 'collaborative-canvas',
zFactor: 2, // Granular updates for smooth cursor movement
}
)
);
const useGameStore = create(
multiplayer(
(set) => ({
players: {} as Record<string, Player>,
gameState: 'waiting' as 'waiting' | 'playing' | 'finished',
scores: {} as Record<string, number>,
joinGame: (playerId: string, name: string) => set((state) => {
state.players[playerId] = { id: playerId, name, ready: false };
}),
updateScore: (playerId: string, points: number) => set((state) => {
state.scores[playerId] = (state.scores[playerId] || 0) + points;
}),
}),
{
namespace: `game-room-${roomId}`,
zFactor: 1, // Player-level granularity
}
)
);
const useFormStore = create(
multiplayer(
(set) => ({
formData: {},
fieldLocks: {} as Record<string, string>, // Track who's editing what
updateField: (field: string, value: any, userId: string) => set((state) => {
if (!state.fieldLocks[field] || state.fieldLocks[field] === userId) {
state.formData[field] = value;
state.fieldLocks[field] = userId;
}
}),
releaseField: (field: string) => set((state) => {
delete state.fieldLocks[field];
}),
}),
{
namespace: `form-${formId}`,
sync: ['formData', 'fieldLocks'], // Don't sync local validation errors
}
)
);
// Server-side (Node.js)
import { createStore } from 'zustand/vanilla';
const broadcastStore = createStore(
multiplayer(
(set) => ({
notifications: [] as Notification[],
broadcast: (message: string) => set((state) => ({
notifications: [...state.notifications, {
id: Date.now(),
message,
timestamp: new Date(),
}],
})),
}),
{
namespace: 'system-notifications',
apiKey: process.env.HPKV_API_KEY, // Server uses API key directly
}
)
);
// Broadcast to all clients
broadcastStore.getState().broadcast('System maintenance at 5 PM');
For applications with high-frequency updates, consider these optimization strategies:
// Example: Optimizing a collaborative drawing app
const useCanvasStore = create(
multiplayer(
(set) => ({
strokes: {},
currentStroke: null,
// Batch updates for better performance
updateStroke: debounce((strokeId, points) => set((state) => {
state.strokes[strokeId] = points;
}), 100), // Debounce to max 10 updates/second. This should not exceed the rate limit for better performance
}),
{
namespace: 'canvas',
rateLimit: 10, // Match your HPKV tier (Free: 10/s, Pro: 100/s)
zFactor: 1, // Store each stroke separately
}
)
);
Performance Tips:
-
Set
rateLimit
to match your HPKV tier to enable automatic throttling - Use debouncing for high-frequency events (mouse moves, typing)
- Batch updates when possible to reduce network calls
-
Choose appropriate
zFactor
- higher values mean more granular updates but more keys
Zustand Multiplayer works seamlessly with other middlewares:
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import { subscribeWithSelector } from 'zustand/middleware';
import { multiplayer } from '@hpkv/zustand-multiplayer';
const useStore = create(
multiplayer(
subscribeWithSelector(
immer((set) => ({
// Immer allows direct mutations
todos: {},
addTodo: (text: string) => set((state) => {
const id = Date.now().toString();
state.todos[id] = { id, text, completed: false }; // Direct mutation!
}),
toggleTodo: (id: string) => set((state) => {
state.todos[id].completed = !state.todos[id].completed;
}),
}))
),
{ namespace: 'todos-with-immer' }
)
);
// Subscribe to specific changes
useStore.subscribe(
(state) => state.todos,
(todos) => console.log('Todos changed:', todos)
);
function ConnectionMonitor() {
const { multiplayer } = useStore();
return (
<div>
<p>Status: {multiplayer.connectionState}</p>
<p>Round Trip Latency: {multiplayer.performanceMetrics.averageSyncTime}ms</p>
<button onClick={() => multiplayer.reHydrate()}>Force Sync</button>
</div>
);
}
Zustand Multiplayer is built with TypeScript-first design and provides full type safety for your multiplayer stores.
Always use the WithMultiplayer<T>
wrapper type to ensure proper typing:
import { create } from 'zustand';
import { multiplayer, WithMultiplayer } from '@hpkv/zustand-multiplayer';
interface TodoState {
todos: Record<string, Todo>;
filter: 'all' | 'active' | 'completed';
addTodo: (text: string) => void;
toggleTodo: (id: string) => void;
setFilter: (filter: TodoState['filter']) => void;
}
// Use WithMultiplayer wrapper
const useTodoStore = create<WithMultiplayer<TodoState>>()(
multiplayer(
(set) => ({
todos: {},
filter: 'all',
addTodo: (text) => set((state) => ({
todos: {
...state.todos,
[Date.now().toString()]: { id: Date.now().toString(), text, completed: false }
}
})),
toggleTodo: (id) => set((state) => ({
todos: {
...state.todos,
[id]: { ...state.todos[id], completed: !state.todos[id].completed }
}
})),
setFilter: (filter) => set({ filter }),
}),
{
namespace: 'todos-app',
apiBaseUrl: process.env.NEXT_PUBLIC_HPKV_API_BASE_URL!,
tokenGenerationUrl: '/api/generate-token',
}
)
);
Zustand Multiplayer works anywhere Zustand works - not just React!
import { createStore } from 'zustand/vanilla';
import { multiplayer } from '@hpkv/zustand-multiplayer';
const store = createStore(
multiplayer(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}),
{
namespace: 'vanilla-counter',
apiKey: 'your-api-key', // Server-side only!
}
)
);
// Use the store
store.getState().increment();
console.log(store.getState().count);
// Subscribe to changes
store.subscribe((state) => {
document.getElementById('count').textContent = state.count;
});
import { createStore } from 'zustand/vanilla';
import { multiplayer } from '@hpkv/zustand-multiplayer';
// Create a server-side store
const metricsStore = createStore(
multiplayer(
(set) => ({
metrics: {},
updateMetric: (key, value) => set((state) => {
state.metrics[key] = value;
}),
}),
{
namespace: 'server-metrics',
apiKey: process.env.HPKV_API_KEY,
}
)
);
// Update metrics from your server
setInterval(() => {
metricsStore.getState().updateMetric('cpu', process.cpuUsage());
metricsStore.getState().updateMetric('memory', process.memoryUsage());
}, 5000);
Always implement proper authentication and authorization:
// api/generate-token.ts
export default async function handler(req, res) {
// 1. Authenticate the user
const user = await authenticateUser(req.headers.authorization);
if (!user) {
return res.status(401).json({ error: 'Unauthorized' });
}
// 2. Check permissions for the requested namespace
const { namespace } = req.body;
if (!user.canAccessNamespace(namespace)) {
return res.status(403).json({ error: 'Access denied' });
}
// 3. Rate limiting
if (await isRateLimited(user.id)) {
return res.status(429).json({ error: 'Too many requests' });
}
// 4. Generate token
const token = await tokenHelper.processTokenRequest({
...req.body
});
// 5. Log for audit
await logTokenGeneration(user.id, namespace);
return res.status(200).json(token);
}
- Never expose API keys in client-side code
- Tokens expire after 2 hours by default
- Anyone with a token can read/write to that namespace
- Implement authorization in your token endpoint
- Consider rate limiting to prevent abuse
interface MultiplayerOptions<TState> {
namespace: string; // Required: Unique identifier
apiBaseUrl: string; // Required: HPKV API URL
apiKey?: string; // Server-side only
tokenGenerationUrl?: string; // Client-side only
sync?: Array<keyof TState>; // Fields to sync (default: all non-function keys)
zFactor?: number; // Storage depth (0-10, default: 1)
logLevel?: LogLevel; // Logging verbosity
rateLimit?: number; // Throttle to N req/s (match your HPKV tier)
}
// Access via store
const { multiplayer } = useStore();
// State (reactive)
multiplayer.connectionState // 'CONNECTED' | 'DISCONNECTED' | 'CONNECTING' | 'RECONNECTING'
multiplayer.hasHydrated // boolean - Has initial sync completed
multiplayer.performanceMetrics // perfromance metrics
const store = useStore();
// Methods
await store.multiplayer.reHydrate(); // Force sync with server
await store.multiplayer.clearStorage(); // Clear all persisted data
await store.multiplayer.disconnect(); // Close connection
await store.multiplayer.connect(); // Establish connection
await store.multiplayer.destroy(); // Cleanup (call on unmount)
// Monitoring
store.store.multiplayer.getConnectionStatus(); // Detailed connection info
store.multiplayer.getMetrics(); // Performance metrics
HPKV has rate limits based on your tier (Free tier: 10 requests/second). The rateLimit
option enables automatic throttling to avoid hitting these limits:
{
namespace: 'high-frequency-app',
rateLimit: 10, // Automatically throttle to 10 updates/second
}
For high-frequency updates (e.g., mouse movements, real-time drawing):
- Consider debouncing or throttling at the application level
- Batch multiple changes into single updates
- Use higher zFactor for granular updates to reduce operation size
We provide two complete example applications demonstrating real-world usage:
1. Next.js + React Example (/examples/nextjs-todo
)
A modern React application with TypeScript showing:
- Full-stack setup with Next.js
- Token generation endpoint implementation
- React hooks integration
- TypeScript
2. Express + Vanilla JS Example (/examples/express-vanilla
)
A traditional web application demonstrating:
- Vanilla JavaScript (no framework)
- HTML5 with real-time updates
- Token endpoint with Express.js
- API Documentation - Detailed API reference
- Token Setup Guide - Authentication implementation
- Migration Guide - Upgrading from older versions
We welcome contributions! See CONTRIBUTING.md for guidelines.
git clone https://github.com/hpkv-io/zustand-multiplayer.git
cd zustand-multiplayer
npm install
npm test
- Issues: GitHub Issues
- Discussions: GitHub Discussions
- Email: support@hpkv.io
MIT © HPKV Team