Avenger is a data fetching and caching layer written in TypeScript. Its API is designed to mirror the principles of Command Query Responsibility Segregation and facilitate their adoption (if you are new to the concept you can get a grasp of its foundations in this nice article by Martin Fowler).
Building user interfaces is a complex task, mainly because of its IO
intensive nature. Reads (queries) and updates (commands) toward "external" data sources are ubiquitous and difficult to orchestrate, but orchestration is not the only challenge a UI developer faces, performance and scalability are also key aspects of good design.
We believe that an effective and powerful abstraction to handle caching and synchronization of external data in a declarative way is of fundamental importance when designing a solid user interface.
This is what Avenger aims to be: an abstraction layer over external data that handles caching and synchronization for you:
By separating how we fetch external data and how we update it we are able to state in a very declarative and natural way the correct lifecycle of that data:
import { queryStrict, command } from 'avenger';
// define a cached query, with strategy "available" (more about this later)
const user = queryStrict((id: string) => API.fetchUser(id), available);
// define a command that invalidates the previous query
const updateUsername = command(
(patch: Partial<User>) => API.updateUser(patch),
{ user }
);
// declare it for usage in a React component
import { pipe } from 'fp-ts/lib/pipeable';
import * as QR from 'avenger/lib/QueryResult';
import { declareQueries } from 'avenger/lib/react';
const queries = declareQueries({ user });
const Username = queries(props => (
<div>
{pipe(
props.queries,
QR.fold(
() => 'loading...',
() => 'error while retrieving user',
queries => (
<UserNameForm
value={queries.user.username}
onSubmit={updateUsername}
/>
)
)
)}
</div>
));
// render the component
<Username queries={{ user: '42' }} />;
At the very heart of Avenger's DSL there are two constructors: query and command.
The query
function allows you to query your data source and get an object of type CachedQuery
in return.
It accepts two parameters: the first is a function with a Fetch
signature that is used to retrieve data from your data source; the second is an object with the Strategy
signature that will be used to decide if the data stored by Avenger is still relevant or needs to be refetched.
Although important, query
is a pretty low-level API and Avenger offers some convenient utils with a StrategyBuilder
signature that you should prefer over it (unless you have very specific needs):
- refetch: runs the fetch function every time the data is requested (unless there's an ongoing pending request, which is always reused).
- expire: when the data is requested, the fetch function is run only if data in the
Cache
is older than the expiration defined, otherwise the cached value is used. - available: when the data is requested, if a cached value is available it is always returned, otherwise the fetch function is run and the result stored in the
Cache
accordingly.
All these utils ask you to pass custom Eq
instances as arguments; they will be used to check if a value for an input combination is already present in one of the Cache
's keys (if the check is successful Avenger
will try to use that value, otherwise it will resort to the Fetch
function).
You can (and should) use these utils together with one of the built-in implementations that automatically take care of passing by the needed Eq
s:
- queryShallow: will use an
Eq
instance that performs a shallow equality check to compare inputs. - queryStrict: will use an
Eq
instance that performs a strict equality check to compare inputs.
Some examples will help clarify:
/*
this implementation will always re-run the `Fetch` function
even if valid cached data is already present
and use shallow equality to compare input
*/
const myQuery = queryShallow(fetchFunction, refetch);
/*
this implementation will never run the `Fetch` function
unless no valid data is present in the Cache
and use strict equality to compare input
*/
const myQuery = queryStrict(fetchFunction, available);
/*
this implementation will run the `Fetch` function only if no valid data is present in the Cache
or t > 10000 ms passed till the last time data was fetched
and use strict equality to compare input
*/
const myQuery = queryStrict(fetchFunction, expire(10000));
Each time the Fetch
function is run with some input
, those same input
is used as a key
to store the result obtained:
// usersCache is empty
usersCache: {}
//a user is fetched
getUser({ userId: 1 }) -> { userName: "Mario" }
// usersCache is now populated
usersCache: {
[{ userId: 1 }]: { userName: Mario }
}
From that moment onwards, when Avenger will need to decide if the data in our Cache
is present and still valid it will:
- attempt to retrieve data from the
Cache
- match the result against the cache strategy defined (for instance if we chose
refetch
the data will always be deemed invalid irrespective of the result).
If a valid result is found it is used without further actions, otherwise the Fetch
function will be re-run in order to get valid data. The two flows are relatively simple:
when you call run
or subscribe
on a query
with a combination of inputs
that was never used before (or whose last use ended up with a Failure
), avenger will try to run the Fetch
function resulting in a more complex flow:
There are two ways to get a query result:
type Error = '500' | '404';
type User = { userName: String };
declare function getUser(userId: number): TaskEither<Error, User>;
const userQuery: CachedQuery<number, Error, User> = query(getUser)(refetch);
declare function dispatchError(e: Error): void;
declare function setCurrentUser(e: User): void;
// feeding your query to `observe` will give you an observable on the query
// N.B. until now no fetch is yet attempted, avenger will wait until the first subscription is issued
const observable: Observable<QueryResult<Error, User>> = observe(userQuery);
// this will trigger the fetch function
observable.subscribe(dispatchError, setCurrentUser);
// alternatively you can call `run` on your query and it will return a TaskEither<Error, User>
// you can then use it imperatively
const task: TaskEither<Error, User> = userQuery.run(1);
const result: Either<Error, User> = await task();
although the run
method is available to check a query result imperatively, it is highly suggested the use of the observe
utility in order to be notified in real time of when data changes.
Either way, whenever you ask for a query result you will end up with an object with the QueryResult
signature that conveniently lets you fold
to decide the best way to handle the result. The fold
method takes three functions as parameters: the first is used to handle a Loading
result; the second is used in case a Failure
occurs; the last one handles Success
values.
You can build bigger queries from smaller ones in two ways:
- by composing them with
compose
: when you need your queries to be sequentially run with the results of one feeding the other, you can usecompose
. - by grouping them with
product
: when you don't need to run the queries sequentially but would like to conveniently group them and treat them as if they were one you can useproduct
*.
*Internally product
uses the Applicative
nature of QueryResults
to group them using the following hierarchical logic:
- If any of the queries returned a
Failure
then the whole composition is aFailure
. - If any of the queries is
Loading
then the whole composition isLoading
. - If all the queries ended with a
Success
then the composition is aSuccess
with a record of results that mirrors the key/value result of the single queries as value.
Here are a couple of simple examples on how to use them:
/* N.B. each value defined is explicitly annotated for clarity, although the annotations are not strictly required */
import { compose } from 'avenger/lib/Query';
type UserPreferences = { color: string };
// note that the two ends of the composed functions must have compatible types
declare function getUser(userId: number): TaskEither<Error, User>;
declare function getUserPreferences(
user: User
): TaskEither<Error, UserPreferences>;
const userQuery: CachedQuery<number, Error, User> = queryStrict(
getUser,
refetch
);
const preferencesQuery: CachedQuery<
User,
Error,
UserPreferences
> = queryShallow(getUserPreferences, refetch);
// this is a query composition
const composition: Composition<number, Error, UserPreferences> = compose(
userQuery,
preferencesQuery
);
// this is a query product
const group: Product<number, Error, UserPreferences> = product({
myQuery,
myQuery2
});
Up to now we only described how to fetch data. When you need to update or insert data remotely you can make use of command
:
declare function updateUserPreferences({
color: string
}): TaskEither<Error, void>;
const updatePreferencesCommand = command(updateUserPreferences, {
preferencesQuery
});
command
accepts a Fetch
function that will be used to modify the remote data source and, as a second optional parameter, a record of query
es that will be invalidated once the command
is successfully run:
/* when you call the command you can specify the input value corresponding
to the Cache key that should be invalidated as a second parameter */
updatePreferencesCommand({ color: 'acquamarine' }, { preferencesQuery: 1 });
Avenger also exports some utilities to use with React
.
declareQueries
is a HOC
(Higher-Order Component) builder. It lets you define the queries that you want to inject into a component and then creates a simple HOC
to wrap it:
import { pipe } from 'fp-ts/lib/pipeable';
import { declareQueries } from 'avenger/lib/react';
import * as QR from 'avenger/lib/QueryResult';
import { userPreferences } from './queries';
const queries = declareQueries({ userPreferences });
class MyComponent extends React.PureComponent<Props, State> {
render() {
return pipe(
this.props.queries,
QR.fold(
() => <p>loading</p>,
() => <p>there was a problem when fetching preferences</p>,
({ userPreferences }) => <p>my favourite color is {userPreferences.color}</p>
)
)
}
}
export queries(MyComponent)
When using this component from outside you will have to pass it the correct query parameters inside the queries
prop in order for it to load the declared queries:
class MyOtherComponent extends React.PureComponent<Props, State> {
render() {
return (
<MyComponent
queries={{
userPreferences: { userName: 'Mario' }
}}
/>
);
}
}
alternatively, to avoid unecessary boilerplate, you can use the WithQueries
component:
import * as QR from 'avenger/lib/QueryResult';
import { WithQueries } from 'avenger/lib/react';
import { userPreferences } from './queries';
class MyComponent extends React.PureComponent<Props, State> {
render() {
return (
<WithQueries
queries={{ userPreferences }}
params={{ userPreferences: { userName: 'Mario' } }}
render={QR.fold(
() => (
<p>loading</p>
),
() => (
<p>there was a problem when fetching preferences</p>
),
({ userPreferences }) => (
<p>Mario's favourite color is {userPreferences.color}</p>
)
)}
/>
);
}
}
NB both declareQueries
and WithQueries
do not support dynamic queries definition (e.g. declareQueries(someCondition ? { queryA } : { queryA, queryB }
will not work).
alternatively, to avoid unecessary boilerplate, you can use the useQuery
and useQueries
hooks:
import { pipe } from 'fp-ts/lib/pipeable';
import * as QR from 'avenger/lib/QueryResult';
import { useQuery } from 'avenger/lib/react';
import { userPreferences } from './queries';
const MyComponent: React.FC<{ userName: string }> = props => {
return pipe(
useQuery(userPreferences, { userName: props.userName }),
QR.fold(
() => <p>loading</p>,
() => <p>there was a problem when fetching preferences</p>,
userPreferences => (
<p>
{props.userName}'s favourite color is {userPreferences.color}
</p>
)
)
);
};
import { pipe } from 'fp-ts/lib/pipeable';
import * as QR from 'avenger/lib/QueryResult';
import { useQueries } from 'avenger/lib/react';
declare const query1: ObservableQuery<string, unknown, number>;
declare const query2: ObservableQuery<void, unknown, string>;
const MyComponent: React.FC = props => {
return pipe(
useQueries({ query1, query2 }, { query1: 'query-1-input' }),
QR.fold(
() => <p>still loading query1 or query2 (or both)</p>,
() => <p>there was a problem when fetching either query1 or query2</p>,
({ query1, query2 }) => (
<p>
{query2}: {query1}
</p>
)
)
);
};
NB both useQuery
and useQueries
support dynamic queries definition (e.g. useQueries(someCondition ? { queryA } : { queryA, queryB }
will work as expected).
Another useful set of utilities is the one used to handle client navigation in the browser. Following you can find a simple but exhaustive example of how it is used:
import { getCurrentView, getDoUpdateCurrentView } from "avenger/lib/browser";
export type CurrentView =
| { view: 'itemView'; itemId: String }
| { view: 'items' };
| { view: 'home' };
const itemViewRegex = /^\/items\/([^\/]+)$/;
const itemsRegex = /^\/items$/;
export function locationToView(location: HistoryLocation): CurrentView {
const itemViewMatch = location.pathname.match(itemViewRegex);
const itemsMatch = location.pathname.match(itemsRegex);
if (itemViewMatch) {
return { view: 'itemView'; itemId: itemViewMatch[1] };
} else if (itemsMatch) {
return { view: 'items' };
} else {
return { view: 'home' };
}
}
export function viewToLocation(view: CurrentView): HistoryLocation {
switch (view.view) {
case 'itemView':
return { pathname: `/items/${view.itemId}`, search: {} };
case 'items':
return { pathname: '/items', search: {} };
case 'home':
return { pathname: '/home', search: {} };
}
}
export const currentView = getCurrentView(locationToView); // ObservableQuery
export const doUpdateCurrentView = getDoUpdateCurrentView(viewToLocation); // Command
once you instantiated all the boilerplate needed to instruct Avenger on how to navigate, you can use currentView
and doUpdateCurrentView
like they were normal queries and commands (and, in fact, they are..).
// ./App.ts
import { pipe } from 'fp-ts/lib/pipeable';
import * as QR from 'avenger/lib/QueryResult';
import { declareQueries } from 'avenger/lib/react';
const queries = declareQueries({ currentView });
// usually at the top level of your app there will be a sort of index of your navigation
class Navigation extends React.PureComponent<Props, State> {
render() {
return pipe(
this.props.queries,
QR.fold(
() => <p>loading</p>,
() => null,
({ currentView }) => {
switch(currentView.view) {
case 'itemView':
return <ItemView id={view.itemId} />
case 'items':
return <Items />
case 'home':
return <Home />
}
}
)
)
}
}
export queries(MyComponent)
// ./Components/ItemView.ts
class ItemView extends React.PureComponent<Props, State> {
goToItems: () => doUpdateCurrentView({ view: 'items' })()
render() {
return <BackButton onClick={this.goToItems}>
}
}
N.B. all the following signatures reference the abstractions in
fp-ts
declare function query<A = void, L = unknown, P = unknown>(
fetch: Fetch<A, L, P>
): (strategy: Strategy<A, L, P>) => CachedQuery<A, L, P>;
type Fetch<A, L, P> = (input: A) => TaskEither<L, P>;
type StrategyBuilder<A, L, P> = (
inputEq: Eq<A>,
cacheValueEq: Eq<CacheValue<L, P>>
) => Strategy<A, L, P>;
export class Strategy<A, L, P> {
constructor(
readonly inputEq: Eq<A>,
readonly filter: Function1<CacheValue<L, P>, boolean>,
readonly cacheValueEq: Eq<CacheValue<L, P>>
) {}
}
interface CachedQuery<A, L, P> {
type: 'cached';
inputEq: Eq<A>;
run: Fetch<A, L, P>;
invalidate: Fetch<A, L, P>;
cache: Cache<A, L, P>;
}
interface Composition<A, L, P> {
type: 'composition';
inputEq: Eq<A>;
run: Fetch<A, L, P>;
invalidate: Fetch<A, L, P>;
master: ObservableQuery<A, L, unknown>;
slave: ObservableQuery<unknown, L, P>;
}
interface Product<A, L, P> {
type: 'product';
inputEq: Eq<A>;
run: Fetch<A, L, P>;
invalidate: Fetch<A, L, P>;
queries: Record<string, ObservableQuery<A[keyof A], L, P[keyof P]>>;
}
type ObservableQuery<A, L, P> =
| CachedQuery<A, L, P>
| Composition<A, L, P>
| Product<A, L, P>;
// instance of Bifunctor2<URI> & Monad2<URI>
type QueryResult<L, A> = Loading<L, A> | Failure<L, A> | Success<L, A>;
function compose<A1, L1, P1, L2, P2>(
master: ObservableQuery<A1, L1, P1>,
slave: ObservableQuery<P1, L2, P2>
): Composition<A1, L1 | L2, P2>;
function product<R extends ObservableQueries>(
queries: EnforceNonEmptyRecord<R>
): Product<ProductA<R>, ProductL<R>, ProductP<R>>;
function command<A, L, P, I extends ObservableQueries, IL extends ProductL<I>>(
cmd: Fetch<A, L, P>,
queries?: EnforceNonEmptyRecord<I>
): (a: A, ia?: ProductA<I>) => TaskEither<L | IL, P>;