Skip to main content

Optimistic Updates

Optimistic updates enable highly responsive and fast interfaces by avoiding network wait times. An update is optimistic by assuming the network is successful.

Doing this amplifies and creates new race conditions; thankfully Reactive Data Client automatically handles these for you.

Resources

resource() can be configured by setting optimistic: true.

import { Entity, resource } from '@data-client/rest';

export class Todo extends Entity {
  id = 0;
  userId = 0;
  title = '';
  completed = false;
  pk() {
    return `${this.id}`;
  }
  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,
});
🔴 Live Preview
Store

This makes all mutations optimistic using some sensible default implementations that handle most cases.

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

For creates (push/unshift) this typically results in no id in the response to compute a pk. Data Client will create a random pk to make this work.

Until the object is actually created, doing mutations on that object generally does not work. Therefore, it may be prudent in these cases to disable further mutations until the actual POST is completed. One way to determine this is to simply look for the existance of a real id in the 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),
};
};
}

Partial updates do not send the entire body, so we can use the entity from the store to compute the expected response. Snapshots give us safe access to the existing store value that is robust against any race conditions.

delete

function optimisticDelete(snap: SnapshotInterface, params: any) {
return params;
}

In case you do not want all endpoints to be optimistic, or if you have unusual API designs, you can set getOptimisticResponse() using Resource.extend()

Optimistic Transforms

Sometimes user actions should result in data transformations that are dependent on the previous state of data. The simplest examples of this are toggling a boolean, or incrementing a counter; but the same principal applies to more complicated transforms. To make it more obvious we're using a simple counter here.

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,
    };
  },
});
🔴 Live Preview
Store

Reactive Data Client automatically handles all race conditions due to network timings. Reactive Data Client both tracks fetch timings, pairs responses with their respective optimistic update and rollsback in case of resolution or rejection/failure.

You can see how this is problematic for other libraries even without optimistic updates; but optimistic updates make it even worse.

Example race condition

Here's an example of the race condition. Here we request an increment twice; but the first response comes back to client after the second response.

With other libraries and no optimistic updates this would result in showing 0, then, 2, then 1.

If the other library does have optimistic updates, it should show 0, 1, 2, 2, then 1.

In both cases we end up showing an incorrect state, and along the way see weird janky state updates.

Compensating for Server timing variations

There are three timings which can vary in an async mutation.

  1. Request timing
  2. Server timing
  3. Response timing

Reactive Data Client is able to automatically handling the network timings, aka request and response timing. Typically this is sufficient, as servers tend to process requests received first before others. However, in case persist order varies from request order in the server this could cause another race condition.

This can be be solved by maintaining a total order. Because the servers and clients can potentially has different times, we will need to track time from a consistent perspective. Since we are performing optimistic updates this means we must use the client's clock. This means we will send the request timing to the server in an updatedAt header via getRequestInit(). The server should then ensure processing based on that order, and then store this updatedAt in the entity to return in any request.

Overriding shouldReorder, we can reorder out-of-order responses based on the server timestamp.

We use snap.fetchedAt in our getOptimisticResponse. This respresents the moment the fetch is triggered, which will be the same time the updatedAt header is computed.

import { getCount, 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,
    };
  },
});
🔴 Live Preview
Store