Atualizações otimistas
As atualizações otimistas permitem interfaces muito responsivas e rápidas ao evitar os tempos de espera da rede. Uma atualização é otimista por presumir que a rede terá sucesso.
Fazer isso amplifica e cria novas race conditions; felizmente, o Reactive Data Client cuida delas automaticamente para você.
Resources
resource() pode ser configurado definindo optimistic: true.
import { Entity, resource } from '@data-client/rest'; export class Todo extends Entity { id = 0; userId = 0; title = ''; completed = false; static key = 'Todo'; } export const TodoResource = resource({ urlPrefix: 'https://jsonplaceholder.typicode.com', path: '/todos/:id', searchParams: {} as { userId?: string | number } | undefined, schema: Todo, optimistic: true, });
Isso torna todas as mutações otimistas usando algumas implementações padrão sensatas que cobrem a maioria dos casos.
update/getList.push/getList.unshift
function optimisticUpdate(
snap: SnapshotInterface,
params: any,
body: any,
) {
return {
...params,
...ensureBodyPojo(body),
};
}
function ensureBodyPojo(body: any) {
return body instanceof FormData
? Object.fromEntries((body as any).entries())
: body;
}
Em criações (push/unshift), isso normalmente resulta em nenhum id na resposta para calcular uma pk.
O Data Client criará uma pk aleatória para fazer isso funcionar.
Até que o objeto seja de fato criado, fazer mutações nesse objeto geralmente não funciona.
Por isso, pode ser prudente nesses casos desabilitar novas mutações até que o
POST real seja concluído. Uma maneira de determinar isso é simplesmente verificar a existência de
um id real na entity.
partialUpdate
function optimisticPartial(schema: Queryable) {
return function (snap: SnapshotInterface, params: any, body: any) {
const data = snap.get(schema, params);
if (!data) throw snap.abort;
return {
...params,
...data,
// even tho we don't always have two arguments, the extra one will simply be undefined which spreads fine
...ensurePojo(body),
};
};
}
Atualizações parciais não enviam o corpo inteiro, então podemos usar a entity do store para calcular a resposta esperada. Os Snapshots nos dão acesso seguro ao valor existente no store, de forma robusta contra quaisquer race conditions.
delete
function optimisticDelete(snap: SnapshotInterface, params: any) {
return params;
}
Caso você não queira que todos os endpoints sejam otimistas, ou se tiver designs de API incomuns, pode definir getOptimisticResponse() usando Resource.extend()
Transformações otimistas
Às vezes, ações do usuário devem resultar em transformações de dados que dependem do estado anterior dos dados. Os exemplos mais simples disso são alternar um booleano ou incrementar um contador; mas o mesmo princípio vale para transformações mais complicadas. Para deixar isso mais evidente, usamos aqui um contador simples.
import { RestEndpoint } from '@data-client/rest'; import { CountEntity, getCount } from './count'; export const increment = new RestEndpoint({ path: '/api/count/increment', method: 'POST', body: undefined, name: 'increment', schema: CountEntity, getOptimisticResponse(snap) { const data = snap.get(CountEntity, {}); if (!data) throw snap.abort; return { count: data.count + 1, }; }, });
O Reactive Data Client trata automaticamente todas as race conditions causadas pelos tempos de rede. Ele acompanha os tempos dos fetches, associa as respostas à respectiva atualização otimista e faz rollback em caso de resolução ou rejeição/falha.
Você pode ver como isso é problemático para outras bibliotecas mesmo sem atualizações otimistas; mas as atualizações otimistas pioram ainda mais a situação.
Exemplo de race condition
Eis um exemplo da race condition. Aqui solicitamos um incremento duas vezes, mas a primeira resposta volta para o cliente depois da segunda.
Com outras bibliotecas e sem atualizações otimistas, isso resultaria em exibir 0, depois 2, depois 1.
Se a outra biblioteca tiver atualizações otimistas, ela exibiria 0, 1, 2, 2 e depois 1.
Nos dois casos acabamos exibindo um estado incorreto e, pelo caminho, vemos atualizações de estado estranhas e travadas.
Compensando variações de tempo do servidor
Existem três tempos que podem variar em uma mutação assíncrona.
- Tempo da requisição
- Tempo do servidor
- Tempo da resposta
O Reactive Data Client consegue lidar automaticamente com os tempos de rede, ou seja, o tempo da requisição e o da resposta. Normalmente isso é suficiente, pois os servidores tendem a processar primeiro as requisições recebidas antes. No entanto, caso a ordem de persistência varie em relação à ordem das requisições no servidor, isso pode causar outra race condition.
Isso pode ser resolvido mantendo uma ordem total. Como
servidores e clientes podem ter horários diferentes, precisamos acompanhar o tempo a partir de uma perspectiva consistente.
Como estamos fazendo atualizações otimistas, isso significa que devemos usar o relógio do cliente. Isso quer dizer que enviaremos o tempo
da requisição ao servidor em um header updatedAt por meio de getRequestInit(). O servidor deve então garantir o processamento com base nessa ordem e
armazenar esse updatedAt na entity para retorná-lo em qualquer requisição.
Sobrescrevendo shouldReorder, podemos reordenar respostas fora de ordem com base no timestamp do servidor.
Usamos snap.fetchedAt em nosso getOptimisticResponse. Ele representa o momento em que o fetch é disparado, que será o mesmo momento em que o header updatedAt é calculado.
import { RestEndpoint } from '@data-client/rest'; import { CountEntity } from './count'; export const increment = new RestEndpoint({ path: '/api/count/increment', method: 'POST', body: undefined, name: 'increment', schema: CountEntity, getRequestInit() { // this is a substitute for super.getRequestInit() // since we aren't in a class context return RestEndpoint.prototype.getRequestInit.call(this, { updatedAt: Date.now(), }); }, getOptimisticResponse(snap) { const data = snap.get(CountEntity, {}); if (!data) throw snap.abort; return { count: data.count + 1, updatedAt: snap.fetchedAt, }; }, });