Pular para o conteúdo principal

Managers e Middleware

O Reactive Data Client usa o padrão de store flux, caracterizado pelo fluxo de dados unidirecional do store, fácil de entender e depurar. As atualizações de estado são realizadas por uma função reducer.

Fluxo flux do ManagerFluxo flux do Manager

Em arquiteturas flux, é fundamental que todas as funções do ciclo flux sejam puras. Os Managers fornecem a orquestração centralizada de efeitos colaterais. Em outras palavras, são o meio de interagir com o mundo fora do Data Client.

Por exemplo, o NetworkManager orquestra a busca de dados e o SubscriptionManager acompanha quais recursos estão assinados com useLive ou useSubscription. Ao centralizar o controle, o NetworkManager deduplica fetches automaticamente, e o SubscriptionManager mantém atualizados apenas os recursos renderizados ativamente.

Isso torna os Managers a melhor forma de integrar efeitos colaterais adicionais, como logging, relatório de erros, métricas, notificações, fluxos de dados, atualização ao focar ou reconectar, sincronização entre abas e persistência offline. Eles também podem ser personalizados para alterar comportamentos centrais.

Managers padrão
NetworkManagerTransforma dispatches de fetch em chamadas de rede
SubscriptionManagerTrata assinaturas de polling
DevToolsManagerHabilita a depuração
Managers extras
LogoutManagerTrata HTTP 401 (ou outras condições de logout)

Examples​

O Reactive Data Client melhora a tipagem segura e a ergonomia ao realizar dispatches e o acesso ao store por meio do seu Controller

Logging com middleware​

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

Relatório de erros​

Reporte fetches com falha a serviços de monitoramento como o Sentry inspecionando as actions SET_RESPONSE com error definido.

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

Métricas​

Acompanhe o tempo dos fetches observando as actions FETCH. action.meta.promise é resolvida quando o fetch termina.

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

Notificações (toasts)​

Exiba um toast quando qualquer mutação for bem-sucedida ou falhar.

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

Atualizar ao focar ou reconectar​

Controller.expireAll() marca os dados como Stale, disparando um novo fetch de qualquer dado renderizado ativamente sem suspender (stale-while-revalidate). init() e cleanup() gerenciam os event listeners.

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

Sincronização entre abas​

Quando uma mutação é bem-sucedida em uma aba, marque os dados como desatualizados em todas as outras abas usando BroadcastChannel.

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

Persistência offline​

Persista o store com IndexedDB (aqui via idb-keyval); restaure-o com initialState do DataProvider. As escritas no IndexedDB são assíncronas e usam structured clone em vez de bloquear a thread principal com serialização JSON, como o localStorage faria. Aplicar debounce às escritas mantém barato o custo de rajadas rápidas de actions. Considere os tempos de expiração ao restaurar.

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

Fluxo de dados com middleware (baseado em push)​

Adicionar um manager para processar dados enviados pelo servidor via websockets ou Server Sent Events garante que possamos manter os dados atualizados quando as atualizações independem de ações do usuário. Por exemplo, o preço em um aplicativo de negociação ou um editor colaborativo em tempo real.

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() permite atualizar diretamente Schemas Querable com event.data.

Agrupando atualizações de alta frequência​

Streams como tickers de bolsa podem enviar centenas de mensagens por segundo, e as conexões costumam começar com um snapshot grande. Em vez de chamar set() a cada mensagem, armazene-as em buffer e escreva cada lote com um schema Array. Controller.set([Entity], rows) normaliza todas as linhas em uma única atualização do store.

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 = {};
}
}

Linhas de um mesmo lote que compartilham uma pk são mescladas em ordem e ignoram Entity.shouldReorder(), portanto armazene em buffer apenas a mensagem mais recente por pk quando a ordem importar.

Experimente os dois botões abaixo. Esta verificação no navegador parte de um store vazio e mede o tempo de um Promise.all com 500 chamadas de set() em comparação com um único set() em lote. Ambos os caminhos resultam em um único commit do React, e cada um grava 500 preços novos.

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)),
      ),
    );
  // highlight-next-line
  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 />);
Resultado
Store▶

Ignorando o DevTools em atualizações de alta frequência​

Ao usar WebSockets ou outras fontes de dados em tempo real, pode ser interessante não registrar certas actions de alta frequência no DevToolsManager, para evitar sobrecarregar a extensão do navegador.

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​

Explore o exemplo coin-app

More Demos