@hpkv/zustand-multiplayer
TypeScript icon, indicating that this package has built-in type declarations

0.6.1 • Public • Published

Zustand Multiplayer Middleware

npm version npm downloads TypeScript License: MIT

What is Multiplayer?

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. 🎉

Why Zustand Multiplayer?

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

Installation

npm install @hpkv/zustand-multiplayer zustand

5-Minute Quick Start

1. Get Your API Credentials

Sign up at hpkv.io and get your API credentials from the dashboard.

2. Create a Multiplayer Store

// 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
    }
  )
);

3. Set Up Token Generation (Security)

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);
}

4. Use in Your App

// 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. ✨

Core Concepts

🏷️ Namespaces - Your Sync Scope

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-v1app-v2
  • Use dynamic namespaces for isolated sessions: meeting-${meetingId}

🔐 Authentication - Client vs Server

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
}

🎯 Selective Synchronization

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
    }
  )
);

🔧 zFactor - Reduce chances of conflict

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)

Exapmle Recipes

🗳️ Live Voting/Polling

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}` }
  )
);

👥 Presence & Live Cursors

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
    }
  )
);

🎮 Game State

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
    }
  )
);

📝 Collaborative Forms

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-to-Client Broadcasting

// 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');

Advanced Features

⚡ Performance Optimization

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

🔄 Middleware Composition

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)
);

📊 Monitoring & Debugging

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>
  );
}

TypeScript Usage Guide

Zustand Multiplayer is built with TypeScript-first design and provides full type safety for your multiplayer stores.

Basic Type Setup

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',
    }
  )
);

Using Without React

Zustand Multiplayer works anywhere Zustand works - not just React!

Vanilla JavaScript

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;
});

Node.js Server

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);

🔐 Security Best Practices

Token Generation Endpoint

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);
}

Important Security Notes

  • 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

API Reference

Multiplayer Options

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)
}

Multiplayer State & Methods

// 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

Rate Limiting & Throttling

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

Examples & Resources

📦 Example Applications

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

📚 Documentation

Contributing

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

Support

License

MIT © HPKV Team


Built with ❤️ by the HPKV Team | Website | Dashboard

Package Sidebar

Install

npm i @hpkv/zustand-multiplayer

Weekly Downloads

150

Version

0.6.1

License

MIT

Unpacked Size

337 kB

Total Files

12

Last publish

Collaborators

  • mehrantsi
  • ehsan.aslani