Skip to content

Store Conventions ​

Complete conventions for Store, TimeTravelStore, and MutableStore patterns in the Context-Action framework.

This guide targets the stable Core 1.2.6, Store Core 0.1.3, Mutative Core 0.8.14, scoped adapter 0.8.15, and React 4.0.7 cohort. The upstream immutable-update baseline is mutative@1.3.0; scoped adapter versions remain independent. The role and transaction contracts are maintained in Store and time-travel contracts.

📋 Table of Contents ​

  1. Store Types Overview
  2. Store (Default)
  3. TimeTravelStore
  4. MutableStore Pattern
  5. notifyPath/notifyPaths API
  6. Event Loop Control
  7. Performance Guidelines
  8. Best Practices
  9. Business Logic Separation

Store Types Overview ​

Context-Action Framework provides three specialized Store implementations:

Store TypeImplementationKey FeaturesUse Case
StorecreateStore()Immutability + Deep FreezeGeneral state, forms, settings
TimeTravelStorecreateTimeTravelStore()Undo/Redo + Structural SharingText editors, drawing apps
MutableStoreTimeTravelStore without undo/redoStructural Sharing + PerformanceHigh-frequency updates, large trees

Quick Selection Guide ​

typescript
// ✅ Use Store for: General state, forms, settings
const formStore = createStore('form', { name: '', email: '' });

// ✅ Use TimeTravelStore for: Undo/redo features
const editorStore = createTimeTravelStore('editor', { content: '' });

// ✅ Use MutableStore Pattern for: High-performance without undo/redo
// (TimeTravelStore with mutable mode, undo/redo not used)
const dashboardStore = createTimeTravelStore('dashboard', {
  widgets: [...],
  layout: {...}
}, { mutable: true });

Store (Default) ​

Overview ​

Standard store with full immutability guarantees and safety features.

typescript
import { createStore } from '@context-action/react';

const userStore = createStore('user', { name: '', email: '' });

Features ​

  • Deep Freeze: Values frozen to prevent accidental mutations
  • Copy-on-Write: Efficient cloning with version-based caching
  • RAF Batching: Multiple updates batched into single frame
  • Error Recovery: Automatic problematic listener removal
  • Concurrency Protection: Update queue prevents race conditions
  • notifyPath/notifyPaths: Manual event control API

Core API ​

typescript
// Create store
const store = createStore('name', initialValue);

// Read value
const value = store.getValue();
const snapshot = store.getSnapshot();

// Update value
store.setValue(newValue);
store.update(draft => { draft.prop = value; });

// Subscribe
const unsubscribe = store.subscribe(() => {
  console.log('Store updated');
});

// Manual notifications
store.notifyPath(['nested', 'property']);
store.notifyPaths([['path1'], ['path2']]);

React Integration ​

typescript
import { useStoreValue } from '@context-action/react';

function UserComponent() {
  const userStore = useUserStore('profile');

  // ✅ Reactive subscription
  const user = useStoreValue(userStore);

  // ✅ Update
  const updateName = () => {
    userStore.update(draft => { draft.name = 'New Name'; });
  };

  return <div>{user.name}</div>;
}

When to Use ​

  • General state management
  • Forms and settings
  • Cached data
  • When immutability guarantees are critical
  • When using useStoreValue() subscriptions

TimeTravelStore ​

Overview ​

Store with built-in undo/redo functionality powered by history management.

typescript
import { createTimeTravelStore } from '@context-action/react';

const editorStore = createTimeTravelStore('editor',
  { content: '', cursor: 0 },
  { maxHistory: 50, mutable: true }
);

Features ​

  • Undo/Redo: Full history navigation
  • Structural Sharing: Unchanged parts keep same reference
  • Configurable History: maxHistory option
  • Patch-based Updates: Efficient change tracking via JSON patches
  • notifyPath/notifyPaths: Manual event control API

Core API ​

typescript
// Time travel controls
editorStore.undo();        // Go back one step
editorStore.redo();        // Go forward one step
editorStore.goTo(3);       // Jump to specific position
editorStore.reset();       // Reset to initial state

// Check capabilities
if (editorStore.canUndo()) { /* ... */ }
if (editorStore.canRedo()) { /* ... */ }

// Get controls for UI
const { canUndo, canRedo, position, history } = editorStore.getTimeTravelControls();

React Integration ​

Use useStorePath() for a narrow path subscription. useStoreValue() also works for whole-state reads when changes are committed through setValue(), update(), undo/redo, or an explicit transaction. Mutable direct changes reported only through notifyPath() require a path or selector subscription; they do not create a new root value reference.

typescript
import { useStorePath, useTimeTravelControls } from '@context-action/react';

function Editor() {
  const editorStore = useEditorStore('document');

  // ✅ CORRECT: useStorePath for structural sharing
  const content = useStorePath(editorStore, ['content']);
  const cursor = useStorePath(editorStore, ['cursor']);

  // Whole-state reads are also valid for committed transitions.
  // const state = useStoreValue(editorStore);

  // Time travel controls
  const { canUndo, canRedo, undo, redo } = useTimeTravelControls(editorStore);

  return (
    <div>
      <textarea value={content} />
      <button onClick={undo} disabled={!canUndo}>Undo</button>
      <button onClick={redo} disabled={!canRedo}>Redo</button>
    </div>
  );
}

Subscription Pattern ​

typescript
// Committed TimeTravelStore transitions replace the observable snapshot.
// Unchanged branches retain their references through structural sharing.

// Whole-state subscription: setValue/update/undo/redo transitions
const state = useStoreValue(editorStore);

// Narrow subscriptions: selected paths and mutable notifyPath updates
const content = useStorePath(editorStore, ['content']);
const cursor = useStorePath(editorStore, ['cursor']);

Read safety and snapshot identity ​

Declarative TimeTravelStoreManager instances default to readMode: 'safe'. Direct createTimeTravelStore() calls retain readMode: 'reference' for compatibility. Never mutate a reference read unless the feature explicitly owns the advanced mutable notifyPath() contract. Use getSafeValue() for a defensive copy crossing an external boundary.

React adapters read getSnapshot().value and subscribe to the Store. The snapshot reference must remain stable until an observable transition occurs. Do not return a fresh object from a custom snapshot getter on every read; selectors that allocate objects must use an appropriate equality function.

Explicit multi-store transactions ​

Use StoreTransactionCoordinator.run(participants, callback, meta) when one command changes multiple Stores and must undo/redo as one unit. Name each participant explicitly and attach transactionId, actionId, origin, and label at the transaction boundary. Core dispatch does not infer these participants; bindActionTransactions() connects dispatch traces to the application's explicit participant list.

Coordinator commits update all participant snapshots before notifications are flushed. Failed callbacks roll positions back; a participant moved outside the coordinator causes a history conflict instead of a partial undo/redo. Inspector snapshots contain serializable metadata and positions, never live Store objects.

When to Use ​

  • Text editors, drawing applications
  • Form wizards with back/forward navigation
  • Any feature requiring undo/redo
  • Debugging with state history
  • When history tracking is essential

MutableStore Pattern ​

Overview ​

Definition: TimeTravelStore with mutable: true where undo/redo functionality is NOT used.

This pattern provides structural sharing and high performance without the overhead of history tracking, while still maintaining the technical capability.

typescript
import { createTimeTravelStore } from '@context-action/react';

// MutableStore Pattern: TimeTravelStore without using undo/redo
const dashboardStore = createTimeTravelStore('dashboard', {
  user: { name: 'John', settings: { theme: 'dark' } },
  ui: { sidebar: { isOpen: true } }
}, {
  mutable: true  // Enable structural sharing (default)
  // Note: undo/redo methods exist but are not used
});

Key Characteristics ​

  1. Structural Sharing: Unchanged parts keep same reference
  2. No Undo/Redo Usage: History methods available but ignored
  3. Performance Focus: Optimized for high-frequency updates
  4. notifyPath/notifyPaths: Advanced event control

Why Not a Separate Implementation? ​

  • TimeTravelStore already provides structural sharing via mutable: true
  • Simply don't call undo(), redo(), goTo() methods
  • Avoids code duplication and maintenance overhead
  • History can be enabled later if needed

Usage Pattern ​

typescript
// ✅ MutableStore Pattern: TimeTravelStore without undo/redo
const appStore = createTimeTravelStore('app', {
  user: { name: 'John', status: 'online' },
  ui: { loading: false, progress: 0 }
}, { mutable: true });

// Use structural sharing for selective re-renders
const userName = useStorePath(appStore, ['user', 'name']);
const uiLoading = useStorePath(appStore, ['ui', 'loading']);

// Update with efficient structural sharing
appStore.update(draft => {
  draft.user.name = 'Jane';
  // Only ['user', 'name'] path triggers re-render
  // ui.loading subscribers NOT re-rendered
});

// ⚠️ Don't use undo/redo methods
// appStore.undo();  // Available but not used in MutableStore pattern
// appStore.redo();  // Available but not used in MutableStore pattern

Advanced: Manual Event Control ​

typescript
// 🎯 Pattern: Direct mutation + notifyPath
async function loadUserData() {
  // Step 1: Change the selected value, then notify its path
  const state = appStore.getValue();
  state.ui.loading = true;
  appStore.notifyPath(['ui', 'loading']);

  // Step 2: Fetch data
  const userData = await fetchUserData();

  // Step 3: Replace object-valued selections before notifying them
  state.user = userData;
  state.ui.loading = false;
  appStore.notifyPaths([['user'], ['ui', 'loading']]);
}

// 🎯 Pattern: External mutation + notification
function setupWebSocket() {
  ws.on('message', (data) => {
    const state = appStore.getValue();
    state.user.status = data.status; // Direct mutation
    appStore.notifyPath(['user', 'status']);
  });
}

notifyPath is a notification, not a force-render primitive. React subscriptions still compare the selected snapshot with Object.is. A changed primitive is observable directly, but a selected object or array must be replaced with a new reference before notification; mutating it with push, splice, or an in-place field write keeps the old snapshot reference and does not re-render that selector.

When to Use MutableStore Pattern ​

  • High-frequency updates (animations, real-time data)
  • Large state trees requiring selective re-rendering
  • Performance-sensitive applications
  • External system integration (WebSocket, etc.)
  • When undo/redo is NOT needed

Performance Benefits ​

typescript
// Traditional approach: Multiple re-renders
userStore.update(draft => { draft.loading = true; });  // Re-render 1
const data = await fetch();
userStore.update(draft => {                             // Re-render 2
  draft.data = data;
  draft.loading = false;
});
// Total: 2 re-renders

// MutableStore Pattern: only path subscribers are notified
const state = userStore.getValue();
state.loading = true;
userStore.notifyPath(['loading']);                      // loading subscriber only
const data = await fetch();
state.data = data;                                      // assign a new data reference
state.loading = false;
userStore.notifyPaths([['data'], ['loading']]);         // affected subscribers only

notifyPath/notifyPaths API ​

Overview ​

Manual event control API that decouples state changes from React updates.

typescript
// Notify single path
store.notifyPath(['nested', 'property']);

// Notify multiple paths
store.notifyPaths([
  ['ui', 'loading'],
  ['ui', 'progress']
]);

Core Concept ​

Traditional state management:

State Change → React Re-render

With notifyPath and an observable value change:

State Change → Manual Notification → Selective React Re-render

Calling notifyPath without changing the selected snapshot can notify patch-aware non-React listeners, but it does not force a React render.

Use Cases ​

1. Selective Loading-State Updates ​

typescript
async function loadData() {
  const state = store.getValue();
  state.loading = true;
  store.notifyPath(['loading']);

  const data = await fetchData();

  // Single update with actual data
  store.setValue({ data, loading: false });
}

2. External System Integration ​

typescript
function setupWebSocket(store: IStore<AppState>) {
  ws.onmessage = (event) => {
    const state = store.getValue();

    // Replace the selected array so Object.is observes a new snapshot
    state.messages = [...state.messages, event.data];

    // Notify React
    store.notifyPath(['messages']);
  };
}

3. Batch Notifications ​

typescript
function updateMultipleMetrics() {
  const state = store.getValue();

  // Update multiple properties
  state.ui.loading = false;
  state.ui.progress = 100;
  state.metrics.lastUpdate = Date.now();

  // Single batched notification
  store.notifyPaths([
    ['ui', 'loading'],
    ['ui', 'progress'],
    ['metrics', 'lastUpdate']
  ]);
}

Notification Scheduling ​

Regular Store notifications are batched in requestAnimationFrame. TimeTravelStore defaults to immediate notification and batches only when it is created with notificationMode: 'batched':

typescript
// In batched mode, these share a single RAF frame
store.notifyPath(['path1']);
store.notifyPath(['path2']);
store.notifyPaths([['path3'], ['path4']]);

// Result: Single React update cycle

Event Loop Control ​

Pattern 1: Action Handler + notifyPath ​

Problem: Multiple re-renders for loading + data

typescript
// ❌ Traditional: 2 re-renders
useActionHandler('loadUser', async (payload) => {
  userStore.update(draft => { draft.loading = true; });  // Re-render 1
  const data = await fetchUser(payload.id);
  userStore.update(draft => {                             // Re-render 2
    draft.user = data;
    draft.loading = false;
  });
});

Solution: Manual event control

typescript
// ✅ Optimized: 1 re-render (50% reduction)
useActionHandler('loadUser', async (payload) => {
  userStore.notifyPath(['loading']);                      // UI update only
  const data = await fetchUser(payload.id);
  userStore.update(draft => {                             // Single re-render
    draft.user = data;
    draft.loading = false;
  });
});

Pattern 2: RefContext + notifyPath ​

Combine direct DOM manipulation with selective React updates:

typescript
function ProgressDashboard() {
  const progressBar = useProgressRef('bar');
  const dashboardStore = useAppStore('dashboard');

  const updateProgress = useCallback((progress: number) => {
    // Step 1: Direct DOM (zero React overhead)
    if (progressBar.target) {
      progressBar.target.style.width = `${progress}%`;
    }

    // Step 2: Notify state subscribers
    dashboardStore.notifyPath(['ui', 'progress']);

    // Step 3: Update store for persistence
    const state = dashboardStore.getValue();
    state.ui.progress = progress;
  }, [progressBar, dashboardStore]);

  return (
    <div>
      <div ref={progressBar.setRef} className="progress-bar" />
      <ProgressStats /> {/* Only this re-renders */}
    </div>
  );
}

Pattern 3: Preventing Infinite Loops ​

Problem: Subscription triggers action → action updates store → subscription triggers

typescript
// ❌ INFINITE LOOP
useEffect(() => {
  return store.subscribe(() => {
    dispatch('onStoreChange', { data: store.getValue() });
  });
}, []);

useActionHandler('onStoreChange', (payload) => {
  store.setValue(processData(payload.data)); // Triggers subscription!
});

Solution: Use notifyPath to break the loop

typescript
// ✅ SOLUTION
useEffect(() => {
  return store.subscribe(() => {
    const value = store.getValue();
    if (requiresProcessing(value)) {
      dispatch('onStoreChange', { data: value });
    }
  });
}, []);

useActionHandler('onStoreChange', (payload) => {
  const processed = processData(payload.data);

  // Direct mutation + notification (no subscription trigger)
  const state = store.getValue();
  state.processed = processed;
  store.notifyPath(['processed']);
});

Performance Guidelines ​

Subscription Strategy ​

typescript
// ✅ Store: Use useStoreValue
const userStore = createStore('user', { name: '' });
const user = useStoreValue(userStore);

// Narrow reads and mutable direct updates reported by notifyPath
const editorStore = createTimeTravelStore('editor', { content: '' });
const content = useStorePath(editorStore, ['content']);

// Whole-state reads are valid for committed transitions
const state = useStoreValue(editorStore);

Update Strategy ​

typescript
// For Store (immutable)
userStore.setValue({ name: 'John', email: 'john@example.com' });
userStore.update(draft => { draft.name = 'John'; });

// For TimeTravelStore/MutableStore (structural sharing)
editorStore.update(draft => {
  draft.content = 'New content';
  // Only ['content'] path re-renders
});

Event Control Strategy ​

typescript
// Use notifyPath for:
// 1. Loading states
store.notifyPath(['loading']);

// 2. External mutations
const state = store.getValue();
state.prop = externalValue;
store.notifyPath(['prop']);

// 3. Batch updates
store.notifyPaths([['path1'], ['path2']]);

// Don't use notifyPath for:
// - Regular state updates (use setValue/update)
// - When subscription to full state is needed

Best Practices ​

1. Choose the Right Store Type ​

typescript
// ✅ Store: Forms, settings, general state
const settingsStore = createStore('settings', { theme: 'dark' });

// ✅ TimeTravelStore: Undo/redo needed
const editorStore = createTimeTravelStore('editor', { content: '' });

// ✅ MutableStore Pattern: High-performance, no undo/redo
const dashboardStore = createTimeTravelStore('dashboard', {
  widgets: [...]
}, { mutable: true });

2. Use Correct Subscription Pattern ​

typescript
// ✅ Store
const value = useStoreValue(store);

// ✅ TimeTravelStore/MutableStore
const content = useStorePath(store, ['content']);
const cursor = useStorePath(store, ['cursor']);

3. Leverage notifyPath for Performance ​

typescript
// ✅ Reduce re-renders
store.notifyPath(['loading']);
const data = await fetch();
store.setValue({ data, loading: false });

// ✅ Batch notifications
store.notifyPaths([['path1'], ['path2']]);

// ✅ External integration
state.prop = externalValue;
store.notifyPath(['prop']);

4. Prevent Infinite Loops ​

typescript
// ✅ Use notifyPath in subscriptions
store.subscribe(() => {
  const state = store.getValue();
  state.processed = process(state.raw);
  store.notifyPath(['processed']);
});

// ❌ Don't use setValue in subscriptions
store.subscribe(() => {
  store.setValue(process(store.getValue())); // LOOP!
});

5. Combine Patterns for Maximum Efficiency ​

typescript
// ✅ RefContext + notifyPath + MutableStore
function OptimizedComponent() {
  const domRef = useProgressRef('element');
  const store = useDashboardStore('metrics');

  const update = useCallback((value: number) => {
    // Direct DOM (zero overhead)
    if (domRef.target) {
      domRef.target.style.width = `${value}%`;
    }

    // Notify subscribers
    store.notifyPath(['progress']);

    // Persist state
    const state = store.getValue();
    state.progress = value;
  }, [domRef, store]);

  return <div ref={domRef.setRef} />;
}

Business Logic Separation ​

Separating business logic from UI components and state management is crucial for maintainability, testability, and scalability.

Core Principles ​

  1. Pure Business Logic: Independent of React and store implementations
  2. State Machines: Explicit state transitions for complex async processes
  3. Progress Decoupling: Separate progress updates from state changes using notifyPath
  4. Modular Design: Testable business logic modules

Patterns Overview ​

The Context-Action framework supports business logic separation through:

  1. Business Logic Module - Pure classes/functions independent of React
  2. Async Process State Machine - State-driven async operations
  3. Progress-Only Updates - Decoupled progress tracking with notifyPath
  4. Modular Integration - Composable business logic modules
  5. Error Handling with State Machine - Robust error recovery
  6. Multi-file Queue Management - Complex workflows

Quick Example ​

typescript
// Pure business logic module
class FileUploadService {
  validateFile(file: File): { valid: boolean; error?: string } {
    const maxSize = 10 * 1024 * 1024; // 10MB
    if (file.size > maxSize) {
      return { valid: false, error: 'File too large' };
    }
    return { valid: true };
  }

  async uploadFile(file: File): Promise<UploadResult> {
    // Pure async logic
    const formData = new FormData();
    formData.append('file', file);
    
    const response = await fetch('/api/upload', {
      method: 'POST',
      body: formData
    });
    
    return response.json();
  }
}

// Integration with action handler
function FileUploadLogic({ children }) {
  const uploadService = useMemo(() => new FileUploadService(), []);
  const fileStore = useFileStore('upload');

  useFileActionHandler('uploadFile', useCallback(async (payload) => {
    // Validation
    const validation = uploadService.validateFile(payload.file);
    if (!validation.valid) {
      fileStore.setValue({ error: validation.error });
      return;
    }

    // Business logic execution
    try {
      fileStore.setValue({ status: 'uploading', progress: 0 });
      const result = await uploadService.uploadFile(payload.file);
      fileStore.setValue({ status: 'complete', result });
    } catch (error) {
      fileStore.setValue({ status: 'error', error });
    }
  }, [uploadService, fileStore]));

  return children;
}

Comprehensive Guide ​

For detailed patterns, testing strategies, and advanced examples, see:
📘 Business Logic Separation Guide

This dedicated guide covers:

  • 6 detailed patterns with complete examples
  • Testing business logic independently
  • Performance characteristics and optimization
  • Integration strategies with stores and actions
  • Real-world implementation examples

Comparison Table ​

┌──────────────────────────┬───────────────┬──────────────────┬──────────────────┐
│ Feature                  │ Store         │ TimeTravelStore  │ MutableStore     │
├──────────────────────────┼───────────────┼──────────────────┼──────────────────┤
│ Implementation           │ createStore   │ createTimeTravel │ createTimeTravel │
│ Immutability             │ ✅ Deep Freeze │ ❌ Mutable Mode  │ ❌ Mutable Mode  │
│ Structural Sharing       │ ❌ No          │ ✅ Yes           │ ✅ Yes           │
│ Undo/Redo                │ ❌ No          │ ✅ Yes (used)    │ ✅ Yes (ignored) │
│ notifyPath/notifyPaths   │ ✅ Yes         │ ✅ Yes           │ ✅ Yes           │
│ useStoreValue()          │ ✅ Works       │ ✅ Commits       │ ✅ Commits       │
│ useStorePath()           │ ✅ Works       │ ✅ Selective     │ ✅ notifyPath    │
│ RAF Batching             │ ✅ Yes         │ ✅ Yes           │ ✅ Yes           │
│ Clone on getValue()      │ ✅ Default On  │ readMode policy  │ readMode policy  │
│ Manual Event Control     │ ✅ Yes         │ ✅ Yes           │ ✅ Yes           │
│ External Mutation        │ ⚠️ Not a commit│ ⚠️ Explicit path │ ⚠️ Explicit path │
│ Use Case                 │ General state │ Undo/Redo apps   │ High performance │
└──────────────────────────┴───────────────┴──────────────────┴──────────────────┘

Released under the Apache-2.0 License.