react-async-utils
v0.14.0
Published
Collection of utils to work with asynchronous data and asynchronous tasks in React in a more declarative way.
Downloads
15
Maintainers
Readme
Collection of utils to work with asynchronous data and tasks in React in a more declarative way. Featuring useAsyncData
and useAsyncTask
hooks for this purpose. It is delightful to use with TypeScript, but it can equally be used with JavaScript.
Table of Contents
- The difficulties
- This solution
- Installation
- The new
Async
data concept - API Reference (WIP)
- Contributing
- LICENSE
The difficulties
Data structure
Dealing with asynchronous data or tasks is usually an imperative process, harder to express in a declarative manner, such as React promotes. It usually results in using a combination of variables/properties to keep track of the possible states:
let loading;
let data;
let error;
// Maybe more...
...
This a somehow complex construct for such an ubiquitous case. It can lead to verbose code, even more when dealing with multiple pieces of async data at the same time. Some of these combinations don't even make sense (loading === true && error !== undefined
?). It can feel awkward to follow this pattern.
Features (React and non-React)
You want to stay up to date with the state of art in our domain (Hooks, Concurrent Mode, Suspense...). You also want to take care of the more subtle requirements, like race conditions, or aborting. And you want to do it the right way. And that is not trivial.
This solution
Data structure
The base of this library is "making impossible states impossible" for async data, and building rich abstractions around it.
We do not separate the data itself from its asynchronous state, we consider it an intrinsic part of its nature. And so we put it all together as a new data type consistent with this async nature.
We named this data type Async
.
let asyncPerson: Async<Person>;
It can be considered the declarative counterpart of a Promise
.
This new data type allows us to create some powerful abstractions, like the useAsyncData
custom hook
const asyncPerson = useAsyncData(getPersonPromise);
which we will explain further down.
Features (React and non-React)
Our utils use the latest stable React capabilities to make working properly with async data and tasks easy and direct. They also take care of stuff like race conditions, cleaning up and easy aborting.
Installation
npm install react-async-utils
The new Async
data concept
We are going to deal with async data in all of its possible states as a single entity. This entity includes all possible states and related data within it, in an ordered (and type-safe) manner.
The 4 basic states of Async
data
We consider any Async
data can exist in one of this 4 states:
- INIT: nothing has happened yet. This is time 0 of our async process:
interface InitAsync {
progress: Progress.Init;
}
- IN PROGRESS: our async process is happening. We are waiting for its outcome:
interface InProgressAsync {
progress: Progress.InProgress;
}
- SUCCESS: our async process is successfully completed. The actual data will be available in its payload:
interface SuccessAsync<Payload> {
progress: Progress.Success;
payload: Payload;
}
- ERROR: our async process failed. There will be an error, the cause of this failure:
interface ErrorAsync {
progress: Progress.Error;
error: Error;
}
And so, an Async
data encapsulates the 4 states of a piece of data along the async process within a single data type:
export type Async<Payload> =
| InitAsync
| InProgressAsync
| SuccessAsync<Payload>
| ErrorAsync;
This data type is the base of our library. Take your time to understand it, and we will be able to do great things with it.
The hooks
This library features 2 main hooks: useAsyncData
and useAsyncTask
:
useAsyncData
hook
A powerful abstraction to manage querying or fetching data in a declarative way. It takes care of race conditions and it can get aborted. It looks like this:
const asyncPerson = useAsyncData(getPersonPromise);
getPersonPromise
: input function to fetch data. It returns aPromise
that resolves to the desired data.asyncPerson
: it is ourAsync
data. It will be in the INIT state at the beginning, but will start getting updated as the data fetching task is triggered.
This hook will run our data fetching task as an effect, so it will happen automatically after the first render. This effect will update asyncPerson
state according to the state of the Promise
returned by getPersonPromise
.
⚠️ Be careful, this hook can cause infinite loops. The input function is a dependency of the effect. You want to use React.useCallback
if needed to keep its identity and prevent these loops:
const asyncPerson = useAsyncData(
React.useCallback(() => getPersonPromise(personId), [personId]),
);
You can read more details of useAsyncData
hook at the API Reference.
useAsyncTask
hook
Similar to useAsyncData
, this hook is used to manage posting or mutating data in a more declarative way. It can be aborted.
const asyncSubmitValues = useAsyncTask(() => submitValues);
const triggerButton = (
<button onClick={() => asyncSubmitValues.trigger(values)}>Submit</button>
);
submitValues
: input function that accepts input arguments and returns aPromise
that resolves to the result of the operation.asyncSubmitValues
: it is ourAsync
data. It will be in the INIT state at the beginning, but will start getting updated once the async task is triggered.asyncSubmit.trigger
: a function that triggers the async task. When invoked, it will callsubmitValues
with the same arguments it receives, and it will updateasyncSubmit
state according to the state of thePromise
returned bysubmitValues
.
Unlike useAsyncData
, this task will not be triggered as an effect, you will trigger it with the trigger
funtion, and you can provide it with any parameters.
See the API Reference for more details.
Rendering Async
data
render
A good option is the render
helper method. You can provide a render method per state. The corresponding one will be used:
import { render } from 'react-async-utls';
render(asyncPerson, {
init: () => <p>INIT state render. Nothing happened yet.</p>,
inProgress: () => (
<p>IN PROGRESS state render. We are fetching our Person.</p>
),
success: person => <p>SUCCESS state render. Please welcome {person.name}!</p>,
error: error => (
<p>ERROR state render. Something went wrong: {error.message}</p>
),
});
AsyncViewContainer
Another option is the AsyncViewContainer
component:
import { AsyncViewContainer, getPayload } from 'react-async-utls';
function MyComponent({ asyncPerson }) {
person = getPayload(asyncPerson);
return (
<AsyncViewContainer
asyncData={asyncPerson}
inProgressRender={() => 'Loading person...'}
errorRender={error => `Something went wrong: ${error.message}`}
>
{person ? <FancyPerson person={person} /> : 'No Data'}
</AsyncViewContainer>
);
}
Apart from its children, it will render the render method of the corresponding Async
data state.
BONUS: AsyncViewContainer
accepts an array at the asyncData
prop :
function MyComponent({ asyncPerson }) {
return (
<AsyncViewContainer
asyncData={[asyncProfile, asyncContacts, asyncNotifications]}
inProgressRender={() => 'Loading stuff...'}
errorRender={errors => errors.map(error => error.message).join(' AND ')}
>
// ...
</AsyncViewContainer>
);
}
It will render the corresponding render method if any Async
data is on that state.
API Reference (WIP)
Work in progress.
InitAsync
class InitAsync {
public progress: Progress.Init;
public aborted?: boolean;
public constructor(aborted?: boolean);
}
Represents the INIT state and the ABORTED sub-state.
InProgressAsync
class InProgressAsync {
public progress: Progress.InProgress;
public constructor();
}
Represents the IN PROGRESS state.
SuccessAsync
class SuccessAsync {
public progress: Progress.Success;
public payload: Payload;
public invalidated?: boolean;
public constructor(aborted?: boolean);
}
Represents the SUCCESS state, with its corresponding payload and the INVALIDATED sub-state (payload outdated, new one in progress).
ErrorAsync
class ErrorAsync {
public progress: Progress.Error;
public error: Error;
public constructor(error: Error);
}
Represents the ERROR state, with its corresponding error.
Async
All Async
objects have these methods:
isInit
public isInit(): this is InitAsync
- @returns
true
if async object is in INIT state and act as type guard.
isInProgress
public isInProgress(): this is InProgressAsync
- @returns
true
if async object is in IN PROGRESS state and act as type guard.
isSuccess
public isSuccess(): this is SuccessAsync<Payload>
- @returns
true
if async object is in SUCCESS state and act as type guard.
isError
public isError(): this is ErrorAsync
- @returns
true
if async object is in ERROR state and act as type guard.
isInProgressOrInvalidated
public isInProgressOrInvalidated(): this is InProgressAsync | SuccessAsync<Payload>
- @returns
true
if async object is in IN PROGRESS state or INVALIDATED sub-state and act as type guard.
isAborted
public isAborted(): this is InitAsync
- @returns
true
if async object is in ABORTED sub-state and act as type guard.
getPayload
public getPayload(): Payload | undefined
- @returns corresponding payload (generic) if async object is in SUCCESS state or
undefined
otherwise.
getError
public getError(): Error | undefined
- @returns corresponding
Error
if async object is in ERROR state orundefined
otherwise.
Hooks
useAsyncData
function useAsyncData<Payload>(
getData: (singal?: AbortSignal) => Promise<Payload>,
{
disabled,
onSuccess,
onError,
}: {
disabled?: boolean;
onSuccess?: (payload: Payload) => void;
onError?: (error: Error) => void;
},
): AsyncData<Payload>;
type AsyncData<Payload> = Async<Payload> & {
refresh: () => void;
};
This hook is suitable for handling any kind of querying or data fetching. It takes care of race conditions and it cleans up on component unmount.
⚠️ Be careful, all input functions (getData
, onSuccess
, onError
) are dependencies of the effect it uses. You can create infinite loops if you do not hand them carefully. Wrap the input functions in React.useCallback
if needed to prevent these infinite loops.
Definition:
@param
getData
You want to perform your fetch here. This input function is the async data fetching task that will be carried out. It must return a
Promise
that resolves to the desired data.It can use the
AbortSignal
that the hook provides (when browser supports it) if you want to make your task abortable.@param
options.disabled
While false (default), your task will be run as an effect (inside a
useEffect
hook). If true, your task will not be run as an effect, it will always return anInitAsync
.@param
options.onSuccess
Callback function that will be called when the task reaches the SUCCESS state.
@param
options.onError
Callback function that will be called when the task reaches the ERROR state.
@returns
The
AsyncData<Payload>
, which is theAsync<Payload>
corresponding to the current state of the data, extended with this function:- refresh: function that can be used to trigger the fetch manually (i.e. from a "Refresh" button).
useAsyncTask
function useAsyncTask<Payload, Args extends unknown[]>(
getTask: (singal?: AbortSignal) => (...args: Args) => Promise<Payload>,
{
onSuccess,
onError,
}: {
onSuccess?: (payload: Payload) => void;
onError?: (error: Error) => void;
},
): AsyncTask<Payload, Args>;
type AsyncTask<Payload, Args extends unknown[]> = Async<Payload> & {
trigger: (...args: Args) => Promise<Async<Payload>>;
abort: () => void;
};
This hook is suitable for handling any kind of data posting or mutation task. On component unmount it cleans up, but it does not abort flying requests (this can be done with the provided function).
If triggered multiple times in a row:
- All triggered tasks will happen.
- The returned
Aync<Payload>
will only track the state of the last triggered tasks. SeeuseManyAsyncTasks
if you want to trigger and track the state of many tasks. abort
will abort all existing tasks.
Definition:
@param
getTask
This input function returns the async task that will be carried out. The returned task can have any input arguments, that will be provided when the task is triggered. The task must return a
Promise
that can resolve to the result of the operation (i.e. the new ID of a posted item) or to void.It can use the
AbortSignal
that the hook provides (when browser supports it) if you want to make your task abortable.@param
options.onSuccess
Callback function that will be called when the task reaches the SUCCESS state.
@param
options.onError
Callback function that will be called when the task reaches the ERROR state.
@returns
The
AsyncTask<Payload>
, which is theAsync<Payload>
corresponding to the current state of the task, extended with these functions:- trigger: function that triggers the task (i.e. from a "Submit" button). It forwards its args to the task that you provided to the hook, and it returns a
Promise
of theAsync
result. You generally won't use this returnedAsync
, it is a escape hatch for some cases. - abort: function that aborts the task, setting the
Async
back to the INIT state, and as ABORTED if it was IN PROGRESS or INVALIDTED.
- trigger: function that triggers the task (i.e. from a "Submit" button). It forwards its args to the task that you provided to the hook, and it returns a
useManyAsyncTasks
function useManyAsyncTasks<Payload, Args extends unknown[]>(
getTask: (singal?: AbortSignal) => (...args: Args) => Promise<Payload>,
{
onSuccess,
onError,
}: {
onSuccess?: (payload: Payload) => void;
onError?: (error: Error) => void;
},
): (key: any) => AsyncTask<Payload, Args>;
It works exactly the same way useAsyncTask
does, but this hook can be used to track multiple async tasks of the same type. Instead of returning an AsyncTask
, it returns an AsyncTask
getter. Use any key as input to obtain the associated AsyncTask
.
Contributing
Open to contributions!
LICENSE
MIT