# Managers and Middleware

Reactive Data Client uses the [flux store](https://facebookarchive.github.io/flux/docs/in-depth-overview/) pattern, which is
characterized by an easy to [understand and debug](https://dataclient.io/docs/getting-started/debugging.md) the store's [undirectional data flow](https://en.wikipedia.org/wiki/Unidirectional_Data_Flow_\(computer_science\)). State updates are performed by a [reducer function](https://github.com/reactive/data-client/blob/master/packages/core/src/state/reducer/createReducer.ts#L19).

In flux architectures, it is critical all functions in the flux loop are [pure](https://react.dev/learn/keeping-components-pure).
Managers provide centralized orchestration of side effects. In other words, they are the means to interface
with the world outside Data Client.

For instance, [NetworkManager](https://dataclient.io/docs/api/NetworkManager.md) orchestrates data fetching and [SubscriptionManager](https://dataclient.io/docs/api/SubscriptionManager.md)
keeps track of which resources are subscribed with [useLive](https://dataclient.io/docs/api/useLive.md) or [useSubscription](https://dataclient.io/docs/api/useSubscription.md). By centralizing control, [NetworkManager](https://dataclient.io/docs/api/NetworkManager.md) automatically deduplicates fetches, and [SubscriptionManager](https://dataclient.io/docs/api/SubscriptionManager.md)
will keep only actively rendered resources updated.

This makes [Managers](https://dataclient.io/docs/api/Manager.md) the best way to integrate additional side-effects like
[logging](#middleware-logging), [error reporting](#error-reporting), [metrics](#metrics),
[notifications](#notifications), [data streams](#data-stream), [refreshing on focus or reconnect](#refresh-on-focus),
[cross-tab synchronization](#cross-tab-sync), and [offline persistence](#persistence).
They can also be customized to change core behaviors.

| Default managers                                                             |                                                                                                              |
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| [NetworkManager](https://dataclient.io/docs/api/NetworkManager.md)           | Turns fetch dispatches into network calls                                                                    |
| [SubscriptionManager](https://dataclient.io/docs/api/SubscriptionManager.md) | Handles polling [subscriptions](https://dataclient.io/docs/getting-started/data-dependency.md#subscriptions) |
| [DevToolsManager](https://dataclient.io/docs/api/DevToolsManager.md)         | Enables [debugging](https://dataclient.io/docs/getting-started/debugging.md)                                 |
| Extra managers                                                               |                                                                                                              |
| [LogoutManager](https://dataclient.io/docs/api/LogoutManager.md)             | Handles HTTP `401` (or other logout conditions)                                                              |

## Examples

Reactive Data Client improves type-safety and ergonomics by performing dispatches and store access with
its [Controller](https://dataclient.io/docs/api/Controller.md)

### Middleware logging

```typescript
import type { Manager, Middleware } from '@data-client/react';

export default class LoggingManager implements Manager {
  middleware: Middleware = controller => next => async action => {
    console.log('before', action, controller.getState());
    await next(action);
    console.log('after', action, controller.getState());
  };

  cleanup() {}
}
```

### Error reporting {#error-reporting}

Report failed fetches to monitoring services like [Sentry](https://sentry.io) by inspecting
[SET\_RESPONSE](https://dataclient.io/docs/api/Actions.md#set_response) actions with `error` set.

```typescript
import {
  type Manager,
  type Middleware,
  actionTypes,
} from '@data-client/react';
import { captureException } from '@sentry/react';

export default class ErrorReportManager implements Manager {
  middleware: Middleware = controller => next => async action => {
    if (action.type === actionTypes.SET_RESPONSE && action.error)
      captureException(action.response, {
        extra: { endpoint: action.endpoint.name, args: action.args },
      });
    return next(action);
  };

  cleanup() {}
}
```

### Metrics {#metrics}

Track fetch timing by observing [FETCH](https://dataclient.io/docs/api/Actions.md#fetch) actions. `action.meta.promise`
resolves when the fetch completes.

```typescript
import {
  type Manager,
  type Middleware,
  actionTypes,
} from '@data-client/react';
import { trackTiming } from './analytics';

export default class MetricsManager implements Manager {
  middleware: Middleware = controller => next => async action => {
    if (action.type === actionTypes.FETCH) {
      const start = performance.now();
      action.meta.promise
        .finally(() => {
          trackTiming(action.endpoint.name, performance.now() - start);
        })
        // the fetch's caller handles errors; this only observes timing
        .catch(() => {});
    }
    return next(action);
  };

  cleanup() {}
}
```

### Notifications (toasts) {#notifications}

Show a toast when any [mutation](https://dataclient.io/rest/guides/side-effects.md) succeeds or fails.

```typescript
import {
  type Manager,
  type Middleware,
  actionTypes,
} from '@data-client/react';
import { toast } from './toast';

export default class ToastManager implements Manager {
  middleware: Middleware = controller => next => async action => {
    if (
      action.type === actionTypes.SET_RESPONSE &&
      action.endpoint.sideEffect
    ) {
      if (action.error) toast.error(`${action.endpoint.name} failed`);
      else toast.success(`${action.endpoint.name} succeeded`);
    }
    return next(action);
  };

  cleanup() {}
}
```

### Refresh on focus or reconnect {#refresh-on-focus}

[Controller.expireAll()](https://dataclient.io/docs/api/Controller.md#expireAll) marks data as [Stale](https://dataclient.io/docs/concepts/expiry-policy.md#stale),
triggering refetch of any _actively rendered_ data without suspending ([stale-while-revalidate](https://dataclient.io/docs/concepts/expiry-policy.md)).
[init()](https://dataclient.io/docs/api/Manager.md#init) and [cleanup()](https://dataclient.io/docs/api/Manager.md#cleanup) manage the event listeners.

```typescript
import type { Manager, Middleware, Controller } from '@data-client/react';

export default class RefreshManager implements Manager {
  declare protected controller: Controller;
  protected handle = () =>
    this.controller.expireAll({ testKey: () => true });

  middleware: Middleware = controller => {
    this.controller = controller;
    return next => async action => next(action);
  };

  init() {
    window.addEventListener('focus', this.handle);
    window.addEventListener('online', this.handle);
  }

  cleanup() {
    window.removeEventListener('focus', this.handle);
    window.removeEventListener('online', this.handle);
  }
}
```

### Cross-tab synchronization {#cross-tab-sync}

When a mutation succeeds in one tab, mark data stale in all other tabs using
[BroadcastChannel](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel).

```typescript
import {
  type Manager,
  type Middleware,
  actionTypes,
} from '@data-client/react';

export default class TabSyncManager implements Manager {
  protected channel = new BroadcastChannel('data-client');

  middleware: Middleware = controller => {
    this.channel.onmessage = () =>
      controller.expireAll({ testKey: () => true });
    return next => async action => {
      if (
        action.type === actionTypes.SET_RESPONSE &&
        action.endpoint.sideEffect &&
        !action.error
      )
        this.channel.postMessage('mutation');
      return next(action);
    };
  };

  cleanup() {
    this.channel.close();
  }
}
```

### Offline persistence {#persistence}

Persist the store with [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API)
(here via [idb-keyval](https://www.npmjs.com/package/idb-keyval)); restore it with
[DataProvider's initialState](https://dataclient.io/docs/api/DataProvider.md#initialState). IndexedDB writes are
asynchronous and use [structured clone](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm)
instead of blocking the main thread with JSON serialization like `localStorage` would.
Debouncing writes keeps rapid action bursts cheap. Consider [expiry times](https://dataclient.io/docs/concepts/expiry-policy.md)
when restoring.

```typescript
import type { Manager, Middleware } from '@data-client/react';
import { set } from 'idb-keyval';

export default class PersistManager implements Manager {
  declare protected timer?: ReturnType<typeof setTimeout>;

  middleware: Middleware = controller => next => async action => {
    await next(action);
    // debounce: persist at most once per second
    clearTimeout(this.timer);
    this.timer = setTimeout(() => {
      // in-flight optimistic updates reference functions, so are not persistable
      const state = { ...controller.getState(), optimistic: [] };
      set('data-client', state);
    }, 1000);
  };

  cleanup() {
    clearTimeout(this.timer);
  }
}
```

```tsx title="index.tsx"
import { DataProvider, getDefaultManagers } from '@data-client/react';
import { createRoot } from 'react-dom/client';
import { get } from 'idb-keyval';
import App from './App';
import PersistManager from './PersistManager';

const managers = [...getDefaultManagers(), new PersistManager()];
const initialState = await get('data-client');

createRoot(document.body).render(
  <DataProvider initialState={initialState} managers={managers}>
    <App />
  </DataProvider>,
);
```

### Middleware data stream (push-based) {#data-stream}

Adding a manager to process data pushed from the server by [websockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API)
or [Server Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) ensures
we can maintain fresh data when the data updates are independent of user action. For example, a trading app's
price, or a real-time collaborative editor.

```typescript
import type {
  Manager,
  Middleware,
  Controller,
  EntityInterface,
} from '@data-client/react';

export default class StreamManager implements Manager {
  declare protected controller: Controller;
  declare protected evtSource: WebSocket | EventSource;
  declare protected createEventSource: () => WebSocket | EventSource;
  declare protected entities: Record<string, EntityInterface>;

  constructor(
    createEventSource: () => WebSocket | EventSource,
    entities: Record<string, EntityInterface>,
  ) {
    this.createEventSource = createEventSource;
    this.entities = entities;
  }

  middleware: Middleware = controller => {
    this.controller = controller;
    return next => async action => next(action);
  };

  connect() {
    this.evtSource = this.createEventSource();
    this.evtSource.onmessage = (event: MessageEvent) => {
      try {
        const msg: { type: string; args: [any]; data: any } = JSON.parse(
          event.data,
        );
        if (msg.type in this.entities)
          this.controller.set(
            this.entities[msg.type],
            ...msg.args,
            msg.data,
          );
      } catch (e) {
        console.error('Failed to handle message');
        console.error(e);
      }
    };
  }

  init() {
    this.connect();
  }

  cleanup() {
    this.evtSource?.close();
  }
}
```

[Controller.set()](https://dataclient.io/docs/api/Controller.md#set) allows directly updating [Querable Schemas](https://dataclient.io/rest/api/schema.md#queryable)
directly with `event.data`.

#### Batching high-frequency updates {#batching}

Streams like exchange tickers can send hundreds of messages per second, and connections often start with a large snapshot.
Rather than calling `set()` per message, buffer them and write each batch with an [Array](https://dataclient.io/rest/api/Array.md) schema.
[Controller.set(\[Entity\], rows)](https://dataclient.io/docs/api/Controller.md#set-array) normalizes every row in one store update.

```typescript
export default class StreamManager implements Manager {
  // ...
  protected buffer: Record<string, any[]> = {};
  declare protected flushTimeout?: ReturnType<typeof setTimeout>;

  connect() {
    this.evtSource = this.createEventSource();
    this.evtSource.onmessage = event => {
      const msg = JSON.parse(event.data);
      if (msg.type in this.entities) {
        (this.buffer[msg.type] ??= []).push(msg.data);
        this.flushTimeout ??= setTimeout(this.flush, 50);
      }
    };
  }

  flush = () => {
    const buffer = this.buffer;
    this.buffer = {};
    this.flushTimeout = undefined;
    for (const type in buffer) {
      this.controller.set([this.entities[type]], buffer[type]);
    }
  };

  cleanup() {
    this.evtSource?.close();
    clearTimeout(this.flushTimeout);
    this.flushTimeout = undefined;
    this.buffer = {};
  }
}
```

Rows in one batch that share a pk merge in order and skip [Entity.shouldReorder()](https://dataclient.io/rest/api/Entity.md#shouldreorder),
so buffer only the latest message per pk when order matters.

Try both buttons below. This browser check starts from an empty store and times `Promise.all` of 500 `set()`
calls against one batch `set()`. Both paths are one React commit, and each writes 500 new prices.

```ts title="Ticker"
import { Entity } from '@data-client/rest';

export class Ticker extends Entity {
  product_id = '';
  price = 0;

  pk() {
    return this.product_id;
  }
  static key = 'Ticker';
}

export const newPrices = () =>
  Array.from({ length: 500 }, (_, i) => ({
    product_id: `COIN-${i}`,
    price: Math.round(Math.random() * 10000) / 100,
  }));
```

```tsx title="PriceStream"
import React from 'react';
import { useController, useQuery } from '@data-client/react';
import { Ticker, newPrices } from './Ticker';

function PriceStream() {
  const ctrl = useController();
  const [timing, setTiming] = React.useState('');
  const first = useQuery(Ticker, { product_id: 'COIN-0' });

  const time = async (
    label: string,
    write: (rows: ReturnType<typeof newPrices>) => Promise<unknown>,
  ) => {
    const rows = newPrices();
    const start = performance.now();
    await write(rows);
    setTiming(`${label}: ${(performance.now() - start).toFixed(1)} ms`);
  };
  const perRow = () =>
    time('500 set() calls', rows =>
      Promise.all(
        rows.map(row => ctrl.set(Ticker, { product_id: row.product_id }, row)),
      ),
    );
  const batch = () => time('1 batch set()', rows => ctrl.set([Ticker], rows));

  return (
    <div>
      <button onClick={perRow}>set() per row</button>{' '}
      <button onClick={batch}>batch set()</button>
      <p>COIN-0: {first ? `$${first.price}` : 'no data yet'}</p>
      <p>{timing}</p>
    </div>
  );
}
render(<PriceStream />);
```

#### Skipping DevTools for high-frequency updates

When using WebSockets or other real-time data sources, you may want to skip logging
certain high-frequency actions to [DevToolsManager](https://dataclient.io/docs/api/DevToolsManager.md) to avoid
overwhelming the browser extension.

```typescript
import { getDefaultManagers, actionTypes } from '@data-client/react';
import StreamManager from './StreamManager';
import { Ticker } from './Ticker';

export default function getManagers() {
  return [
    new StreamManager(() => new WebSocket('wss://ws-feed.example.com'), {
      ticker: Ticker,
    }),
    ...getDefaultManagers({
      devToolsManager: {
        // Increase latency buffer for high-frequency updates
        latency: 1000,
        // Skip WebSocket SET actions to avoid log spam
        // (batched writes use the [Ticker] schema)
        predicate: (state, action) =>
          action.type !== actionTypes.SET ||
          (action.schema !== Ticker && action.schema[0] !== Ticker),
      },
    }),
  ];
}
```

### Coin App

Example app: [coin-app](https://github.com/reactive/data-client/tree/master/examples/coin-app) ([`src/getManagers.ts`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/getManagers.ts), [`src/resources/Ticker.ts`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/resources/Ticker.ts), [`src/pages/AssetDetail/AssetPrice.tsx`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/pages/AssetDetail/AssetPrice.tsx), [`src/resources/StreamManager.ts`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/resources/StreamManager.ts))
