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:
const user = queryStrict((id: string) => API.fetchUser(id), available);
const updateUsername = command(
(patch: Partial<User>) => API.updateUser(patch),
{ user }
);
const queries = declareQueries({ user });
const Username = queries(props => (
<div>
{props.queries.fold(
() => 'loading...',
() => 'error while retrieving user',
queries => (
<UserNameForm value={queries.user.username} onSubmit={updateUsername} />
)
)}
</div>
));
<Username queries={{ user: '42' }} />;
Avenger
At the very heart of Avenger's DSL there are two constructors: query and command.
queries
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 Setoid
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 Setoids
:
- queryShallow: will use a
Setoid
instance that performs a shallow equality check to compare inputs. - queryStrict: will use a
Setoid
instance that performs a strict equality check to compare inputs. - queryJSON: will use a
Setoid
instance that performs a strict equality check after transforming the data via JSON stringification to compare inputs.
Some examples will help clarify:
const myQuery = queryShallow(fetchFunction, refetch);
const myQuery = queryStrict(fetchFunction, available);
const myQuery = queryJSON(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:
Valid CacheValue
Invalid CacheValue
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:
listening to queries
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;
const observable: Observable<QueryResult<Error, User>> = observe(userQuery);
observable.subscribe(dispatchError, setCurrentUser);
const task: TaskEither<Error, User> = userQuery.run(1);
const result: Either<Error, User> = await task.run();
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.
composing queries
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 use compose
. - 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 use product
*.
*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 a Failure
. - If any of the queries is
Loading
then the whole composition is Loading
. - If all the queries ended with a
Success
then the composition is a Success
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:
import { compose } from 'avenger/lib/Query';
type UserPreferences = { color: string };
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);
const composition: Composition<number, Error, UserPreferences> = compose(
userQuery,
preferencesQuery
);
const group: Product<number, Error, UserPreferences> = product({
myQuery,
myQuery2
});
commands
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:
updatePreferencesCommand({ color: 'acquamarine' }, { preferencesQuery: 1 });
React
Avenger also exports some utilities to use with React
.
declareQueries
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 { declareQueries } from 'avenger/lib/react';
import { userPreferences } from './queries';
const queries = declareQueries({ userPreferences });
class MyComponent extends React.PureComponent<Props, State> {
render() {
return this.props.queries.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' }
}}
/>
);
}
}
WithQueries
alternatively, to avoid unecessary boilerplate, you can use the WithQueries
component:
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={queries =>
queries.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).
useQuery
alternatively, to avoid unecessary boilerplate, you can use the useQuery
and useQueries
hooks:
import { useQuery } from 'avenger/lib/react';
import { userPreferences } from './queries';
const MyComponent: React.FC<{ userName: string }> = props => {
return useQuery(userPreferences, { userName: props.userName }).fold(
() => <p>loading</p>,
() => <p>there was a problem when fetching preferences</p>,
userPreferences => (
<p>
{props.userName}'s favourite color is {userPreferences.color}
</p>
)
);
};
useQueries
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 useQueries({ query1, query2 }, { query1: 'query-1-input' }).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).
Navigation
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);
export const doUpdateCurrentView = getDoUpdateCurrentView(viewToLocation);
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..).
import { declareQueries } from 'avenger/lib/react';
const queries = declareQueries({ currentView });
class Navigation extends React.PureComponent<Props, State> {
render() {
return this.props.queries.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)
class ItemView extends React.PureComponent<Props, State> {
goToItems: () => doUpdateCurrentView({ view: 'items' }).run()
render() {
return <BackButton onClick={this.goToItems}>
}
}
Signatures
N.B. all the following signatures reference the abstractions in fp-ts
query
declare function query<A = void, L = unknown, P = unknown>(
fetch: Fetch<A, L, P>
): (strategy: Strategy<A, L, P>) => CachedQuery<A, L, P>;
Fetch
type Fetch<A, L, P> = (input: A) => TaskEither<L, P>;
StrategyBuilder
type StrategyBuilder<A, L, P> = (
inputSetoid: Setoid<A>,
cacheValueSetoid: Setoid<CacheValue<L, P>>
) => Strategy<A, L, P>;
Strategy
export class Strategy<A, L, P> {
constructor(
readonly inputSetoid: Setoid<A>,
readonly filter: Function1<CacheValue<L, P>, boolean>,
readonly cacheValueSetoid: Setoid<CacheValue<L, P>>
) {}
}
CachedQuery
interface CachedQuery<A, L, P> {
type: 'cached';
inputSetoid: Setoid<A>;
run: Fetch<A, L, P>;
invalidate: Fetch<A, L, P>;
cache: Cache<A, L, P>;
}
Composition
interface Composition<A, L, P> {
type: 'composition';
inputSetoid: Setoid<A>;
run: Fetch<A, L, P>;
invalidate: Fetch<A, L, P>;
master: ObservableQuery<A, L, unknown>;
slave: ObservableQuery<unknown, L, P>;
}
Product
interface Product<A, L, P> {
type: 'product';
inputSetoid: Setoid<A>;
run: Fetch<A, L, P>;
invalidate: Fetch<A, L, P>;
queries: Record<string, ObservableQuery<A[keyof A], L, P[keyof P]>>;
}
ObservableQuery
type ObservableQuery<A, L, P> =
| CachedQuery<A, L, P>
| Composition<A, L, P>
| Product<A, L, P>;
QueryResult
type QueryResult<L, A> = Loading<L, A> | Failure<L, A> | Success<L, A>;
compose
function compose<A1, L1, P1, L2, P2>(
master: ObservableQuery<A1, L1, P1>,
slave: ObservableQuery<P1, L2, P2>
): Composition<A1, L1 | L2, P2>;
product
function product<R extends ObservableQueries>(
queries: EnforceNonEmptyRecord<R>
): Product<ProductA<R>, ProductL<R>, ProductP<R>>;
command
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>;