Pular para o conteúdo principal

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

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

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.

  1. Tempo da requisição
  2. Tempo do servidor
  3. 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,
    };
  },
});
Resultado
Store▶