Skip to content

Instantly share code, notes, and snippets.

@brigand
Created September 28, 2019 18:36
Show Gist options
  • Save brigand/8e5fef491924a31ac9b92623c9fe3bf3 to your computer and use it in GitHub Desktop.
Save brigand/8e5fef491924a31ac9b92623c9fe3bf3 to your computer and use it in GitHub Desktop.
// @flow
import * as React from 'react';
import { Option } from 'safe-types';
import usePromise from '~/hooks/usePromise';
type State = 'initial' | 'pending' | 'resolved' | 'rejected';
export class UsePromiseState<Result> {
_result: Result | null;
_error: mixed;
_state: State;
constructor(result: Result | null, error: any, state: State) {
this._result = result;
this._error = error;
this._state = state;
}
// Get the latest value... ignoring the state
value(): Option<Result> {
return Option.of(this._result);
}
// Get the latest error... ignoring the state
error(): Option<mixed> {
return Option.of(this._error);
}
// Get the value if in a success state
success(): Option<Result> {
return this.value().filter((x) => this._state === 'resolved');
}
// Get the error if in an failure state
failure(): Option<mixed> {
return this.error().filter((x: mixed) => this._state === 'rejected');
}
// Get if we're current in a loading state
loading(): boolean {
return this._state === 'pending';
}
// Get if we're current in the initial state.
initial(): boolean {
return this._state === 'initial';
}
// Get either Some(true) or None, for loading vs not loading
// Maybe this isn't needed because of match
loadingOption(): Option<true> {
if (this.loading()) {
return Option.Some(true);
} else {
return Option.None();
}
}
match<Out>(
matcher: $Shape<{
value: (value: Result) => Out,
error: (error: mixed) => Out,
success: (value: Result) => Out,
failure: (value: mixed) => Out,
loading: () => Out,
initial: () => Out,
default: () => Out,
}>,
): Out {
for (const key of Object.keys(matcher)) {
if (key === 'value' && matcher.value) {
const v = this.value();
if (v.is_some()) {
return matcher.value(v.unwrap());
} else {
continue;
}
}
if (key === 'error' && matcher.error) {
const v = this.error();
if (v.is_some()) {
return matcher.error(v.unwrap());
} else {
continue;
}
}
if (key === 'success' && matcher.success) {
const v = this.success();
if (v.is_some()) {
return matcher.success(v.unwrap());
} else {
continue;
}
}
if (key === 'failure' && matcher.failure) {
const v = this.failure();
if (v.is_some()) {
return matcher.failure(v.unwrap());
} else {
continue;
}
}
if (key === 'loading' && matcher.loading) {
if (this.loading()) {
return matcher.loading();
} else {
continue;
}
}
if (key === 'initial' && matcher.initial) {
if (this.initial()) {
return matcher.initial();
} else {
continue;
}
}
if (key !== 'default') {
throw new Error(
`Unexpected matcher key "${key}". See src/utils/usePromise for the expected API`,
);
}
}
if (matcher.default) {
return matcher.default();
}
throw new Error(
`No cases matched. Define a 'default' case as a fallback if needed.`,
);
}
/// Allows matching on multiple states at once, and getting Option<Option<T>> where T is the success type.
/// Returns None if none of the provided variants match
/// Returns Some(Some(value)) if the success/value variants match
/// Returns Some(None) if any other variant matches
anyOf(
...variants: Array<
'failure' | 'success' | 'value' | 'error' | 'loading' | 'initial',
>
): Option<Option<Result>> {
return this.match({
failure: () =>
variants.includes('failure') ? Option.Some(Option.None()) : Option.None(),
success: (value) =>
variants.includes('success')
? Option.Some(Option.Some(value))
: Option.None(),
value: (value) =>
variants.includes('value') ? Option.Some(Option.Some(value)) : Option.None(),
error: () =>
variants.includes('error') ? Option.Some(Option.None()) : Option.None(),
loading: () =>
variants.includes('loading') ? Option.Some(Option.None()) : Option.None(),
initial: () =>
variants.includes('initial') ? Option.Some(Option.None()) : Option.None(),
default: () => Option.None(),
});
}
}
class Skip {}
export type Service = {
skip: () => Skip,
};
type InputFunc<Result> = (
service: Service,
) => Promise<Result> | Skip | Option<Promise<Result>>;
export type Input<Result> =
| Promise<Result>
| InputFunc<Result>
| Option<Promise<Result>>;
export type ShouldRun = () => boolean;
const alwaysRun: ShouldRun = () => true;
function useAsync<Result: any>(
promise: Input<Result>,
deps?: Array<any>,
shouldRun: ShouldRun = alwaysRun,
): UsePromiseState<Result> {
const skip = React.useRef(false);
const innerArg: any =
typeof promise === 'function'
? () => {
if (!shouldRun()) {
skip.current = true;
return null;
}
skip.current = false;
const func: InputFunc<Result> = (promise: any);
let result = func({
skip: () => {
skip.current = true;
return new Skip();
},
});
if (result && result instanceof Option) {
result = result.match({
Some: (value) => value,
None: () => {
skip.current = true;
return new Skip();
},
});
}
if (result instanceof Promise) {
return result;
}
return Promise.resolve(result);
}
: promise;
let didTransitionToRunning = false;
if (!shouldRun()) {
skip.current = true;
} else if (skip.current) {
didTransitionToRunning = true;
}
const [result, error, state] = usePromise<Result>((innerArg: any), deps);
const state2 =
skip.current || result instanceof Skip
? 'initial'
: didTransitionToRunning
? 'pending'
: state;
let lastResult: { current: Result | null } = React.useRef(null);
let lastError: { current: any } = React.useRef(null);
const service = React.useMemo(() => {
if (result != null) {
lastResult.current = result;
}
if (error != null) {
lastError.current = error;
}
return new UsePromiseState(lastResult.current, lastError.current, state2);
}, [result, error, state2]);
return service;
}
export default useAsync;
// Based on https://github.com/bsonntag/react-use-promise/blob/3ab9f8731e69ce9be68f93469b1c5d828fa73521/src/index.js
// but with a bug fix for having an instant transition to pending/loading when the effect runs.
import { useEffect, useReducer } from 'react';
function resolvePromise(promise) {
if (typeof promise === 'function') {
return promise();
}
return promise;
}
const states = {
pending: 'pending',
rejected: 'rejected',
resolved: 'resolved',
};
const STATE_PENDING = {
error: undefined,
result: undefined,
state: states.pending,
};
function reducer(state, action) {
switch (action.type) {
case states.pending:
return STATE_PENDING;
case states.resolved:
return {
error: undefined,
result: action.payload,
state: states.resolved,
};
case states.rejected:
return {
error: action.payload,
result: undefined,
state: states.rejected,
};
/* istanbul ignore next */
default:
return state;
}
}
function usePromise(_promise, inputs) {
let promise = _promise;
const forcePending = React.useRef(false);
const [{ error, result, state }, dispatch] = useReducer(reducer, {
error: undefined,
result: undefined,
state: states.pending,
});
React.useMemo(() => {
forcePending.current = true;
}, inputs);
useEffect(() => {
promise = resolvePromise(promise);
if (!promise) {
return undefined;
}
let canceled = false;
dispatch({ type: states.pending });
promise.then(
(res) => {
forcePending.current = false;
if (!canceled) {
dispatch({
payload: res,
type: states.resolved,
});
}
},
(err) => {
forcePending.current = false;
if (!canceled) {
dispatch({
payload: err,
type: states.rejected,
});
}
},
);
return () => {
canceled = true;
};
}, inputs);
if (forcePending.current) {
return [STATE_PENDING.result, STATE_PENDING.error, STATE_PENDING.state];
}
return [result, error, state];
}
export default usePromise;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment