react-async-utils v0.14.0
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
Asyncdata 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-utilsThe 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 aPromisethat resolves to the desired data.asyncPerson: it is ourAsyncdata. 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 aPromisethat resolves to the result of the operation.asyncSubmitValues: it is ourAsyncdata. 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 callsubmitValueswith the same arguments it receives, and it will updateasyncSubmitstate according to the state of thePromisereturned 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
trueif async object is in INIT state and act as type guard.
isInProgress
public isInProgress(): this is InProgressAsync- @returns
trueif async object is in IN PROGRESS state and act as type guard.
isSuccess
public isSuccess(): this is SuccessAsync<Payload>- @returns
trueif async object is in SUCCESS state and act as type guard.
isError
public isError(): this is ErrorAsync- @returns
trueif async object is in ERROR state and act as type guard.
isInProgressOrInvalidated
public isInProgressOrInvalidated(): this is InProgressAsync | SuccessAsync<Payload>- @returns
trueif async object is in IN PROGRESS state or INVALIDATED sub-state and act as type guard.
isAborted
public isAborted(): this is InitAsync- @returns
trueif 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
undefinedotherwise.
getError
public getError(): Error | undefined- @returns corresponding
Errorif async object is in ERROR state orundefinedotherwise.
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
getDataYou want to perform your fetch here. This input function is the async data fetching task that will be carried out. It must return a
Promisethat resolves to the desired data.It can use the
AbortSignalthat the hook provides (when browser supports it) if you want to make your task abortable.@param
options.disabledWhile false (default), your task will be run as an effect (inside a
useEffecthook). If true, your task will not be run as an effect, it will always return anInitAsync.@param
options.onSuccessCallback function that will be called when the task reaches the SUCCESS state.
@param
options.onErrorCallback 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. SeeuseManyAsyncTasksif you want to trigger and track the state of many tasks. abortwill abort all existing tasks.
Definition:
@param
getTaskThis 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
Promisethat can resolve to the result of the operation (i.e. the new ID of a posted item) or to void.It can use the
AbortSignalthat the hook provides (when browser supports it) if you want to make your task abortable.@param
options.onSuccessCallback function that will be called when the task reaches the SUCCESS state.
@param
options.onErrorCallback 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
Promiseof theAsyncresult. You generally won't use this returnedAsync, it is a escape hatch for some cases. - abort: function that aborts the task, setting the
Asyncback 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
6 years ago
6 years ago
6 years ago
6 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago