Standard utilities to manage query requests and responses.
In development, we frequently encounter scenarios that requires management of data fetched from remote, most of the time we simply do something like:
const state = {data: null};
const fetchData = async params => {
const data = await get('/some-url', params);
state.data = data;
};
When fetchData
is called, we await a response and override the previous data. This is simple but problematic in most cases, the major issue is the introduction race conditions:
- We start a fetch with param
{name: 'foo'}
. - We than start another fetch immediately with param
{name: 'bar'}
. - The second fetch arrives before the fetch fetch.
- So that we have a UI indicating
{name: 'foo'}
with the result of{name: 'bar'}
.
Race conditions are not hard to solve, we just forgot to solve it. One of best solution is to pair params and responses so that our code moves to:
const state = {};
const fetchData = async params => {
const data = await get('/some-url', params);
state[stringify(params)] = {data};
};
In this case, we just retrieve the response by state[stringify(params)]
and no race conditions can happen.
query-shape
is a simple utility to help you manage structures like above by specifying a "strategy" you want.
npm install query-shape
query-shape
defines several data structures, which is important for understanding how it works:
A query is a simple object telling the key, response and pending state of a fetch:
interface Query<TKey, TData, TError> {
readonly key: TKey; // Key of a query, usually the params of fetch
readonly pendingMutex: number; // Count of sent but not received fetches
readonly response: Response<TData, TError> | null; // The last valid response of fetch
readonly nextResponse: Response<TData, TError> | null; // The last unaccepted response
}
A query set is a group of queries keyed by query's key so that we can find a query by key from it:
interface QuerySet<TKey, TData, TError> {
[key: string]: Query<TKey, TData, TError> | undefined;
}
Inside each query we have response, a response tells when it is received, the state (either resolved or rejected) and result of it:
interface Response<TData, TError> {
readonly arrivedAt: number;
readonly data?: TData;
readonly error?: TError;
}
A query live along 4 stages:
- The initial state, with no response and pending fetches, everything except
key
isundefined
in this stage. - The fetch state, when a fetch is sent,
pendingMutex
is incremented, we can simply detect its pending state bypendingMutex > 0
. - The receive state, when a response arrives,
pendingMutex
is decremented,nextResponse
is assigned to the last arrived response, in this stageresponse
is not modified. - The accept state, when use decides to accept
nextResponse
as the last response,response
is replaced bynextResponse
andnextResponse
is assigned tonull
again. In most cases accept is automatically done by strategy so we straightly getresponse
updated. In rare cases we can wait our end users to confirmnextResponse
and ask query to accept it.
Simple import a strategy option and the createStrategy
function to create a strategy, in most cases the acceptLatest
is enough:
import {createStrategy, acceptLatest} from 'query-shape';
const strategy = createStrategy(acceptLatest);
By doing this we not get a strategy
object using a "always accept the latest response" strategy, we have several functions in the object:
initialize()
: returns an empty query set with no query attached.fetch(key)
: to make a query moving to "fetch" stage.receive(key, data)
: to make a query moving to "receive" stage with a resolved data.error(key, error)
: to make a query moving to "receive" stage with a rejected error.accept(key)
: to make a query accept itsnextResponse
.
We can implement a fetch function along with its query state:
const state = {querySet: strategy.initialize()};
const fetchData = async params => {
state.querySet = strategy.fetch(params);
try {
const data = await fetch('/some-url', params);
state.querySet = strategy.receive(params, data);
}
catch (ex) {
state.querySet = strategy.error(params, ex);
}
};
We can also get a query by its key using findQuery
function:
import {findQuery} from 'query-shape';
const query = findQuery(state.querySet, params);
if (query.pendingMutex > 0) {
renderLoading();
return;
}
if (query.response.data) {
renderResult(query.response.data);
}
else if (query.response.error) {
renderError(query.response.error);
}
query-shape
is published with some native strategies:
acceptLatest
: always use the last response no matter its resolved or rejected, nonextResponse
is used.keepEarliest
: persist and use the first arrived response, no further response can override the first one.keepEarliestSuccess
: persist the first success response, a previous rejected response will be overridden, otherwise the new response will be discarded.waitAccept
: store new response innextResponse
, users are asked to callaccept()
to accept it.