react-async-cases
v1.1.0
Published
Separating async use-case logic from the UI in React applications.
Readme
react-async-cases
Separating async business logic from user interface in React applications.
You can extract plain business logic (i.e. cases) into independent classes. The react-async-cases hooks connect these cases to React components and give you the result value and state of the async process. Your components can remain clear and simple.
Features:
useCasehook - separating async use-casesuseCaseStatehook - separating async use-cases and async state monitoringResult<Value, Error>type - bypassing exception handlingResultobject - helpers for creating an instance ofResulttypeuseAsynStatehook - storing async state and resulting value- abortable execution of the
Case
Simple example:
// Case implementation
export class LoadTodosCase implements Case {
async execute() {
// send API request
const result = await Result.async(() => axios.get('/todos'));
if (result.isErr()) {
// we can do something with the result.error
console.log('LoadTodosCase error:', result.error);
}
if (result.isOk()) {
// we can do something with the result.value
console.log('LoadTodosCase value:', result.value);
}
return result;
}
}
// React component
export function TodoList() {
// make a connection with LoadTodosCase
const { state, error, value, run } = useCaseState(() => new LoadTodosCase());
useEffect(() => {
// initial loading
// the run() function executes the case
void run();
}, [run]);
return (
<>
<h1>Todo List</h1>
{state.isPending ? <Loader />}
{!state.isPending && error && <ErrorPanel error={error} />}
<TodoList list={value} />
</>
);
}Later we will see how to write cases.
Installation
$ npm install react-async-casesUsage
1. Case Definition
In terms of the react-async-cases library, the case is a separate unit that covers one application feature. We might also call it an application service or a use case.
The case is implemented as a class with this interface:
interface Case<Res, Err, P> {
execute(params: P): Promise<Result<Res, Err>>;
onAbort?: () => void;
}The case is separated from the rest of the application. It declares all its dependencies in its contructor, so it is well testable.
2. Case Result
The execute method of the Case object must not throw an exception. Instead, it returns an object of Result type, which is a union type of a success or error value.
type Result<V, E> = Ok<V> | Err<E>;The Ok object wraps a value and offers it via the result.value getter.
The Err object wraps an error and offers it via the result.error getter.
Both Ok and Err objects implement isOk() and isErr() methods, which act as type guards.
Although it is fine to use a constructor to create an instance of Ok or Err, you can also use prepared helper functions. Result object aggregates all helper functions.
Result.ok(value: V)returnsOk<V>instanceResult.err(error: E)returnsErr<E>instanceResult.async(asyncFn, errorFactory)calls the asynchronous functionasyncFn(), catches any exceptions and returnsPromisewithOk<V> | Err<E>value. OptionalerrorFactoryfunction may transform an error into a custom object.Result.sync(syncFn, errorFactory)calls the synchronous functionsyncFn(), catches any exceptions and returnsOk<V> | Err<E>instance. OptionalerrorFactoryfunction may transform an error into a custom object.
Examples of creating an instance of the Result type:
import { Result } from 'react-async-cases';
// Ok result
const okResult = Result.ok('success');
if (okResult.isOk()) {
console.log(okResult.value); // -> 'success'
// okResult.error // -> TS: Property 'error' does not exist on type Ok
}
if (okResult.isErr()) {
// -> never
}
// Err result
const errResult = Result.err('error message');
if (errResult.isErr()) {
console.log(errResult.error); // -> 'error message'
// errResult.value // -> TS: Property 'value' does not exist on type Err
}
if (errResult.isOk()) {
// -> never
}3. Case Creation
Let's make an example case for getting a todo list from a REST API service. We will use the prepared Result.async() function, which calls an asynchronous API request and promises an instance of Result type. It does not throw an exception.
import axios from 'axios';
import { Result, Case } from 'react-async-cases';
export class LoadTodosCase implements Case {
constructor(private abortController: AbortController = new AbortController()) {}
async execute(filter: string) {
// send API request
const result = await Result.acync(() => axios.get('/todos', { params: { filter } }));
if (result.isErr()) {
// we can do something with result.error
// e.g. log error
console.log('LoadTodosCase:', result.error);
}
if (result.isOk()) {
// we can do something with result.value
// e.g. save to some store (zustand, redux, ...)
}
return result;
}
/**
* Implementation of case aborting. We will use it in component.
* This is optional feature, not every case needs it.
*/
onAbort() {
this.abortController.abort();
}
}4. Connection With a Component
Cases are independent pieces of code. How can we use them in React components?
As an adapter, we can choose from prepared library hooks: useCase or useCaseState.
Hooks gets a Case factory method as a parameter. Factory method must create an instance and not throw an exception.
Example:
const loadTodos = useCaseState(() => new LoadTodosCase());You can inject an additional dependency:
const additionalDependency = useSomething();
const anotherCase = useCaseState(() => new AnotherCase(additionalDependency));useCase and useCaseState returns a run function. React component can call this run function to execute the case.
Internally, the run function creates an instance of the Case object using its factory method, then calls the execute function with arguments passed to run function, and finally returns an instance of Result type.
Additionally, the useCaseState hook returns a state object, so the component can monitor the state of the async process.
5. Usage in Component
export function TodoList() {
const [filter, setFilter] = useState('');
// make a connection with LoadTodosCase
const { state, error, value, run, abort } = useCaseState(() => new LoadTodosCase());
useEffect(() => {
// initial loading and loading when changing the filter
void run(filter);
return () => {
// abort running requests
abort();
};
}, [abort, filter, run]);
/** Todo item was created/updated/removed. */
const handleListChanged = () => {
// abort running requests
abort();
// reload todo list
void run(filter);
};
return (
<div>
<h1>Todo List</h1>
<Filter filter={filter} onChange={setFilter} />
{state.isPending ? <Loader />}
{!state.isPending && error && <ErrorPanel error={error} />}
<TodoList list={value} onChange={handleListChanged} />
</div>
);
}6. Chaining of Cases
Cases may call other cases within the execute method. Components call such a compound case once and does not need to trigger a chain of cases using the useEffect hook.
Example:
export class AddTodoItemCase implements Case {
async execute(todoItem: Todo) {
// post a new item
const result = await Result.async(() => axios.post('/todo/add', todoItem);
if (result.isErr()) {
// result is Err object
// do something with result.error
return result;
}
// New item is created on backend,
// so we want to update the todo list.
// Create the LoadTodosCase
const loadTodosCase = new LoadTodosCase();
// and execute it
const loadingResult = await loadTodosCase.execute('');
if (loadingResult.isErr()) {
return loadingResult;
}
return result;
}
}7. Aborting of Cases
The Case interface offers onAbort method. When the component is unmounted, the onAbort method is callled. It is up to you how your case will behave in this situation. A common approach is to use AbortController API.
It is also possible to abort the case manually. Both hooks useCase and useCaseState returns an abort method that can be called in components.
Aborted case does not change any of the value, error, state values returned from the useCaseState hook. E.g. manually aborted pending case remains pending. Therefore, the last properly finished case will return the correct value, error and state.
You saw the use of aborting in the LoadTodosCase example. When we type a few characters in the filter input field, a series of request is sent. To prevent a request race, we need to abort old requests every time a new character is typed.
8. Usage With State Management Libraries
In general, when a case requires an external dependency, we can pass that dependency as a parameter in the case constructor.
8.1. With Redux
Define the type and hook of the Redux store (see Redux Toolkit with TypeScript):
export type RootState = ReturnType<typeof store.getState>;
export const useAppStore = () => useStore<RootState>();Inject the app Redux store to the case:
export function useLoadTodos() {
const appStore = useAppStore();
return useCaseState(() => new LoadTodosCase(appStore));
}Define the case:
export class LoadTodosCase implements Case {
constructor(private appStore: AppStore) {}
async execute() {
// get a value from the store
const filter = this.appStore.getState().todo.filter;
// send API request
const result = await Result.async(() => TodoApiService.list(filter));
if (result.isErr()) {
// log error
console.log('LoadTodosCase error:', result.error);
return result;
}
// set todos to the store
this.appStore.dispatch(setTodos(result.value));
return result;
}
}See the full code in the sample application.
8.2. With Zustand
We can use a similar constructor injection as with Redux or we can use the Zustand store directly in the case class.
Example of direct use of the Zustand instance:
export class LoadTodosCase implements Case {
async execute() {
// get a value from the store
const filter = useTodoStore.getState().filter;
// send API request
const result = await Result.async(() => TodoApiService.list(filter));
if (result.isErr()) {
// log error
console.log('LoadTodosCase error:', result.error);
return result;
}
// set todos to the store
const { setTodos } = useTodoStore.getState().actions;
setTodos(result.value);
return result;
}
}See the full code in the sample application.
Sample Application
The sample application is part of this repository. It shows the use of the react-async-cases library not only in pure React, but also with Redux and Zustand.
Asynchronous requests are simulated with random delays to emphasize the penging phase.
Download this repository and as usual:
$ npm installAnd run the sample app:
$ npm run devAPI
Hook useCaseState(caseFactory)
The useCaseState(caseFactory) hook returns run and abort methods and values for state monitoring.
Parameters
caseFactory:() => Case- it must not throw an exception. The returned object should implement theCaseinterface.
Returns
Case controlling:
run:async (params) => Promise<Result>- it calls theexecute(params)method of theCaserunis an async function, in components you can wait for its Result
abort:() => void- it calls the theonAbort()method of theCase
Async state monitoring:
value: resolved promise value from therunmethod, it is unwrappedvalueof theOktypeerror: rejected promise value from therunmethod, it is unwrappederrorof theErrtypestate: state objectstate: 'initial' | 'pending' | 'resolved' | 'rejected'isInitial: boolean - true when norunhas startedisPending: boolean - true whenrunmethod is awaitingisResolved: boolean - true whenrunwas resolvedisRejected: boolean - true whenrunwas rejectedisFinished: boolean - true whenrunwas resolved or rejected
actions: control the state manually (rarely usable)start:() => void- marks the state as 'pending'resolve:(value) => void- marks the state as 'resolved' and sets the resolvedvaluereject:(error) => void- marks the state as 'rejected' and sets the rejectederrorvaluereset:() => void- marks the state as 'initial' and resetsvalueanderror
Hook useCase(caseFactory)
The useCase(caseFactory) hook returns run and abort methods.
Parameters
caseFactory:() => Case
Returns
run:async (params) => Promise<Result>abort:() => void
Interface Case
The Case is interface.
Methods
execute:async (params) => Result- async function returns an object ofResulttype. It must not throw an exception. Therunmethod of the hooks calls theexecutemethod of the case.onAbort:() => void- method is optional. Theabortmethod of the hooks calls theonAbortmethod of the case.
Type Result
Result is a union type of the Ok or Err value.
type Result<V, E> = Ok<V> | Err<E>;Class Ok
Class Ok wraps a value of any type. To create a new instance, you can use the constructor or helper function ok(value).
Example with constructor:
import { Ok } from 'react-async-cases';
const result = new Ok({ title: 'Success' });Example with ok(value) function:
import { ok } from 'react-async-cases';
const result = ok({ title: 'Success' });Class members
constructor(value)- thevaluecan be of any typevalue: readonly valueisOk(): type guard, returns trueisErr(): type guard, returns false
Class Err
Class Err wraps an error of any type. To create a new instance, you can use the constructor or helper function err(error).
Example with constructor:
import { Err } from 'react-async-cases';
const result = new Err({ reason: 'Bad credentials' });Example with err(error) function:
import { err } from 'react-async-cases';
const result = err({ reason: 'Bad credentials' });Class members
constructor(error)- theerrorcan be of any typeerror: readonly error valueisOk(): type guard, returns falseisErr(): type guard, returns true
Hook useAsyncState()
useAsyncState() helps to monitor the state of an async process. Hook stores the result value or error of an async process and its current state. It does not control the process itself.
Returns
value: resolved valueerror: rejected valuestate: the state of the async processstate: 'initial' | 'pending' | 'resolved' | 'rejected'isInitial: boolean - true when state is 'initial'isPending: boolean - true when state is 'pending'isResolved: boolean - true when state was 'resolved'isRejected: boolean - true when state was 'rejected'isFinished: boolean - true when state was 'resolved' or 'rejected'
actions: setting the state and resultstart:() => void- marks the state as 'pending'resolve:(value) => void- marks the state as 'resolved' and sets the resolvedvaluereject:(error) => void- marks the state as 'rejected' and sets the rejectederrorvaluereset:() => void- marks the state as 'initial' and resetsvalueanderrortoundefined
Helpers
To create instances of the Result type, you can use either separate methods or a helper object Result that aggregates them all.
Result.ok(value) alias ok(value)
The ok(value) helper function creates a new instance of the Ok class.
ok:(value) => Ok
Result.err(error) alias err(error)
The err(error) helper function creates a new instance of the Err class.
err:(error) => Err
Result.async(asyncFn, errorFactory) alias asyncResult(asyncFn, errorFactory)
The asyncResult(asyncFn, errorFactory) helper function wraps the asynchronous function call, catches any exceptions, and returns a Promise with Ok | Err value.
asyncFn:() => Promise<V>errorFactory?:(error: unknown) => E | Err<E>- optional function can transform an error to custom error object
Simple example:
const getTodos = () => axios.get<Todo[]>('/todos');
const apiData = await Result.async(getTodos);
// apiData is of type Ok<Todo[]> | Err<unknown>Example with errorFactory:
const getTodos = () => axios.get<Todo[]>('/todos');
const apiData = await Result.async(getTodos, (error: unknown) => new MyApiError(error));
// apiData is of type Ok<Todo[]> | Err<MyApiError>Result.sync(syncFn, errorFactory) alias syncResult(syncFn, errorFactory)
Synchronous variant of the asyncResult function.
License
MIT
