Skip to content
On this page

Cache

Farfetched provides a way to cache the result of the Query. Let's dive into internal details of this mechanism.

In the following article, some Effector APIs are used to describe application logic — createStore, createEffect combine, sample.

Valid data guarantee

cache operator provides a guarantee that the cached data won't be saved to Query unless it is valid for the current state of the Query. It means, you can safely deploy a new version of your application with a new expected shape of the data and the old cached data will be automatically invalidated.

Data-flow

Internal implementation of this guarantee is a pretty simple. Farfetched is based on Do not trust remote data principle, so it uses Contract and Validator to validate the data from the remote source.

TIP

Read more detailed description of data-flow in Data flow in Remote Operation article.

cache operator pushes the data to the Query right after the response parsing stage of the data-flow. It means, same Contract and Validator are used to validate the cached data as any other data from the regular remote source. If the cached data is valid, it is saved to the Query. Otherwise, the cached data is ignored, and the Query is updated with the new data from the remote source.

INFO

User-land code can't access the cached data directly. It is only available through the Query object. So, invalid cached data is not exposed to the user.

To achieve this, Every Query exposes .__.lowLevelAPI.dataSources which contains an array of data sources that are used to retrieve the data for the Query. By default, the first element of this array is always the original handler of the Query. We can mutate this array to add new data sources to the Query. cache operator does exactly this, it adds a new data source to the array that is responsible for the cached data.

DANGER

.__.lowLevelAPI.dataSources is a low-level API that is not recommended using it directly in user-land.

Cache key generation

cache does not require any manual key generation to work, it uses the SID of the Query and all external Stores that affect Query to create a unique identifier for every cache entry. It means, key generation is fully automatic, and you don't need to worry about it.

Sources extraction

Due to static nature of Effector we can extract all external Stores that affect Query right after application loading and use their values in key generation process.

Every factory has to pass a list of [Sourced]sourced fields used in the Query creation process to field .__.lowLevelAPI.sourced.

For example, the following Query uses $language and $region Stores to define the final value of the field url:

ts
const locationQuery = createJsonQuery({
  request: {
    url: {
      source: combine({ language: $language, region: $region }),
      fn: (_params, { language, region }) => (region === 'us' ? `https://us-west.salo.com/${language}/location` : `https://eu-cent.salo.com/${language}/location`),
    },
  },
});

Of course, we can just save both $language and $region to .__.lowLevelAPI.sourced and use them in key generation process, but it is not the best solution. Final URL does not include the value of $region directly, it cares only if it is "us" or not, so we have to emphasize this fact in .__.lowLevelAPI.sourced. To solve this issue, let's check internal implementation of Sourced fields.

INFO

Sourced fields are special fields in Farfetched that are allows to use any combination of Stores and functions to define the final value of the field.

Under the hood Farfetched uses special helper normalizeSourced that transforms any Sourced field to simple Stores, in our case it would be something like this:

ts
// internal function in Farfetched's sources
function normalizedSourced($store, start, transform) {
  const $result = createStore(null);
  sample({
    clock: start,
    source: $store,
    fn: (store, params) => transform(params, store),
    target: $result,
  });
  return $result;
}

// this transformation applied to the field `url` to get the final value
const $url = normalizedSourced(combine({ language: $language, region: $region }), query.start, (_params, { language, region }) => (region === 'us' ? `https://us-west.salo.com/${language}/location` : `https://eu-cent.salo.com/${language}/location`));

After that, we can use $url in .__.lowLevelAPI.sources, it will contain only related data and could be used as a part of cache entry key. Same transformation applies for every sourced field to extract only significant data.

DANGER

normalizeSourced is a low-level API function that is used internally in Farfetched. It is not recommended using it directly in user-land.

Static nature of Effector allows us to perform this transformation under the hood and use only related data from to formulate cache entry key. It is an essential part of stable key generation that leads us to higher cache-hit rate.

SID

Every Query has a unique identifier — SID. Effector provides a couple of plugins for automatic SIDs generation.

Babel plugin

If your project already uses Babel, you do not have to install any additional packages, just modify your Babel config with the following plugin:

json
{
  "plugins": ["effector/babel-plugin"]
}

INFO

Read more about effector/babel-plugin configuration in the Effector's documentation.

SWC plugin

WARNING

Note that plugins for SWC are experimental and may not work as expected. We recommend to stick with Babel for now.

SWC is a blazing fast alternative to Babel. If you are using it, you can install effector-swc-plugin to get the same DX as with Babel.

sh
pnpm add --save-dev effector-swc-plugin @swc/core
sh
yarn add --dev effector-swc-plugin @swc/core
sh
npm install --dev effector-swc-plugin @swc/core

Now just modify your .swcrc config to enable installed plugin:

json
{
  "$schema": "https://json.schemastore.org/swcrc",
  "jsc": {
    "experimental": {
      "plugins": ["effector-swc-plugin"]
    }
  }
}

INFO

Read more about effector-swc-plugin configuration in the plugin documentation.

Vite

If you are using Vite, please read the recipe about it.

Hashing algorithm

So, the key is a hash of the following data:

  • SID of the Query
  • params of the particular call of the Query
  • current values of all external Stores that affect Query

To get short and unique key, we stringify all data, concatenate it and then hash it with SHA-1.

TIP

SHA-1 is a cryptographically broken, but we use it for key generation only, so it is safe to use it in this case.

Adapter replacement

Sometimes it's necessary to replace current cache adapter with a different one. E.g. it's impossible to use localStorage on server-side during SSR, so you have to replace it with some in-memory adapter. To do this Farfetched provides a special property in every adapter .__.$instance that can be replaced via Fork API.

Adapter internal structure

Fork API allows to replace any Store value in the particular Scope, so we have to provide some "magic" to adapters to make it Store-like.

In general, every adapter is a simple object with the following structure:

ts
const someAdapter = {
  get: createEffect(({ key }) => /* ... */),
  set: createEffect(({ key, value }) => /* ... */),
  purge: createEffect(() => /* ... */),
};

We have to add .__.$instance property to it to make it replacable via Fork API:

ts
// internal function in Farfetched's sources
function makeAdapterRepalcable(adapter) {
  return {
    ...adapter,
    __: {
      $instance: createStore(adapter),
    },
  };
}

DANGER

makeAdapterRepalcable is a low-level API function that is used internally in Farfetched. It is not recommended using it directly in user-land.

That's it, now we can replace any adapter with another one via Fork API:

ts
// app.ts
import { localStorageCache } from '@farfetched/core';

// Create some adapter to use in the appliaction
const applicationCacheAdapter = localStorageCache();

cache(query, { adapter: applicationCacheAdapter });

// app.test.ts
import { inMemoryCache } from '@farfetched/core';

test('app', async () => {
  const scope = fork({
    values: [
      // Replace its implementation during fork
      [applicationCacheAdapter.__.$instance, inMemoryCache()],
    ],
  });
});

Operator cache does not use .get, .set and .purge methods directly, it extracts them from the .__.$instance on every hit instead. It allows users to replace adapters in specific environments (such as tests or SSR) without any changes in the application code.

Query interruption

cache allows skipping Query execution if the result is already in the cache and does not exceed the staleAfter time. It uses .__.lowLevelAPI.dataSources as well.

To retrieve new data a Query iterates over all data sources and calls .get method on them. If the result is not empty it stops iteration and returns the result. So, cache operator just adds a new data source to the start of the list of data sources.

Released under the MIT License.