Skip to content

Commit

Permalink
feat: improve useAsync and useAsyncFn
Browse files Browse the repository at this point in the history
  • Loading branch information
streamich authored May 31, 2019
2 parents e79fdfe + 09f9bdb commit 3ab1d5d
Show file tree
Hide file tree
Showing 7 changed files with 334 additions and 41 deletions.
27 changes: 19 additions & 8 deletions babel.config.js
Original file line number Diff line number Diff line change
@@ -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']
}
}
};
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,5 +131,9 @@
"tslint --fix -t verbose",
"git add"
]
},
"volta": {
"node": "10.16.0",
"yarn": "1.16.0"
}
}
}
175 changes: 175 additions & 0 deletions src/__tests__/useAsync.test.tsx
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
});
98 changes: 98 additions & 0 deletions src/__tests__/useAsyncFn.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
});
13 changes: 8 additions & 5 deletions src/useAsync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
22 changes: 13 additions & 9 deletions src/useAsyncFn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading

0 comments on commit 3ab1d5d

Please sign in to comment.