feat: improve useAsync and useAsyncFn

This commit is contained in:
Vadim Dalecky 2019-05-31 10:10:42 +02:00 committed by GitHub
commit 3ab1d5db3c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 334 additions and 41 deletions

View File

@ -1,11 +1,22 @@
module.exports = {
presets: ['@babel/preset-env', '@babel/preset-react', "@babel/preset-typescript"],
env: {
test: {
plugins: ['dynamic-import-node']
},
production: {
plugins: ['@babel/plugin-syntax-dynamic-import']
}
presets: [
[
"@babel/preset-env",
{
targets: {
node: "current"
}
}
],
"@babel/preset-react",
"@babel/preset-typescript"
],
env: {
test: {
plugins: ['dynamic-import-node']
},
production: {
plugins: ['@babel/plugin-syntax-dynamic-import']
}
}
};

View File

@ -131,5 +131,9 @@
"tslint --fix -t verbose",
"git add"
]
},
"volta": {
"node": "10.16.0",
"yarn": "1.16.0"
}
}
}

View File

@ -0,0 +1,175 @@
import { useCallback } from 'react';
import { cleanup, renderHook } from 'react-hooks-testing-library';
import useAsync from '../useAsync';
afterEach(cleanup);
// NOTE: these tests cause console errors.
// maybe we should test in a real environment instead
// of a fake one?
describe('useAsync', () => {
it('should be defined', () => {
expect(useAsync).toBeDefined();
});
describe('a success', () => {
let hook;
let callCount = 0;
const resolver = async () => {
return new Promise((resolve, reject) => {
callCount++;
const wait = setTimeout(() => {
clearTimeout(wait);
resolve('yay');
}, 0);
});
};
beforeEach(() => {
callCount = 0;
hook = renderHook(({ fn }) => useAsync(fn, [fn]), {
initialProps: {
fn: resolver,
},
});
});
it('initially starts loading', () => {
expect(hook.result.current.loading).toEqual(true);
});
it('resolves', async () => {
expect.assertions(4);
hook.rerender({ fn: resolver });
await hook.waitForNextUpdate();
expect(callCount).toEqual(1);
expect(hook.result.current.loading).toBeFalsy();
expect(hook.result.current.value).toEqual('yay');
expect(hook.result.current.error).toEqual(undefined);
});
});
describe('an error', () => {
let hook;
let callCount = 0;
const rejection = async () => {
return new Promise((resolve, reject) => {
callCount++;
const wait = setTimeout(() => {
clearTimeout(wait);
reject('yay');
}, 0);
});
};
beforeEach(() => {
callCount = 0;
hook = renderHook(({ fn }) => useAsync(fn, [fn]), {
initialProps: {
fn: rejection,
},
});
});
it('initially starts loading', () => {
expect(hook.result.current.loading).toBeTruthy();
});
it('resolves', async () => {
expect.assertions(4);
hook.rerender({ fn: rejection });
await hook.waitForNextUpdate();
expect(callCount).toEqual(1);
expect(hook.result.current.loading).toBeFalsy();
expect(hook.result.current.error).toEqual('yay');
expect(hook.result.current.value).toEqual(undefined);
});
});
describe('re-evaluates when dependencies change', () => {
describe('the fn is a dependency', () => {
let hook;
let callCount = 0;
const initialFn = async () => {
callCount++;
return 'value';
};
const differentFn = async () => {
callCount++;
return 'new value';
};
beforeEach(() => {
callCount = 0;
hook = renderHook(({ fn }) => useAsync(fn, [fn]), {
initialProps: { fn: initialFn },
});
});
it('renders the first value', () => {
expect(hook.result.current.value).toEqual('value');
});
it('renders a different value when deps change', async () => {
expect.assertions(3);
expect(callCount).toEqual(1);
hook.rerender({ fn: differentFn }); // change the fn to initiate new request
await hook.waitForNextUpdate();
expect(callCount).toEqual(2);
expect(hook.result.current.value).toEqual('new value');
});
});
describe('the additional dependencies list changes', () => {
let callCount = 0;
let hook;
const staticFunction = async counter => {
callCount++;
return `counter is ${counter} and callCount is ${callCount}`;
};
beforeEach(() => {
callCount = 0;
hook = renderHook(
({ fn, counter }) => {
const callback = useCallback(() => fn(counter), [counter]);
return useAsync<string>(callback, [callback]);
},
{
initialProps: {
counter: 0,
fn: staticFunction,
},
}
);
});
it('initial renders the first passed pargs', () => {
expect(hook.result.current.value).toEqual('counter is 0 and callCount is 1');
});
it('renders a different value when deps change', async () => {
expect.assertions(1);
hook.rerender({ fn: staticFunction, counter: 1 });
await hook.waitForNextUpdate();
expect(hook.result.current.value).toEqual('counter is 1 and callCount is 2');
});
});
});
});

View File

@ -0,0 +1,98 @@
// NOTE: most behavior that useAsyncFn provides
// is covered be the useAsync tests.
//
// The main difference is that useAsyncFn
// does not automatically invoke the function
// and it can take arguments.
import { cleanup, renderHook } from 'react-hooks-testing-library';
import useAsyncFn, { AsyncState } from '../useAsyncFn';
afterEach(cleanup);
type AdderFn = (a: number, b: number) => Promise<number>;
describe('useAsyncFn', () => {
it('should be defined', () => {
expect(useAsyncFn).toBeDefined();
});
describe('the callback can be awaited and return the value', () => {
let hook;
let callCount = 0;
const adder = async (a: number, b: number): Promise<number> => {
callCount++;
return a + b;
};
beforeEach(() => {
// NOTE: renderHook isn't good at inferring array types
hook = renderHook<{ fn: AdderFn }, [AsyncState<number>, AdderFn]>(({ fn }) => useAsyncFn(fn), {
initialProps: {
fn: adder,
},
});
});
it('awaits the result', async () => {
expect.assertions(3);
const [s, callback] = hook.result.current;
const result = await callback(5, 7);
expect(result).toEqual(12);
const [state] = hook.result.current;
expect(state.value).toEqual(12);
expect(result).toEqual(state.value);
});
});
describe('args can be passed to the function', () => {
let hook;
let callCount = 0;
const adder = async (a: number, b: number): Promise<number> => {
callCount++;
return a + b;
};
beforeEach(() => {
// NOTE: renderHook isn't good at inferring array types
hook = renderHook<{ fn: AdderFn }, [AsyncState<number>, AdderFn]>(({ fn }) => useAsyncFn(fn), {
initialProps: {
fn: adder,
},
});
});
it('initially does not have a value', () => {
const [state] = hook.result.current;
expect(state.value).toEqual(undefined);
expect(state.loading).toEqual(false);
expect(state.error).toEqual(undefined);
expect(callCount).toEqual(0);
});
describe('when invoked', () => {
it('resolves a value derived from args', async () => {
expect.assertions(4);
const [s, callback] = hook.result.current;
callback(2, 7);
hook.rerender({ fn: adder });
await hook.waitForNextUpdate();
const [state, c] = hook.result.current;
expect(callCount).toEqual(1);
expect(state.loading).toEqual(false);
expect(state.error).toEqual(undefined);
expect(state.value).toEqual(9);
});
});
});
});

View File

@ -18,14 +18,17 @@ export type AsyncState<T> =
value: T;
};
const useAsync = <T>(fn: () => Promise<T>, deps: DependencyList = []) => {
const [state, callback] = useAsyncFn(fn, deps);
export default function useAsync<Result = any, Args extends any[] = any[]>(
fn: (...args: Args | []) => Promise<Result>,
deps: DependencyList = []
) {
const [state, callback] = useAsyncFn<Result, Args>(fn, deps, {
loading: true,
});
useEffect(() => {
callback();
}, [callback]);
return state;
};
export default useAsync;
}

View File

@ -18,31 +18,35 @@ export type AsyncState<T> =
value: T;
};
const useAsyncFn = <T>(fn: (...args: any[]) => Promise<T>, deps: DependencyList = []): [AsyncState<T>, () => void] => {
const [state, set] = useState<AsyncState<T>>({
loading: false,
});
export default function useAsyncFn<Result = any, Args extends any[] = any[]>(
fn: (...args: Args | []) => Promise<Result>,
deps: DependencyList = [],
initialState: AsyncState<Result> = { loading: false }
): [AsyncState<Result>, (...args: Args | []) => Promise<Result>] {
const [state, set] = useState<AsyncState<Result>>(initialState);
const mounted = useRefMounted();
const callback = useCallback((...args) => {
const callback = useCallback((...args: Args | []) => {
set({ loading: true });
fn(...args).then(
return fn(...args).then(
value => {
if (mounted.current) {
set({ value, loading: false });
}
return value;
},
error => {
if (mounted.current) {
set({ error, loading: false });
}
return error;
}
);
}, deps);
return [state, callback];
};
export default useAsyncFn;
}

View File

@ -1,18 +1,17 @@
{
"defaultSeverity": "error",
"extends": [
"tslint:recommended",
"tslint-react",
"tslint-eslint-rules",
"tslint-config-prettier"
],
"linterOptions": {
"exclude": [
"node_modules/**"
]
},
"jsRules": {},
"rules": {
"defaultSeverity": "error",
"extends": [
"tslint:recommended",
"tslint-react",
"tslint-eslint-rules",
"tslint-config-prettier"
],
"linterOptions": {
"exclude": ["node_modules/**"]
},
"jsRules": {},
"rules": {
"ban-types": false,
"interface-name": [true, "never-prefix"],
"no-console": false,
"max-classes-per-file": false,
@ -32,7 +31,6 @@
"tabWidth": 2
}
]
},
"rulesDirectory": ["tslint-plugin-prettier"]
}
},
"rulesDirectory": ["tslint-plugin-prettier"]
}