Skip to main content

Endpoint Expiry Policy

By default, Reactive Data Client cache policy can be described as stale-while-revalidate. This means that when data is available it can avoid blocking the application by using the stale data. However, in the background it will still refresh the data if old enough.

Expiry status

Fresh

Data in this state is considered new enough that it doesn't need to fetch.

Stale

Data is still allowed to be shown, however Reactive Data Client might attempt to revalidate by fetching again.

useSuspense() considers fetching on mount as well as when its parameters change. In these cases it will fetch if the data is considered stale.

React Native

When using React Navigation, focus events also trigger fetches for stale data.

Invalid

Data should not be shown. Any components needing this data will trigger fetch and suspense. If no components care about this data no action will be taken.

Expiry Time

Endpoint.dataExpiryLength

Endpoint.dataExpiryLength sets how long (in miliseconds) it takes for data to transition from 'fresh' to 'stale' status. Try setting it to a very low number like '50' to make it becomes stale almost instantly; or a very large number to stay around for a long time.

Toggling between 'first' and 'second' changes the parameters. If the data is still considered fresh you will continue to see the old time without any refresh.

Fixtures
GET /api/currentTime/:id
api/lastUpdated
export class TimedEntity extends Entity {
  id = '';
  updatedAt = Temporal.Instant.fromEpochSeconds(0);

  pk() {
    return this.id;
  }
  static schema = {
    updatedAt: Temporal.Instant.from,
  };
}

export const lastUpdated = new RestEndpoint({
  path: '/api/currentTime/:id',
  schema: TimedEntity,
});
TimePage
import { lastUpdated } from './api/lastUpdated';

const getUpdated = lastUpdated.extend({ dataExpiryLength: 10000 });

export default function TimePage({ id }) {
  const { updatedAt } = useSuspense(getUpdated, { id });
  return (
    <div>
      API time for {id}:{' '}
      <time>
        {DateTimeFormat('en-US', { timeStyle: 'long' }).format(
          updatedAt,
        )}
      </time>
    </div>
  );
}
Navigator
import TimePage from './TimePage';

function Navigator() {
  const [id, setId] = React.useState('1');
  const handleChange = e => setId(e.currentTarget.value);
  return (
    <div>
      <div>
        <button value="1" onClick={handleChange}>
          First
        </button>
        <button value="2" onClick={handleChange}>
          Second
        </button>
      </div>
      <AsyncBoundary fallback={<div>loading...</div>}>
        <TimePage id={id} />
      </AsyncBoundary>
      <div>
        Current Time: <CurrentTime />
      </div>
    </div>
  );
}
render(<Navigator />);
🔴 Live Preview
Store
@data-client/rest

Long cache lifetime

LongLivingResource.ts
import {
RestEndpoint,
RestGenerics,
createResource,
} from '@data-client/rest';

// We can now use LongLivingEndpoint to create endpoints that will be cached for one hour
class LongLivingEndpoint<
O extends RestGenerics,
> extends RestEndpoint<O> {
dataExpiryLength = 60 * 60 * 1000; // one hour
}

const LongLivingResource = createResource({
path: '/:id',
Endpoint: LongLivingEndpoint,
});

Never retry on error

NoRetryResource.ts
import {
RestEndpoint,
RestGenerics,
createResource,
} from '@data-client/rest';

// We can now use NoRetryEndpoint to create endpoints that will be cached for one hour
class NoRetryEndpoint<
O extends RestGenerics,
> extends RestEndpoint<O> {
errorExpiryLength = Infinity;
}

const NoRetryResource = createResource({
path: '/:id',
Endpoint: NoRetryEndpoint,
});

Endpoint.invalidIfStale

Endpoint.invalidIfStale eliminates the 'stale' status, making data that expires immediately be considered 'invalid'.

This is demonstrated by the component suspending once its data goes stale. If the data is still within the expiry time it just continues to display it.

Fixtures
GET /api/currentTime/:id
api/lastUpdated
export class TimedEntity extends Entity {
  id = '';
  updatedAt = Temporal.Instant.fromEpochSeconds(0);
  pk() {
    return this.id;
  }

  static schema = {
    updatedAt: Temporal.Instant.from,
  };
}

export const lastUpdated = new RestEndpoint({
  path: '/api/currentTime/:id',
  schema: TimedEntity,
});
TimePage
import { lastUpdated } from './api/lastUpdated';

const getUpdated = lastUpdated.extend({
  invalidIfStale: true,
  dataExpiryLength: 5000,
});

export default function TimePage({ id }) {
  const { updatedAt } = useSuspense(getUpdated, { id });
  return (
    <div>
      API time for {id}:{' '}
      <time>
        {DateTimeFormat('en-US', { timeStyle: 'long' }).format(
          updatedAt,
        )}
      </time>
    </div>
  );
}
Navigator
import TimePage from './TimePage';

function Navigator() {
  const [id, setId] = React.useState('1');
  const handleChange = e => setId(e.currentTarget.value);
  return (
    <div>
      <div>
        <button value="1" onClick={handleChange}>
          First
        </button>
        <button value="2" onClick={handleChange}>
          Second
        </button>
      </div>
      <AsyncBoundary fallback={<div>loading...</div>}>
        <TimePage id={id} />
      </AsyncBoundary>
      <div>
        Current Time: <CurrentTime />
      </div>
    </div>
  );
}
render(<Navigator />);
🔴 Live Preview
Store

Force refresh

We sometimes want to fetch new data; while continuing to show the old (stale) data.

A specific endpoint

Controller.fetch can be used to trigger a fetch while still showing the previous data. This can be done even with 'fresh' data.

Fixtures
GET /api/currentTime/:id
api/lastUpdated
export class TimedEntity extends Entity {
  id = '';
  updatedAt = Temporal.Instant.fromEpochSeconds(0);
  pk() {
    return this.id;
  }

  static schema = {
    updatedAt: Temporal.Instant.from,
  };
}

export const lastUpdated = new RestEndpoint({
  path: '/api/currentTime/:id',
  schema: TimedEntity,
});
ShowTime
import { lastUpdated } from './api/lastUpdated';

function ShowTime() {
  const { updatedAt } = useSuspense(lastUpdated, { id: '1' });
  const ctrl = useController();
  return (
    <div>
      <time>
        {DateTimeFormat('en-US', { timeStyle: 'long' }).format(
          updatedAt,
        )}
      </time>{' '}
      <button onClick={() => ctrl.fetch(lastUpdated, { id: '1' })}>
        Refresh
      </button>
    </div>
  );
}
render(<ShowTime />);
🔴 Live Preview
Store

Refresh visible endpoints

Controller.expireAll() sets all responses' expiry status matching testKey to Stale.

Fixtures
GET /api/currentTime/:id
api/lastUpdated
export class TimedEntity extends Entity {
  id = '';
  updatedAt = Temporal.Instant.fromEpochSeconds(0);
  pk() {
    return this.id;
  }

  static schema = {
    updatedAt: Temporal.Instant.from,
  };
}

export const lastUpdated = new RestEndpoint({
  path: '/api/currentTime/:id',
  schema: TimedEntity,
});
ShowTime
import { lastUpdated } from './api/lastUpdated';

export default function ShowTime({ id }: { id: string }) {
  const { updatedAt } = useSuspense(lastUpdated, { id });
  const ctrl = useController();
  return (
    <div>
      <b>{id}</b>{' '}
      <time>
        {DateTimeFormat('en-US', { timeStyle: 'long' }).format(
          updatedAt,
        )}
      </time>
    </div>
  );
}
Loading
export default function Loading({ id }: { id: string }) {
  return <div>{id} Loading...</div>;
}
Demo
import { AsyncBoundary } from '@data-client/react';

import { lastUpdated } from './api/lastUpdated';
import ShowTime from './ShowTime';
import Loading from './Loading';

function Demo() {
  const ctrl = useController();
  return (
    <div>
      <AsyncBoundary fallback={<Loading id="1" />}>
        <ShowTime id="1" />
      </AsyncBoundary>
      <AsyncBoundary fallback={<Loading id="2" />}>
        <ShowTime id="2" />
      </AsyncBoundary>
      <AsyncBoundary fallback={<Loading id="3" />}>
        <ShowTime id="3" />
      </AsyncBoundary>

      <button onClick={() => ctrl.expireAll(lastUpdated)}>
        Expire All
      </button>
      <button onClick={() => ctrl.fetch(lastUpdated, { id: '1' })}>
        Force Refresh First
      </button>
    </div>
  );
}
render(<Demo />);
🔴 Live Preview
Store

Invalidate (re-suspend)

Both endpoints and entities can be targetted to be invalidated.

A specific endpoint

In this example we can see invalidating the endpoint shows the loading fallback since the data is not allowed to be displayed.

Fixtures
GET /api/currentTime/:id
api/lastUpdated
export class TimedEntity extends Entity {
  id = '';
  updatedAt = Temporal.Instant.fromEpochSeconds(0);
  pk() {
    return this.id;
  }

  static schema = {
    updatedAt: Temporal.Instant.from,
  };
}

export const lastUpdated = new RestEndpoint({
  path: '/api/currentTime/:id',
  schema: TimedEntity,
});
ShowTime
import { lastUpdated } from './api/lastUpdated';

export default function ShowTime({ id }: { id: string }) {
  const { updatedAt } = useSuspense(lastUpdated, { id });
  const ctrl = useController();
  return (
    <div>
      <b>{id}</b>{' '}
      <time>
        {DateTimeFormat('en-US', { timeStyle: 'long' }).format(
          updatedAt,
        )}
      </time>
    </div>
  );
}
Loading
export default function Loading({ id }: { id: string }) {
  return <div>{id} Loading...</div>;
}
Demo
import { AsyncBoundary } from '@data-client/react';

import { lastUpdated } from './api/lastUpdated';
import ShowTime from './ShowTime';
import Loading from './Loading';

function Demo() {
  const ctrl = useController();
  return (
    <div>
      <AsyncBoundary fallback={<Loading id="1" />}>
        <ShowTime id="1" />
      </AsyncBoundary>
      <AsyncBoundary fallback={<Loading id="2" />}>
        <ShowTime id="2" />
      </AsyncBoundary>
      <AsyncBoundary fallback={<Loading id="3" />}>
        <ShowTime id="3" />
      </AsyncBoundary>

      <button onClick={() => ctrl.invalidateAll(lastUpdated)}>
        Invalidate All
      </button>
      <button
        onClick={() => ctrl.invalidate(lastUpdated, { id: '1' })}
      >
        Invalidate First
      </button>
    </div>
  );
}
render(<Demo />);
🔴 Live Preview
Store

Any endpoint with an entity

Using the Invalidate schema allows us to invalidate any endpoint that includes that relies on that entity in their response. If the endpoint uses the entity in an Array, it will simply be removed from that Array.

Fixtures
GET /api/currentTime/:id
DELETE /api/currentTime/:id
api/lastUpdated
export class TimedEntity extends Entity {
  id = '';
  updatedAt = Temporal.Instant.fromEpochSeconds(0);
  pk() {
    return this.id;
  }

  static schema = {
    updatedAt: Temporal.Instant.from,
  };
}

export const lastUpdated = new RestEndpoint({
  path: '/api/currentTime/:id',
  schema: TimedEntity,
});
TimePage
import { lastUpdated } from './api/lastUpdated';

export default function TimePage({ id }) {
  const { updatedAt } = useSuspense(lastUpdated, { id });
  return (
    <div>
      API time for {id}:{' '}
      <time>
        {DateTimeFormat('en-US', { timeStyle: 'long' }).format(
          updatedAt,
        )}
      </time>
    </div>
  );
}
ShowTime
import { useLoading } from '@data-client/hooks';
import { TimedEntity } from './api/lastUpdated';
import TimePage from './TimePage';

export const deleteLastUpdated = new RestEndpoint({
  path: '/api/currentTime/:id',
  method: 'DELETE',
  schema: new schema.Invalidate(TimedEntity),
});

function ShowTime() {
  const ctrl = useController();
  const [handleDelete, loadingDelete] = useLoading(
    () => ctrl.fetch(deleteLastUpdated, { id: '1' }),
    [],
  );
  return (
    <div>
      <AsyncBoundary fallback={<div>loading...</div>}>
        <TimePage id="1" />
      </AsyncBoundary>
      <div>
        Current Time: <CurrentTime />
      </div>
      <button onClick={handleDelete}>
        {loadingDelete ? 'loading...' : 'Invalidate'}
      </button>
      <button
        onClick={() =>
          ctrl.setResponse(
            deleteLastUpdated,
            { id: '1' },
            { id: '1' },
          )
        }
      >
        Invalidate (without fetching DELETE)
      </button>
    </div>
  );
}
render(<ShowTime />);
🔴 Live Preview
Store

Controller.fetch() lets us update the server and store. We can use Controller.setResponse() for cases where we simply want to change the local store without updating the server.