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/vue';

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/vue';
import { captureException } from '@sentry/vue';

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/vue';
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/vue';
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/vue';

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/vue';

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 opção initialState do DataClientPlugin. 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/vue';
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);
}
}
main.ts
import { createApp } from 'vue';
import { DataClientPlugin, getDefaultManagers } from '@data-client/vue';
import { get } from 'idb-keyval';
import App from './App.vue';
import PersistManager from './PersistManager';

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

const app = createApp(App);
app.use(DataClientPlugin, { initialState, managers });
app.mount('#app');

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/vue';

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.

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/vue';
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),
},
}),
];
}