Server side caching
Let's talk about caching and SSR in the single recipe. If you render your application on the server, TTFB (time to first byte) is very important and caching of data-source responses can help you to reduce it.
Case study
A case study is a detailed study of a specific subject, such a service or feature. It is a way to show how Farfetched can be used to solve a real-world problem.
The code in it is not supposed to be ready to use "as is", it is just an example of how Effector and Farfetched can be used to deal with a specific problem.
Pre-requisites
Do not forget that cache
operator requires setting up SIDs in your application. It can be done by using code transformation tools.
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:
{
"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.
pnpm add --save-dev effector-swc-plugin @swc/core
yarn add --dev effector-swc-plugin @swc/core
npm install --dev effector-swc-plugin @swc/core
Now just modify your .swcrc
config to enable installed plugin:
{
"$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.
Kick-off
Let's say we have a simple application with a list of characters and a page with a character details. We want to cache the list of characters on the server side, so we can render it on the server and don't need to wait for the response from the data-source.
import { createJsonQuery } from '@farfetched/core';
export const characterListQuery = createJsonQuery({
params: declareParams<{ ids: TId[] }>(),
request: {
url: 'https://rickandmortyapi.com/api/character',
method: 'GET',
},
response: { contract: charactersListContract },
});
So, we can simply use cache
operator to cache the response from the data-source.
import { cache } from '@farfetched/core';
cache(characterListQuery);
And it'll work. But there is a problem. By default, cache
operator stores the response in the memory. So:
- if you restart the server, the cache will be cleared
- if you have multiple instances of the server, the cache won't be shared between them
In order to solve this problem, we can use custom adapter to store the cache in the any external storage. Let's use Redis as an example.
Implementation
Custom adapter
First, we need to install some package to deal with Redis, let's use ioredis
:
pnpm install ioredis
yarn add ioredis
npm install ioredis
Then we need to create a custom adapter for cache
operator to store the cache in Redis. In Farfetched custom adapters are just functions that accept some options and return a special object with some methods — get
, set
, unset
and purge
. Let's implement the adapter with a Redis as a storage.
TIP
createCacheAdapter
is a helper function that helps to create custom adapters. It accepts an object with get
, set
, unset
and purge
methods and returns an adapter object with the same methods and some additional properties. It has to be used in order to make the adapter compatible with the cache
operator.
import { createEffect } from 'effector';
import { createCacheAdapter } from '@farfetched/core';
function redisCache({ maxAge }: { maxAge: number }) {
return createCacheAdapter({
get: createEffect((_: { key: string }): { value: unknown; cachedAt: number } | null => {
// TODO: implement
return null;
}),
set: createEffect((_: { key: string; value: unknown }) => {
// TODO: implement
return;
}),
unset: createEffect((_: { key: string }) => {
// TODO: implement
return;
}),
purge: createEffect(() => {
// TODO: implement
return;
}),
});
}
Effects
Because Farfetched uses Effector under the hood, it is required to use make all the side effects in custom adapters performed by Effects that are created by createEffect
function.
Now, let's implement all methods of the adapter one by one.
get
get
Effect accepts a single argument — an object with key
property. It should return an object with value
and cachedAt
properties or null
if there is no value in the cache.
This Effect can fail with an error if something went wrong.
import Redis from 'ioreis';
function redisCache({ maxAge }) {
const redis = new Redis();
return createCacheAdapter({
get: createEffect(async ({ key }) => {
// NOTE: we store stringified object with {value, cachedAt} in the Redis
const valueFromCache = await redis.get(key);
if (!valueFromCache) {
return null;
}
return JSON.parse(valueFromCache);
}),
set,
unset,
purge,
});
}
set
set
Effect accepts a single argument — an object with key
and value
properties. It should store the value in the cache.
Because of internal implementation of the cache
operator, it is required to store the cachedAt
property in the cache. It is a timestamp of the moment when the value was cached. So, let's store it together with the value in the cache.
This Effect can fail with an error if something went wrong.
import Redis from 'ioreis';
function redisCache({ maxAge }) {
const redis = new Redis();
return createCacheAdapter({
get,
set: createEffect(
async ({ key, value }) => {
await redis.set(
key,
JSON.stringify({ value, cachedAt: Date.now() }),
'EX',
maxAge
);
}
),
unset,
purge,
});
}
unset
unset
Effect accepts a single argument — an object with key
property. It should remove the value from the cache.
This Effect should not fail with an error. So, you have to provide a guarantee that the value will be removed from the cache after resolving the Effect. We skip this step in this example, but it's required to implement it in the real application.
import Redis from 'ioredis';
function redisCache({ maxAge }) {
const redis = new Redis();
return createCacheAdapter({
get,
set,
unset: createEffect(async ({ key } => {
await redis.del(key);
}),
purge,
});
}
purge
purge
Effect doesn't accept any arguments. It should remove all the values from the cache.
This Effect should not fail with an error. So, you have to provide a guarantee that all values will be removed from the cache after resolving the Effect. We skip this step in this example, but it's required to implement it in the real application.
import Redis from 'ioredis';
function redisCache({ maxAge }) {
const redis = new Redis();
return createCacheAdapter({
get,
set,
unset,
purge: createEffect(async () => {
await redis.flushall()
}),
});
}
Inject adapter
So far, we have implemented a custom adapter for the cache
operator. But we still need to use it in our application. And we need to use different adapters in different environments — on server and on client.
Effector has a built-in mechanism to inject different implementations of the same value in different environments — Fork API. Let's use it to inject different adapters in different environments.
Write default path in the regular way:
import { inMemoryCache } from '@farfetched/core';
// NOTE: we use inMemoryCache as a default adapter
const charactersCache = imMemoryCache();
cache(characterListQuery, { adapter: charactersCache });
And then, in the server.ts
file, we can inject the Redis adapter during fork
:
function handleHttp(req, res) {
const scope = fork({
values: [
// NOTE: let's use Redis adapter on server for charactersCache
[
charactersCache.__.$adapter,
redisCache({
maxAge: 60 * 60 * 1000, // 1 hour
}),
],
],
});
// ... run calculations
// ... render html
// ... send response
}
TIP
Read more about SSR with Farfetched in the recipe about Server-side rendering.
What else?
That's it, we have a redisCache
adapter that can be used in the real application. But it's only a part of the story. There are a lot of things that can be improved:
Observability
All built-in adapters support observability
option. It allows to track the cache state and the number of cache hits and misses, expired and evicted values, etc.
It is useful for debugging and performance optimization. In general, it is a good practice to add observability
to your custom adapters as well.
However, in case of our Redis adapter it is not recommended to track external storage metrics at the application level — it is much better to track them directly from your Redis instances — ask your Ops-team about it.
Dynamic configuration
In this recipe, we have skipped Redis configuration. But in real applications, it is required to configure Redis connection. We can do it by passing the configuration through Store:
import Redis from 'ioreis';
import { attach } from 'effector';
const $redisConnection = createStore<string | null>(null);
const $redis = $redisConnection.map((connection) => new Redis(connection));
In the adapter, we can use attach
to pass instance of Redis
to any Effect:
import { attach } from 'effector';
function redisCache({ maxAge }) {
return createCacheAdapter({
get,
set,
unset: attach({
source: $redis,
effect: (redis, { key }) => redis.del(key),
}),
purge,
});
}
In this case we can change Redis connection dynamically during fork
:
function handleHttp(req, res) {
const scope = fork({
values: [
// NOTE: let's use Redis connection from environment variable
[$redisConnection, process.env.REDIS_CONNECTION],
],
});
// ... run calculations
// ... render html
// ... send response
}
Conclusion
We have set up server side cache for our Queries using external Redis. It's not a complete solution, but it's a good start. Key points of this article:
- use
createCacheAdapter
to create custom adapters forcache
operator - use Fork API to inject different adapters in different environments