2.0.0 β€’ Published 5 months ago

@asaidimu/react-store v2.0.0

Weekly downloads
-
License
MIT
Repository
github
Last release
5 months ago

@asaidimu/react-store

npm version License: MIT

A performant, type-safe state management solution for React with built-in persistence, extensive observability, and a robust middleware system.

⚠️ Beta Warning
This package is currently in beta. The API is subject to rapid changes and should not be considered stable. Breaking changes may occur frequently without notice as we iterate and improve. We’ll update this warning once the package reaches a stable release. Use at your own risk and share feedback or report issues to help us improve!


Table of Contents


Overview & Features

@asaidimu/react-store provides an efficient and predictable way to manage complex application state in React applications. It goes beyond basic state management by integrating features typically found in separate libraries, such as data persistence and comprehensive observability tools, directly into its core. This allows developers to build robust, high-performance applications with deep insights into state changes and application behavior.

Designed with modern React in mind, it leverages useSyncExternalStore for optimal performance and reactivity, ensuring components re-render only when relevant parts of the state change. Its flexible design supports a variety of use cases, from simple counter applications to complex data flows requiring atomic updates and cross-tab synchronization.

Key Features

  • πŸ“Š Reactive State Management: Automatically tracks dependencies to optimize component renders and ensure efficient updates.
  • πŸ›‘οΈ Type-Safe: Built with TypeScript from the ground up, providing strict type checking and a safer development experience.
  • βš™οΈ Middleware Pipeline: Implement custom logic to transform, validate, or log state changes before they are applied. Supports both transforming and blocking middleware.
  • πŸ“¦ Transaction Support: Group multiple state updates into a single atomic operation, with automatic rollback if any part of the transaction fails.
  • πŸ’Ύ Built-in Persistence: Seamlessly integrate with web storage mechanisms like IndexedDB and WebStorage (localStorage/sessionStorage), including cross-tab synchronization.
  • πŸ” Deep Observability: Gain profound insights into your application's state with built-in metrics, detailed event logging, state history, and time-travel debugging capabilities.
  • πŸ“ˆ Remote Observability: Extend monitoring by sending collected metrics and traces to external systems like OpenTelemetry, Prometheus, or Grafana Cloud.
  • ⚑ Performance Optimized: Features intelligent selector caching and debounced actions to prevent rapid successive calls and ensure smooth application performance.
  • βš›οΈ React 18+ Ready: Fully compatible with the latest React versions, leveraging modern APIs for enhanced performance and development ergonomics.
  • πŸ—‘οΈ Explicit Deletions: Use Symbol.for("delete") to explicitly remove properties from nested state objects.

Installation & Setup

Prerequisites

  • Node.js (v18 or higher recommended)
  • React (v18 or higher recommended)
  • A package manager like bun, npm, or yarn.

Installation Steps

To add @asaidimu/react-store to your project, run one of the following commands:

bun install @asaidimu/react-store
# or
npm install @asaidimu/react-store
# or
yarn add @asaidimu/react-store

Configuration

No global configuration is required. All options are passed during store creation.

Verification

You can verify the installation by importing createStore and setting up a basic store:

import { createStore } from '@asaidimu/react-store';

const myStore = createStore({
  state: { value: 'hello' },
  actions: {
    setValue: (state, newValue: string) => ({ value: newValue }),
  },
});

console.log(myStore().select(s => s.value)); // Should output 'hello'

If no errors are thrown, the package is correctly installed.

Usage Documentation

Creating a Store

Define your application state and actions, then create a store using createStore.

// src/stores/myStore.ts
import { createStore } from '@asaidimu/react-store';

interface AppState {
  count: number;
  user: { name: string; loggedIn: boolean; email?: string };
  settings: { theme: 'light' | 'dark'; notifications: boolean };
}

const initialState: AppState = {
  count: 0,
  user: { name: 'Guest', loggedIn: false },
  settings: { theme: 'light', notifications: true },
};

const myStore = createStore({
  state: initialState,
  actions: {
    increment: (state, amount: number) => ({ count: state.count + amount }),
    login: async (state, username: string, email: string) => {
      // Simulate an asynchronous API call
      await new Promise(resolve => setTimeout(resolve, 500));
      return { user: { name: username, loggedIn: true, email } };
    },
    toggleTheme: (state) => ({
      settings: { theme: state.settings.theme === 'light' ? 'dark' : 'light' },
    }),
  },
}, {
  debounceTime: 100, // Debounce actions by 100ms
  enableConsoleLogging: true, // Enable console logs for store events
  logEvents: { updates: true, transactions: true, middleware: true },
  performanceThresholds: { updateTime: 20, middlewareTime: 5 }, // Warn for slow operations
});

// Export the hook for use in components
export const useMyStore = myStore;

Using in Components

Consume your store's state and actions within your React components using the exported hook.

// src/components/MyComponent.tsx
import React from 'react';
import { useMyStore } from '../stores/myStore';

function MyComponent() {
  const { select, actions, isReady } = useMyStore();

  // Select specific parts of the state for granular re-renders
  const count = select((state) => state.count);
  const userName = select((state) => state.user.name);
  const theme = select((state) => state.settings.theme);

  // isReady indicates if persistence has loaded initial state
  if (!isReady) {
    return <div>Loading store data...</div>;
  }

  return (
    <div style={{ background: theme === 'dark' ? '#333' : '#FFF', color: theme === 'dark' ? '#FFF' : '#333' }}>
      <h1>Welcome, {userName}!</h1>
      <p>Current Count: {count}</p>
      <button onClick={() => actions.increment(1)}>Increment</button>
      <button onClick={() => actions.increment(5)}>Increment by 5 (debounced)</button>
      <button onClick={() => actions.login('Alice', 'alice@example.com')}>Login as Alice</button>
      <button onClick={() => actions.toggleTheme()}>Toggle Theme</button>
    </div>
  );
}

export default MyComponent;

Handling Deletions

To remove a property from the state, use the Symbol.for("delete") symbol in your action’s return value. The store’s internal merge function will remove the specified key from the state.

Example

import { createStore } from '@asaidimu/react-store';

const deleteStore = createStore({
  state: {
    id: 'product-123',
    name: 'Fancy Gadget',
    details: {
      color: 'blue',
      weight: '1kg',
      dimensions: { width: 10, height: 20 }
    },
    tags: ['electronics', 'new']
  },
  actions: {
    removeDetails: (state) => ({ details: Symbol.for("delete") }),
    removeDimensions: (state) => ({ details: { dimensions: Symbol.for("delete") } }),
    removeTag: (state, tagToRemove: string) => ({
      tags: state.tags.filter(tag => tag !== tagToRemove)
    }),
    clearAllExceptId: (state) => ({
      name: Symbol.for("delete"),
      details: Symbol.for("delete"),
      tags: Symbol.for("delete")
    })
  },
});

async function runDeleteExample() {
  const { select, actions } = deleteStore();

  console.log("Initial state:", select(s => s));
  // Initial state: { id: 'product-123', name: 'Fancy Gadget', details: { color: 'blue', weight: '1kg', dimensions: { width: 10, height: 20 } }, tags: ['electronics', 'new'] }

  await actions.removeDimensions();
  console.log("After removing dimensions:", select(s => s));
  // After removing dimensions: { id: 'product-123', name: 'Fancy Gadget', details: { color: 'blue', weight: '1kg' }, tags: ['electronics', 'new'] }

  await actions.removeDetails();
  console.log("After removing details:", select(s => s));
  // After removing details: { id: 'product-123', name: 'Fancy Gadget', tags: ['electronics', 'new'] }

  await actions.removeTag('new');
  console.log("After removing 'new' tag:", select(s => s));
  // After removing 'new' tag: { id: 'product-123', name: 'Fancy Gadget', tags: ['electronics'] }

  await actions.clearAllExceptId();
  console.log("After clearing all except ID:", select(s => s));
  // After clearing all except ID: { id: 'product-123' }
}

runDeleteExample();

Persistence

Persist your store's state across browser sessions or synchronize it across multiple tabs.

import { createStore, WebStoragePersistence, IndexedDBPersistence } from '@asaidimu/react-store';

// 1. Using WebStoragePersistence (localStorage by default)
// Data persists even if the browser tab is closed and reopened.
const localStorePersistence = new WebStoragePersistence('my-app-state-key');
const useLocalStore = createStore(
  {
    state: { sessionCount: 0, lastVisited: new Date().toISOString() },
    actions: {
      incrementSessionCount: (state) => ({ sessionCount: state.sessionCount + 1 }),
      updateLastVisited: () => ({ lastVisited: new Date().toISOString() }),
    },
  },
  { persistence: localStorePersistence },
);

// 2. Using WebStoragePersistence (sessionStorage)
// Data only persists for the duration of the browser tab. Clears on tab close.
const sessionStoragePersistence = new WebStoragePersistence('my-session-state-key', true);
const useSessionStore = createStore(
  {
    state: { tabSpecificData: 'initial' },
    actions: {
      updateTabSpecificData: (state, newData: string) => ({ tabSpecificData: newData }),
    },
  },
  { persistence: sessionStoragePersistence },
);

// 3. Using IndexedDBPersistence
// Ideal for larger amounts of data, offers robust cross-tab synchronization.
const indexedDBPersistence = new IndexedDBPersistence('user-profile-data');
const useUserProfileStore = createStore(
  {
    state: { userId: '', preferences: { language: 'en', darkMode: false } },
    actions: {
      setUserId: (state, id: string) => ({ userId: id }),
      toggleDarkMode: (state) => ({ preferences: { darkMode: !state.preferences.darkMode } }),
    },
  },
  { persistence: indexedDBPersistence },
);

function AppWithPersistence() {
  const { select: selectLocal, actions: actionsLocal, isReady: localReady } = useLocalStore();
  const { select: selectProfile, actions: actionsProfile, isReady: profileReady } = useUserProfileStore();

  const sessionCount = selectLocal(s => s.sessionCount);
  const darkMode = selectProfile(s => s.preferences.darkMode);

  React.useEffect(() => {
    if (localReady) {
      actionsLocal.incrementSessionCount();
      actionsLocal.updateLastVisited();
    }
    if (profileReady && !selectProfile(s => s.userId)) {
      actionsProfile.setUserId('user-' + Math.random().toString(36).substring(2, 9));
    }
  }, [localReady, profileReady]);

  if (!localReady || !profileReady) {
    return <div>Loading persisted data...</div>;
  }

  return (
    <div>
      <h3>Local Store</h3>
      <p>Session Count: {sessionCount}</p>
      
      <h3>User Profile Store (IndexedDB)</h3>
      <p>Dark Mode: {darkMode ? 'Enabled' : 'Disabled'}</p>
      <button onClick={() => actionsProfile.toggleDarkMode()}>Toggle Dark Mode</button>
    </div>
  );
}

Middleware

Middleware functions can intercept and modify or block state updates. They are executed in the order they are added.

import { createStore, Middleware, BlockingMiddleware } from '@asaidimu/react-store';

interface CartState {
  items: Array<{ id: string; name: string; quantity: number; price: number }>;
  total: number;
}

const calculateTotalMiddleware: Middleware<CartState> = (state, update) => {
  if (update.items) {
    const newItems = update.items as CartState['items'];
    const newTotal = newItems.reduce((sum, item) => sum + (item.quantity * item.price), 0);
    return { ...update, total: newTotal };
  }
  return update;
};

const validateItemMiddleware: BlockingMiddleware<CartState> = (state, update) => {
  if (update.items) {
    for (const item of update.items as CartState['items']) {
      if (item.quantity < 0) {
        console.warn('Blocked: Item quantity cannot be negative.');
        return false; // Blocks the update
      }
    }
  }
  return true; // Allows the update
};

const useCartStore = createStore({
  state: { items: [], total: 0 },
  actions: {
    addItem: (state, item: { id: string; name: string; price: number }) => {
      const existingItem = state.items.find(i => i.id === item.id);
      if (existingItem) {
        return {
          items: state.items.map(i =>
            i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
          ),
        };
      }
      return {
        items: [...state.items, { ...item, quantity: 1 }],
      };
    },
    updateQuantity: (state, id: string, quantity: number) => ({
      items: state.items.map(item => (item.id === id ? { ...item, quantity } : item)),
    }),
  },
  middleware: { calculateTotal: calculateTotalMiddleware },
  blockingMiddleware: { validateItem: validateItemMiddleware },
});

function CartComponent() {
  const { select, actions } = useCartStore();
  const items = select(s => s.items);
  const total = select(s => s.total);

  return (
    <div>
      <h2>Shopping Cart</h2>
      <ul>
        {items.map(item => (
          <li key={item.id}>
            {item.name} ({item.quantity}) - ${item.price} each
            <button onClick={() => actions.updateQuantity(item.id, item.quantity - 1)}>-</button>
            <button onClick={() => actions.updateQuantity(item.id, item.quantity + 1)}>+</button>
          </li>
        ))}
      </ul>
      <p>Total: ${total.toFixed(2)}</p>
      <button onClick={() => actions.addItem({ id: 'apple', name: 'Apple', price: 1.50 })}>Add Apple</button>
      <button onClick={() => actions.updateQuantity('apple', -1)}>Set Apple Quantity to -1 (Blocked)</button>
    </div>
  );
}

Observability

Enable metrics and debugging via the store and observer objects:

import { createStore } from '@asaidimu/react-store';

const useObservedStore = createStore(
  {
    state: { task: '', completed: false },
    actions: {
      addTask: (state, taskName: string) => ({ task: taskName, completed: false }),
      completeTask: (state) => ({ completed: true }),
    },
  },
  {
    enableMetrics: true, // Crucial for enabling the 'observer' object
    enableConsoleLogging: true, // Log events directly to browser console
    logEvents: { updates: true, middleware: true, transactions: true }, // Which event types to log
    performanceThresholds: {
      updateTime: 50, // Warn if updates take longer than 50ms
      middlewareTime: 20 // Warn if middleware takes longer than 20ms
    },
    maxEvents: 500, // Max number of events to keep in history
    maxStateHistory: 50, // Max number of state snapshots for time travel
    debounceTime: 300, // Debounce actions by 300ms to prevent rapid calls
  },
);

function DebugPanel() {
  const { actions, observer, actionTracker } = useObservedStore();

  // Access performance metrics
  const metrics = observer?.getPerformanceMetrics();

  // Access state history for time travel
  const timeTravel = observer?.createTimeTravel();

  // Access action execution history
  const actionHistory = actionTracker.getExecutions();

  return (
    <div>
      <h2>Debug Panel</h2>
      {observer && (
        <>
          <h3>Performance Metrics</h3>
          <p>Update Count: {metrics?.updateCount}</p>
          <p>Avg Update Time: {metrics?.averageUpdateTime?.toFixed(2)}ms</p>
          <p>Largest Update Size (paths): {metrics?.largestUpdateSize}</p>

          <h3>Time Travel</h3>
          <button onClick={() => timeTravel?.undo()} disabled={!timeTravel?.canUndo()}>Undo</button>
          <button onClick={() => timeTravel?.redo()} disabled={!timeTravel?.canRedo()}>Redo</button>
          <p>State History: {timeTravel?.getHistoryLength()}</p>

          <h3>Action History</h3>
          <ul>
            {actionHistory.slice(0, 5).map(exec => (
              <li key={exec.id}>
                <strong>{exec.name}</strong> ({exec.status}) - {exec.duration.toFixed(2)}ms
              </li>
            ))}
          </ul>
        </>
      )}
      <button onClick={() => actions.addTask('Learn React Store')}>Add Task</button>
      <button onClick={() => actions.completeTask()}>Complete Task</button>
    </div>
  );
}

Remote Observability

Send collected metrics and traces to external systems like OpenTelemetry, Prometheus, or Grafana Cloud for centralized monitoring.

import { createStore, useRemoteObservability } from '@asaidimu/react-store';
import React, { useEffect } from 'react';

const useRemoteStore = createStore(
  {
    state: { apiCallsMade: 0, lastApiError: null },
    actions: {
      simulateApiCall: async (state) => {
        // Simulate an error 10% of the time
        if (Math.random() < 0.1) {
          throw new Error('API request failed');
        }
        return { apiCallsMade: state.apiCallsMade + 1, lastApiError: null };
      },
      handleApiError: (state, error: string) => ({ lastApiError: error })
    },
  },
  {
    enableMetrics: true, // Required for RemoteObservability
    enableConsoleLogging: false,
    collectCategories: {
      performance: true,
      errors: true,
      stateChanges: true,
      middleware: true,
    },
    reportingInterval: 10000, // Send metrics every 10 seconds
    batchSize: 10, // Send after 10 metrics or interval, whichever comes first
    immediateReporting: false, // Don't send immediately after each metric
  }
);

function MonitoringIntegration() {
  const { store, observer } = useRemoteStore();
  const { remote, addOpenTelemetryDestination, addPrometheusDestination, addGrafanaCloudDestination } = useRemoteObservability(store, {
    serviceName: 'my-react-app',
    environment: 'development',
    instanceId: `web-client-${Math.random().toString(36).substring(2, 9)}`,
  });

  useEffect(() => {
    // Add OpenTelemetry Collector as a destination
    addOpenTelemetryDestination({
      endpoint: 'http://localhost:4318', // Default OpenTelemetry HTTP endpoint
      apiKey: 'your-otel-api-key',
      resource: { 'app.version': '1.0.0', 'host.name': 'frontend-server' }
    });

    // Add Prometheus Pushgateway as a destination
    addPrometheusDestination({
      pushgatewayUrl: 'http://localhost:9091', // Default Prometheus Pushgateway
      jobName: 'react-store-metrics',
      username: 'promuser', // Optional basic auth
      password: 'prompassword',
    });

    // Add Grafana Cloud Loki as a destination (for logs/traces)
    addGrafanaCloudDestination({
      url: 'https://loki-prod-us-central1.grafana.net', // Example Loki endpoint
      apiKey: 'your-grafana-cloud-api-key',
    });

    // Report current store metrics periodically (in addition to event-driven metrics)
    const interval = setInterval(() => {
      observer?.reportCurrentMetrics();
    }, 5000); // Report every 5 seconds

    return () => clearInterval(interval);
  }, []);

  return null; // This component doesn't render anything visually
}

// In your App component:
// <MonitoringIntegration />
// <button onClick={() => useRemoteStore().actions.simulateApiCall().catch(e => useRemoteStore().actions.handleApiError(e.message))}>
//   Simulate API Call
// </button>

Transaction Support

Group related updates that should succeed or fail together. If an error occurs within the transaction, all changes made during that transaction are automatically rolled back.

import { createStore } from '@asaidimu/react-store';

interface BankState {
  checking: number;
  savings: number;
  transactions: string[];
}

const useBankStore = createStore<BankState, any>({
  state: { checking: 1000, savings: 500, transactions: [] },
  actions: {
    transferFunds: async (state, fromAccount: 'checking' | 'savings', toAccount: 'checking' | 'savings', amount: number) => {
      if (amount <= 0) {
        throw new Error('Transfer amount must be positive.');
      }

      const newChecking = fromAccount === 'checking' ? state.checking - amount : state.checking + amount;
      const newSavings = fromAccount === 'savings' ? state.savings - amount : state.savings + amount;

      if ((fromAccount === 'checking' && newChecking < 0) || (fromAccount === 'savings' && newSavings < 0)) {
        throw new Error('Insufficient funds.');
      }

      // Simulate a complex operation that might fail
      if (amount > 700 && fromAccount === 'checking') {
        throw new Error('Large transfers from checking require additional verification.');
      }

      const newTransactions = [...state.transactions, `Transfer ${amount} from ${fromAccount} to ${toAccount}`];
      return {
        checking: newChecking,
        savings: newSavings,
        transactions: newTransactions,
      };
    },
  },
});

function BankApp() {
  const { select, actions } = useBankStore();
  const checkingBalance = select(s => s.checking);
  const savingsBalance = select(s => s.savings);
  const transactions = select(s => s.transactions);

  const handleTransfer = async (from: 'checking' | 'savings', to: 'checking' | 'savings', amount: number) => {
    try {
      await actions.transferFunds(from, to, amount);
      alert(`Successfully transferred ${amount} from ${from} to ${to}.`);
    } catch (error) {
      alert(`Transfer failed: ${error instanceof Error ? error.message : String(error)}`);
      // State is automatically rolled back if an error occurs within the transaction
    }
  };

  return (
    <div>
      <h2>Bank Accounts</h2>
      <p>Checking: ${checkingBalance.toFixed(2)}</p>
      <p>Savings: ${savingsBalance.toFixed(2)}</p>
      <h3>Recent Transactions</h3>
      <ul>
        {transactions.map((t, i) => <li key={i}>{t}</li>)}
      </ul>
      <button onClick={() => handleTransfer('checking', 'savings', 100)}>Transfer $100 (Checking to Savings)</button>
      <button onClick={() => handleTransfer('savings', 'checking', 200)}>Transfer $200 (Savings to Checking)</button>
      <button onClick={() => handleTransfer('checking', 'savings', 800)}>Transfer $800 (Will Fail)</button>
      <button onClick={() => handleTransfer('checking', 'savings', 1500)}>Transfer $1500 (Insufficient Funds)</button>
    </div>
  );
}

Event System

The store emits various events during its lifecycle, which you can subscribe to for logging, analytics, or custom side effects.

import { createStore } from '@asaidimu/react-store';
import React, { useEffect } from 'react';

const useEventStore = createStore(
  {
    state: { data: 'initial', processedCount: 0 },
    actions: {
      processData: (state, newData: string) => ({ data: newData, processedCount: state.processedCount + 1 }),
      triggerError: () => { throw new Error("Action failed intentionally"); }
    },
    middleware: {
      myLoggingMiddleware: (state, update) => {
        console.log('Middleware processing:', update);
        return update;
      }
    }
  }
);

function EventMonitor() {
  const { store } = useEventStore();
  const [eventLogs, setEventLogs] = React.useState<string[]>([]);

  useEffect(() => {
    const addLog = (message: string) => {
      setEventLogs(prev => [`${new Date().toLocaleTimeString()}: ${message}`, ...prev].slice(0, 10));
    };

    // Subscribe to specific store events
    const unsubscribeUpdateStart = store.onStoreEvent('update:start', (data) => {
      addLog(`Update Started (timestamp: ${data.timestamp})`);
    });

    const unsubscribeUpdateComplete = store.onStoreEvent('update:complete', (data) => {
      if (data.blocked) {
        addLog(`Update BLOCKED by middleware or error. Error: ${data.error?.message || 'unknown'}`);
      } else {
        addLog(`Update Completed in ${data.duration?.toFixed(2)}ms. Paths changed: ${data.changedPaths?.join(', ')}`);
      }
    });

    const unsubscribeMiddlewareStart = store.onStoreEvent('middleware:start', (data) => {
      addLog(`Middleware '${data.name}' started (${data.type})`);
    });

    const unsubscribeMiddlewareError = store.onStoreEvent('middleware:error', (data) => {
      addLog(`Middleware '${data.name}' encountered an error: ${data.error.message}`);
    });

    const unsubscribeTransactionStart = store.onStoreEvent('transaction:start', () => {
      addLog(`Transaction Started`);
    });

    const unsubscribeTransactionError = store.onStoreEvent('transaction:error', (data) => {
      addLog(`Transaction Failed: ${data.error.message}`);
    });

    const unsubscribePersistenceReady = store.onStoreEvent('persistence:ready', () => {
      addLog(`Persistence is READY.`);
    });

    // Cleanup subscriptions on component unmount
    return () => {
      unsubscribeUpdateStart();
      unsubscribeUpdateComplete();
      unsubscribeMiddlewareStart();
      unsubscribeMiddlewareError();
      unsubscribeTransactionStart();
      unsubscribeTransactionError();
      unsubscribePersistenceReady();
    };
  }, [store]); // Re-subscribe if store instance changes (unlikely)

  const { actions } = useEventStore();

  return (
    <div>
      <h3>Store Event Log</h3>
      <button onClick={() => actions.processData('new data')}>Process Data</button>
      <button onClick={() => actions.triggerError().catch(() => {})}>Trigger Action Error</button>
      <button onClick={() => store.transaction(() => { actions.processData('transaction data'); throw new Error('Transaction error'); }).catch(() => {})}>
        Simulate Transaction Error
      </button>
      <ul style={{ maxHeight: '200px', overflowY: 'auto', border: '1px solid #ccc', padding: '10px' }}>
        {eventLogs.map((log, index) => <li key={index} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>{log}</li>)}
      </ul>
    </div>
  );
}

Advanced Hook Properties

The hook returned by createStore provides several properties for advanced usage and debugging, beyond the commonly used select, actions, and isReady:

function MyAdvancedComponent() {
  const {
    select,        // Function to select state parts (memoized)
    actions,       // Object containing your defined actions (debounced)
    isReady,       // Boolean indicating if persistence is ready
    store,         // Direct access to the ReactiveDataStore instance
    observer,      // StoreObservability instance (if `enableMetrics` was true)
    actionTracker, // Instance of ActionTracker for monitoring action executions
    state,         // A hook `() => T` to get the entire reactive state object
  } = useMyStore(); // Assuming useMyStore is defined from createStore

  // Example: Accessing the full state (use with caution for performance, `select` is preferred)
  const fullCurrentState = state();
  console.log("Full reactive state:", fullCurrentState);

  // Example: Accessing observer methods (if enabled)
  if (observer) {
    console.log("Performance metrics:", observer.getPerformanceMetrics());
    console.log("Recent state changes:", observer.getRecentChanges(3));
  }

  // Example: Accessing action history
  console.log("Action executions:", actionTracker.getExecutions());

  return (
    <div>
      {/* ... your component content ... */}
    </div>
  );
}

Project Architecture

@asaidimu/react-store is structured to provide a modular yet integrated state management solution.

.
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ hooks/
β”‚   β”‚   └── observability.ts        # React hook for remote observability
β”‚   β”œβ”€β”€ persistence/
β”‚   β”‚   β”œβ”€β”€ indexedb.ts             # IndexedDB persistence adapter
β”‚   β”‚   β”œβ”€β”€ local-storage.ts        # WebStorage (localStorage/sessionStorage) persistence adapter
β”‚   β”‚   └── types.ts                # Interface for persistence adapters
β”‚   β”œβ”€β”€ state/
β”‚   β”‚   β”œβ”€β”€ diff.ts                 # Utility for deep diffing state objects
β”‚   β”‚   β”œβ”€β”€ merge.ts                # Utility for immutable deep merging state objects
β”‚   β”‚   β”œβ”€β”€ observability.ts        # Core observability logic for ReactiveDataStore
β”‚   β”‚   └── store.ts                # Core ReactiveDataStore implementation (the state machine)
β”‚   β”œβ”€β”€ store/
β”‚   β”‚   β”œβ”€β”€ compare.ts              # Utilities for fast comparison (e.g., array hashing)
β”‚   β”‚   β”œβ”€β”€ execution.ts            # Action tracking interface and class
β”‚   β”‚   β”œβ”€β”€ hash.ts                 # Utilities for hashing objects (e.g., for selectors)
β”‚   β”‚   β”œβ”€β”€ index.ts                # Main `createStore` React hook
β”‚   β”‚   β”œβ”€β”€ paths.ts                # Utility for building selector paths
β”‚   β”‚   └── selector.ts             # Selector memoization manager
β”‚   β”œβ”€β”€ types.ts                    # Core TypeScript types for the library
β”‚   └── utils/
β”‚       β”œβ”€β”€ destinations.ts         # Concrete remote observability destinations (OTel, Prometheus, Grafana)
β”‚       └── remote-observability.ts # Remote observability extension for StoreObservability
β”œβ”€β”€ index.ts                        # Main entry point for the library
β”œβ”€β”€ package.json
└── tsconfig.json

Core Components

  • ReactiveDataStore (src/state/store.ts): The heart of the library. It manages the immutable state, processes updates (including debouncing), handles middleware, transactions, and interacts with persistence adapters. It also emits detailed internal events for observability.
  • StoreObservability (src/state/observability.ts): An extension built on top of ReactiveDataStore's event system. It provides debugging features like event history, state snapshots for time-travel, performance metrics, and utilities to create logging/validation middleware.
  • createStore Hook (src/store/index.ts): The primary React-facing API. It instantiates ReactiveDataStore and StoreObservability, wraps actions with debouncing and tracking, and provides the select hook powered by useSyncExternalStore for efficient component updates.
  • Persistence Adapters (src/persistence/): Implement the DataStorePersistence interface. WebStoragePersistence (for localStorage/sessionStorage) and IndexedDBPersistence provide concrete storage solutions with cross-tab synchronization.
  • RemoteObservability (src/utils/remote-observability.ts): Extends StoreObservability to enable sending metrics, logs, and traces to external monitoring systems. It defines a pluggable RemoteDestination interface and provides out-of-the-box implementations.

Data Flow

  1. Action Dispatch: A React component calls an action (e.g., actions.increment(1)). Actions are debounced by default.
  2. Action Execution Tracking: The ActionTracker records the action's details (name, params, start time).
  3. State Update Request: The action, after potential debouncing, initiates a store.set() call with a partial state update or a function.
  4. Transaction Context: If within a store.transaction(), the state is snapshotted for potential rollback.
  5. Blocking Middleware: Updates first pass through blockingMiddleware. If any middleware returns false or throws, the update is halted, and the state is not modified.
  6. Transform Middleware: If not blocked, updates then pass through transforming middleware. These functions can modify the partial update.
  7. State Merging: The final transformed update is immutably merged into the current state using the merge utility. Symbol.for("delete") is handled here for property removal.
  8. Change Detection: The diff utility identifies which paths in the state have truly changed.
  9. Persistence: If changes occurred, the new state is saved via the configured DataStorePersistence adapter (e.g., localStorage, IndexedDB). External changes from persistence are also subscribed to and applied.
  10. Listener Notification: Only React.useSyncExternalStore subscribers whose selected paths have changed are notified, triggering re-renders of relevant components.
  11. Observability Events: Throughout this flow, the ReactiveDataStore emits fine-grained events (update:start, middleware:complete, transaction:error, etc.) that StoreObservability captures for debugging, metrics, and remote reporting.

Extension Points

  • Custom Middleware: Easily add your own Middleware or BlockingMiddleware functions for custom logic.
  • Custom Persistence Adapters: Implement the DataStorePersistence<T> interface to integrate with any storage solution (e.g., a backend API, WebSockets, or a custom in-memory store).
  • Remote Observability Destinations: Create new RemoteDestination implementations to send metrics and traces to any external observability platform not already supported.

Development & Contributing

We welcome contributions! Please follow the guidelines below.

Development Setup

  1. Clone the repository:
    git clone https://github.com/asaidimu/react-store.git
    cd react-store
  2. Install dependencies: This project uses bun as the package manager.
    bun install

Scripts

  • bun ci: Installs dependencies (for CI/CD environments).
  • bun test: Runs all unit tests using Vitest.
  • bun test:ci: Runs tests in CI mode (single run).
  • bun clean: Removes the dist directory.
  • bun prebuild: Cleans dist and runs a sync script (internal).
  • bun build: Compiles the TypeScript source into dist/ for CJS and ESM formats, generates type definitions, and minifies.
  • bun dev: Starts a development server (likely for a UI example).
  • bun postbuild: Copies README.md, LICENSE.md, and dist.package.json into the dist folder.

Testing

Tests are written using Vitest and React Testing Library.

To run tests:

bun test
# or to run in watch mode
bun test --watch

Contributing Guidelines

  1. Fork the repository and create your branch from main.
  2. Code Standards: Ensure your code adheres to existing coding styles (TypeScript, ESLint, Prettier are configured).
  3. Tests: Add unit and integration tests for new features or bug fixes. Ensure all tests pass.
  4. Commits: Follow Conventional Commits for commit messages.
  5. Pull Requests: Submit a pull request to the main branch. Provide a clear description of your changes.

Issue Reporting

For bugs, feature requests, or questions, please open an issue on the GitHub Issues page.

Additional Information

Best Practices

  1. Granular Selectors: Always use select((state) => state.path.to.value) instead of select((state) => state) to prevent unnecessary re-renders of components.
  2. Action Design: Keep actions focused on a single responsibility. Use async actions for asynchronous operations and return partial updates upon completion.
  3. Persistence:
    • Use unique storeId or storageKey for each distinct store to avoid data conflicts.
    • Always check the isReady flag for UI elements that depend on the initial state loaded from persistence.
  4. Middleware: Leverage middleware for cross-cutting concerns like logging, analytics, or complex validation logic.
  5. Symbol.for("delete"): Use this explicit symbol for property removal to maintain clarity and avoid accidental data mutations.

API Reference

createStore(definition, options)

The main entry point for creating a store.

type StoreDefinition<T, R extends Actions<T>> = {
  state: T; // Initial state object
  actions: R; // Object mapping action names to action functions
  middleware?: Record<string, Middleware<T>>; // Optional transforming middleware
  blockingMiddleware?: Record<string, BlockingMiddleware<T>>; // Optional blocking middleware
};

interface StoreOptions<T> {
  enableMetrics?: boolean; // Enable StoreObservability features (default: false)
  enableConsoleLogging?: boolean; // Log store events to console (default: false)
  maxEvents?: number; // Maximum number of events to keep in history (default: 500)
  maxStateHistory?: number; // Maximum number of state snapshots for time travel (default: 20)
  logEvents?: { // Which event categories to log (defaults to all true if enableConsoleLogging is true)
    updates?: boolean;
    middleware?: boolean;
    transactions?: boolean;
  };
  performanceThresholds?: { // Thresholds for logging slow operations (in ms)
    updateTime?: number; // default: 50ms
    middlewareTime?: number; // default: 20ms
  };
  persistence?: DataStorePersistence<T>; // Optional persistence adapter instance
  debounceTime?: number; // Time in milliseconds to debounce actions (default: 250ms)
}

const useStore = createStore(definition, options);

Returns: A useStore hook which, when called in a component, returns an object with:

  • store: Direct access to the ReactiveDataStore instance.
  • observer: The StoreObservability instance (available if enableMetrics is true). Provides debug and monitoring utilities.
  • select: A memoized selector function to extract specific state slices. Re-renders components only when selected data changes.
  • actions: An object containing your defined actions. These actions are debounced and tracked.
  • actionTracker: An instance of ActionTracker for monitoring the execution history of your actions.
  • state: A hook () => T that returns the entire current state object. Use sparingly as it will cause re-renders on any state change.
  • isReady: A boolean indicating whether the store's persistence layer (if configured) has finished loading its initial state.

ReactiveDataStore (accessed via useStore().store)

  • get(clone?: boolean): T: Retrieves the current state. Pass true to get a deep clone (recommended for mutations outside of actions).
  • set(update: StateUpdater<T>): Promise<void>: Updates the state with a partial object or a function returning a partial object.
  • subscribe(path: string | string[], listener: (state: T) => void): () => void: Subscribes a listener to changes at a specific path or array of paths. Returns an unsubscribe function.
  • transaction<R>(operation: () => R | Promise<R>): Promise<R>: Executes a function as an atomic transaction. Rolls back all changes if an error occurs.
  • use(middleware: Middleware<T>, name?: string): string: Adds a transforming middleware. Returns its ID.
  • useBlockingMiddleware(middleware: BlockingMiddleware<T>, name?: string): string: Adds a blocking middleware. Returns its ID.
  • removeMiddleware(id: string): boolean: Removes a middleware by its ID.
  • isReady(): boolean: Checks if the persistence layer has loaded its initial state.
  • onStoreEvent(event: StoreEvent, listener: (data: any) => void): () => void: Subscribes to internal store events (e.g., 'update:complete', 'middleware:error').

StoreObservability (accessed via useStore().observer)

  • getEventHistory(): DebugEvent[]: Retrieves a history of all captured store events.
  • getStateHistory(): T[]: Returns a history of state snapshots, enabling time-travel.
  • getRecentChanges(limit?: number): Array<{ timestamp: number; changedPaths: string[]; from: Partial<T>; to: Partial<T>; }>: Provides a simplified view of recent state changes.
  • getPerformanceMetrics(): StoreMetrics: Returns an object containing performance statistics (e.g., updateCount, averageUpdateTime).
  • createTimeTravel(): { canUndo: () => boolean; canRedo: () => boolean; undo: () => Promise<void>; redo: () => Promise<void>; getHistoryLength: () => number; clear: () => void; }: Returns controls for time-travel debugging.
  • createLoggingMiddleware(options?: object): Middleware<T>: A factory for a simple logging middleware.
  • createValidationMiddleware(validator: (state: T, update: DeepPartial<T>) => boolean | { valid: boolean; reason?: string }): BlockingMiddleware<T>: A factory for a schema validation middleware.
  • clearHistory(): void: Clears the event and state history.
  • disconnect(): void: Cleans up all listeners and resources.

RemoteObservability (accessed via useRemoteObservability hook)

Extends StoreObservability with methods for sending metrics and traces externally.

  • addDestination(destination: RemoteDestination): boolean: Adds a remote destination for metrics.
  • removeDestination(id: string): boolean: Removes a remote destination by ID.
  • getDestinations(): Array<{ id: string; name: string }>: Gets a list of configured destinations.
  • testAllConnections(): Promise<Record<string, boolean>>: Tests connectivity to all destinations.
  • beginTrace(name: string): string: Starts a new performance trace, returning its ID.
  • beginSpan(traceId: string, name: string, labels?: Record<string, string>): string: Starts a new span within a trace, returning its ID.
  • endSpan(traceId: string, spanName: string): void: Ends a specific span within a trace.
  • endTrace(traceId: string): void: Ends a performance trace and sends it to remote destinations.
  • trackMetric(metric: RemoteMetricsPayload['metrics'][0]): void: Manually add a metric to the batch for reporting.

Persistence Adapters

All adapters implement DataStorePersistence<T>:

  • set(id:string, state: T): boolean | Promise<boolean>: Persists data.
  • get(): T | null | Promise<T | null>: Retrieves data.
  • subscribe(id:string, callback: (state:T) => void): () => void: Subscribes to external changes.
  • clear(): boolean | Promise<boolean>: Clears persisted data.
IndexedDBPersistence(storeId: string)
  • storeId: A unique identifier for the IndexedDB object store (e.g., 'user-data').
WebStoragePersistence(storageKey: string, session?: boolean)
  • storageKey: The key under which data is stored (e.g., 'app-config').
  • session: Optional. If true, uses sessionStorage; otherwise, uses localStorage (default: false).
LocalStoragePersistence(storageKey: string) (Deprecated)
  • This is an alias for WebStoragePersistence. Use WebStoragePersistence instead.

Comparison with Other State Management Solutions

@asaidimu/react-store aims to provide a comprehensive, all-in-one solution for React state management. Here's a comparison to popular alternatives:

Feature@asaidimu/react-storeReduxZustandMobXRecoil
Dev ExperienceIntuitive hook-based API with rich tooling.Verbose setup with reducers and middleware.Minimalist, hook-friendly API.Reactive, class-based approach.Atom-based, React-native feel.
Learning CurveModerate (middleware, observability add complexity).Steep (boilerplate-heavy).Low (simple API).Moderate (reactive concepts).Low to moderate (atom model).
API ComplexityMedium (rich feature set balanced with simplicity).High (many concepts: actions, reducers, etc.).Low (straightforward).Medium (proxies, decorators).Medium (atom/selectors).
ScalabilityHigh (transactions, persistence, remote metrics).High (structured but verbose).High (small but flexible).High (reactive scaling).High (granular atoms).
ExtensibilityExcellent (middleware, custom persistence, observability).Good (middleware, enhancers).Good (middleware-like).Moderate (custom reactions).Moderate (custom selectors).
PerformanceOptimized (selectors, reactive updates).Good (predictable but manual optimization).Excellent (minimal overhead).Good (reactive overhead).Good (granular updates).
Bundle SizeModerate (includes observability, persistence, remote observability).Large (core + toolkit).Tiny (~1KB).Moderate (~20KB).Moderate (~10KB).
PersistenceBuilt-in (IndexedDB, WebStorage, cross-tab).Manual (via middleware).Manual (via middleware).Manual (custom).Manual (custom).
ObservabilityExcellent (metrics, time-travel, remote).Good (dev tools).Basic (via plugins).Good (reactive logs).Basic (via plugins).
React IntegrationNative (hooks, useSyncExternalStore)Manual (React-Redux).Native (hooks).Native (observers).Native (atoms).

Where @asaidimu/react-store Shines

  • All-in-One: It aims to be a single solution for state management, persistence, and observability, reducing the need for multiple external dependencies.
  • Flexibility: The robust middleware system and transaction support make it highly adaptable to complex business logic and data flows.
  • Modern React: It leverages useSyncExternalStore for direct integration with React's concurrency model, ensuring efficient and up-to-date component renders.

Trade-Offs

  • Bundle Size: While comprehensive, it naturally has a larger bundle size compared to minimalist alternatives like Zustand, as it includes a wider range of features out-of-the-box.
  • Learning Curve: The rich feature set might present a slightly steeper learning curve for developers new to advanced state management concepts, though the API strives for simplicity.

Changelog

For a detailed history of changes and new features, please refer to the CHANGELOG.md file.

License

This project is licensed under the MIT License. See the LICENSE.md file for full details.

Acknowledgments

Developed by Saidimu.

2.0.0

5 months ago

1.5.0

5 months ago

1.4.11

6 months ago

1.4.10

6 months ago

1.4.9

6 months ago

1.4.8

6 months ago

1.4.7

6 months ago

1.4.6

6 months ago

1.4.5

7 months ago

1.4.4

8 months ago

1.4.3

8 months ago

1.4.2

8 months ago

1.4.1

8 months ago

1.4.0

8 months ago

1.3.0

8 months ago

1.2.2

8 months ago

1.2.1

8 months ago

1.2.0

8 months ago

1.1.6

8 months ago

1.1.5

8 months ago

1.1.4

8 months ago

1.1.3

8 months ago

1.1.2

8 months ago

1.1.1

8 months ago

1.1.0

8 months ago

1.0.1

8 months ago

1.0.0

8 months ago