# @jucio.io/state/on-change

Simple global change listener plugin for @jucio.io/state that notifies you of all state changes with automatic batching and consolidation.

## Features

- 🔔 **Global Change Tracking**: Listen to all state changes in one place
- 📦 **Automatic Batching**: Changes are batched and delivered asynchronously
- 🔄 **Smart Consolidation**: Multiple changes to the same path are consolidated
- 🎯 **Simple API**: Just add listeners and remove them when done
- ⚡ **Performance Optimized**: Only active when listeners are registered
- 🔌 **Zero Configuration**: Works out of the box

## Installation

```bash
npm install @jucio.io/state
```

**Note:** OnChange plugin is included in the main package.

## Quick Start

```javascript
import { createState } from '@jucio.io/state';
import { OnChange } from '@jucio.io/state/on-change';

// Create state and install OnChange plugin
const state = createState({
  user: { name: 'Alice', age: 30 },
  settings: { theme: 'dark' }
});
state.install(OnChange);

// Add a change listener
const unsubscribe = state.onChange.addListener((changes) => {
  console.log('State changed:', changes);
});

// Make some changes
state.set(['user', 'name'], 'Bob');
state.set(['settings', 'theme'], 'light');

// Console: "State changed: [{ path: ['user', 'name'], from: 'Alice', to: 'Bob', ... }, ...]"

// Remove listener when done
unsubscribe();
```

## Configuration

The OnChange plugin can be configured with custom options:

```javascript
import { createState } from '@jucio.io/state';
import { OnChange } from '@jucio.io/state/on-change';

const state = createState({
  user: { name: 'Alice', age: 30 }
});

// Install with custom configuration
state.install(OnChange.configure({
  debounce: 100  // Debounce change notifications by 100ms (default: 0)
}));
```

### Options

- **`debounce`** (number): Delay in milliseconds before notifying listeners. Default: `0` (no debounce)

## API Reference

### Actions

#### `state.onChange.addListener(callback)`

Add a listener that will be called whenever state changes occur.

```javascript
const unsubscribe = state.onChange.addListener((changes) => {
  changes.forEach(change => {
    console.log('Changed:', change.path);
    console.log('From:', change.from);
    console.log('To:', change.to);
  });
});
```

**Parameters:**
- `callback` (Function): Function that receives an array of change objects

**Returns:** Unsubscribe function to remove the listener

**Change Object Structure:**
```javascript
{
  path: ['user', 'name'],     // Path that changed
  from: 'Alice',               // Previous value
  to: 'Bob',                   // New value
  method: 'set',               // Method used (set, delete, push, etc.)
  timestamp: 1234567890        // Timestamp of change (if available)
}
```

#### `state.onChange.removeListener(callback)`

Manually remove a listener.

```javascript
function myListener(changes) {
  console.log('Changes:', changes);
}

state.onChange.addListener(myListener);

// Later...
state.onChange.removeListener(myListener);
```

**Parameters:**
- `callback` (Function): The listener function to remove

## How It Works

### Automatic Batching

Multiple synchronous changes are automatically batched and delivered together:

```javascript
state.onChange.addListener((changes) => {
  console.log('Received', changes.length, 'changes');
});

// These three changes are batched together
state.set(['a'], 1);
state.set(['b'], 2);
state.set(['c'], 3);

// Console: "Received 3 changes"
```

### Smart Consolidation

Multiple changes to the same path are consolidated, keeping only the most recent:

```javascript
state.onChange.addListener((changes) => {
  console.log('Changes:', changes);
});

// Rapid changes to the same path
state.set(['counter'], 1);
state.set(['counter'], 2);
state.set(['counter'], 3);

// Console: "Changes: [{ path: ['counter'], from: 0, to: 3, ... }]"
// Only the final change is reported
```

### Performance Optimization

The plugin only tracks changes when at least one listener is registered:

```javascript
// No listeners = no overhead
state.set(['value'], 1); // Fast, no tracking

// Add listener
const unsubscribe = state.onChange.addListener(handler);

// Now tracking is active
state.set(['value'], 2); // Tracked and reported

// Remove listener
unsubscribe();

// Back to no overhead
state.set(['value'], 3); // Fast, no tracking
```

## Common Use Cases

### Logging

```javascript
state.onChange.addListener((changes) => {
  if (process.env.NODE_ENV === 'development') {
    console.group('State Changes');
    changes.forEach(change => {
      console.log(`${change.path.join('.')}: ${change.from} → ${change.to}`);
    });
    console.groupEnd();
  }
});
```

### Persistence

```javascript
state.onChange.addListener((changes) => {
  // Save entire state to localStorage
  localStorage.setItem('appState', JSON.stringify(state.get([])));
});
```

### Analytics

```javascript
state.onChange.addListener((changes) => {
  changes.forEach(change => {
    analytics.track('state_changed', {
      path: change.path.join('.'),
      method: change.method,
      timestamp: Date.now()
    });
  });
});
```

### DevTools Integration

```javascript
if (window.__REDUX_DEVTOOLS_EXTENSION__) {
  const devTools = window.__REDUX_DEVTOOLS_EXTENSION__.connect();
  
  state.onChange.addListener((changes) => {
    changes.forEach(change => {
      devTools.send({
        type: `SET ${change.path.join('.')}`,
        payload: change.to
      }, state.get([]));
    });
  });
}
```

### Sync to Server

```javascript
let syncTimeout;

state.onChange.addListener((changes) => {
  // Debounce server sync
  clearTimeout(syncTimeout);
  syncTimeout = setTimeout(async () => {
    await fetch('/api/sync', {
      method: 'POST',
      body: JSON.stringify({
        changes,
        state: state.get([])
      })
    });
  }, 1000);
});
```

### Change History

```javascript
const changeHistory = [];

state.onChange.addListener((changes) => {
  changeHistory.push({
    timestamp: Date.now(),
    changes: changes.map(c => ({ ...c }))
  });
  
  // Keep last 100 change batches
  if (changeHistory.length > 100) {
    changeHistory.shift();
  }
});
```

### Validation

```javascript
state.onChange.addListener((changes) => {
  changes.forEach(change => {
    if (change.path[0] === 'user' && change.path[1] === 'email') {
      const email = change.to;
      if (!email.includes('@')) {
        console.warn('Invalid email address');
        // Optionally revert the change
        state.set(change.path, change.from);
      }
    }
  });
});
```

## Advanced Patterns

### Conditional Listeners

```javascript
let unsubscribe = null;

function enableChangeTracking() {
  if (!unsubscribe) {
    unsubscribe = state.onChange.addListener((changes) => {
      console.log('Tracking changes:', changes);
    });
  }
}

function disableChangeTracking() {
  if (unsubscribe) {
    unsubscribe();
    unsubscribe = null;
  }
}

// Enable tracking based on some condition
if (userPreferences.debugMode) {
  enableChangeTracking();
}
```

### Multiple Listeners

```javascript
// Logger
state.onChange.addListener((changes) => {
  console.log('LOG:', changes.length, 'changes');
});

// Validator
state.onChange.addListener((changes) => {
  changes.forEach(validateChange);
});

// Sync
state.onChange.addListener((changes) => {
  syncToServer(changes);
});

// All listeners receive the same changes
```

### Filtered Listeners

```javascript
// Create a filtered listener helper
function createFilteredListener(pathPrefix, callback) {
  return state.onChange.addListener((changes) => {
    const filtered = changes.filter(change => {
      return change.path.length >= pathPrefix.length &&
        pathPrefix.every((segment, i) => change.path[i] === segment);
    });
    
    if (filtered.length > 0) {
      callback(filtered);
    }
  });
}

// Only listen to user changes
const unsubscribe = createFilteredListener(['user'], (changes) => {
  console.log('User changed:', changes);
});
```

### Change Replay

```javascript
const recordedChanges = [];
let isRecording = false;

const unsubscribe = state.onChange.addListener((changes) => {
  if (isRecording) {
    recordedChanges.push(...changes);
  }
});

function startRecording() {
  isRecording = true;
  recordedChanges.length = 0;
}

function stopRecording() {
  isRecording = false;
  return [...recordedChanges];
}

function replay(changes) {
  changes.forEach(change => {
    state.set(change.path, change.to);
  });
}
```

## Comparison with Matcher Plugin

| Feature | OnChange | Matcher |
|---------|----------|---------|
| Scope | All changes | Specific paths |
| Batching | Automatic | Automatic |
| Consolidation | By address | By path |
| Filtering | Manual | Built-in |
| Use Case | Global tracking | Specific watchers |
| Performance | Tracks everything | Optimized for paths |

**When to use OnChange:**
- Debug logging
- Global persistence
- Analytics tracking
- DevTools integration
- You need to see all changes

**When to use Matcher:**
- Watch specific data
- React to specific paths
- Need hierarchical matching
- Better performance for selective watching

## Performance Tips

1. **Remove listeners when done** - Always unsubscribe to prevent memory leaks
2. **Avoid heavy computations** - Listeners are called frequently, keep them fast
3. **Use debouncing** for expensive operations like server sync
4. **Filter early** if you only care about specific changes
5. **Batch operations** when making multiple changes to reduce listener calls

```javascript
// Good: Remove when done
const unsub = state.onChange.addListener(handler);
cleanup(() => unsub());

// Bad: Memory leak
state.onChange.addListener(handler);
```

## License

See the root [LICENSE](../../LICENSE) file for license information.

## Related Packages

- [@jucio.io/state](../../core) - Core state management system
- [@jucio.io/state/history](../history) - Undo/redo functionality
- [@jucio.io/state/matcher](../matcher) - Path-based change tracking

