Skip to content

Commit

Permalink
Merge pull request #56 from n1ru4l/feat-ts-yield-typing
Browse files Browse the repository at this point in the history
feat: better ts typing of the yield value
  • Loading branch information
n1ru4l authored Mar 11, 2020
2 parents a7a3843 + 6f465fc commit c839053
Show file tree
Hide file tree
Showing 5 changed files with 119 additions and 124 deletions.
155 changes: 81 additions & 74 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,63 @@
[![CircleCI](https://img.shields.io/circleci/build/github/n1ru4l/use-async-effect.svg)](https://circleci.com/gh/n1ru4l/use-async-effect)
[![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release)

Simplify your async `useEffect` code with a [generator function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*).
Simple type-safe async effects for React powered by [generator functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*).

```tsx
import React from "react";
import useAsyncEffect from "@n1ru4l/use-async-effect";

const MyComponent = ({ filter }) => {
const [data, setData] = React.useState(null);

useAsyncEffect(
function*(onCancel, c) {
const controller = new AbortController();

onCancel(() => controller.abort());

const data = yield* c(
fetch("/data?filter=" + filter, {
signal: controller.signal
}).then(res => res.json())
);

setData(data);
},
[filter]
);
};
```

<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->

- [Install Instructions](#install-instructions)
- [The problem](#the-problem)
- [Example](#example)
- [Before 😖](#before-)
- [After 🤩](#after-)
- [Usage](#usage)
- [Install Instructions](#install-instructions)
- [Usage Instructions](#usage-instructions)
- [Basic Usage](#basic-usage)
- [Cancelling an in-flight `fetch` request](#cancelling-an-in-flight-fetch-request)
- [Cleanup Handler](#cleanup-handler)
- [Setup eslint for `eslint-plugin-react-hooks`](#setup-eslint-for-eslint-plugin-react-hooks)
- [Usage with TypeScript](#usage-with-typescript)
- [Basic Usage](#basic-usage)
- [Cancel handler (Cancelling an in-flight `fetch` request)](#cancel-handler-cancelling-an-in-flight-fetch-request)
- [Cleanup Handler](#cleanup-handler)
- [Setup eslint for `eslint-plugin-react-hooks`](#setup-eslint-for-eslint-plugin-react-hooks)
- [TypeScript](#typescript)
- [API](#api)
- [`useAsyncEffect`](#useasynceffect)
- [`useAsyncEffect` Hook](#useasynceffect-hook)
- [Contributing](#contributing)
- [LICENSE](#license)

<!-- END doctoc generated TOC please keep comment here to allow auto update -->

## Install Instructions

`yarn add -E @n1ru4l/use-async-effect`

or

`npm install -E @n1ru4l/use-async-effect`

## The problem

Doing async stuff with `useEffect` clutters your code:
Expand Down Expand Up @@ -91,14 +124,16 @@ const MyComponent = ({ filter }) => {
const [data, setData] = useState(null);

useAsyncEffect(
function*(onCancel) {
function*(onCancel, c) {
const controller = new AbortController();

onCancel(() => controller.abort());

const data = yield fetch("/data?filter=" + filter, {
signal: controller.signal
}).then(res => res.json());
const data = yield* c(
fetch("/data?filter=" + filter, {
signal: controller.signal
}).then(res => res.json())
);

setData(data);
},
Expand All @@ -109,30 +144,20 @@ const MyComponent = ({ filter }) => {

## Usage

### Install Instructions

`yarn add -E @n1ru4l/use-async-effect`

or

`npm install -E @n1ru4l/use-async-effect`

### Usage Instructions

Works like `useEffect`, but with a generator function.

#### Basic Usage
### Basic Usage

```jsx
import React, { useState } from "react";
import useAsyncEffect from "@n1ru4l/use-async-effect";

const MyDoggoImage = () => {
const [doggoImageSrc, setDoggoImageSrc] = useState(null);
useAsyncEffect(function*() {
const { message } = yield fetch(
"https://dog.ceo/api/breeds/image/random"
).then(res => res.json());
useAsyncEffect(function*(_, c) {
const { message } = yield* c(
fetch("https://dog.ceo/api/breeds/image/random").then(res => res.json())
);
setDoggoImageSrc(message);
}, []);

Expand All @@ -142,7 +167,7 @@ const MyDoggoImage = () => {

[![Edit use-async-effect doggo demo](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/use-async-effect-doggo-demo-qrqix?fontsize=14)

#### Cancelling an in-flight `fetch` request
### Cancel handler (Cancelling an in-flight `fetch` request)

You can react to cancels, that might occur while a promise has not resolved yet, by registering a handler via `onCancel`.
After an async operation has been processed, the `onCancel` handler is automatically being unset.
Expand All @@ -153,14 +178,14 @@ import useAsyncEffect from "@n1ru4l/use-async-effect";

const MyDoggoImage = () => {
const [doggoImageSrc, setDoggoImageSrc] = useState(null);
useAsyncEffect(function*(onCancel) {
useAsyncEffect(function*(onCancel, c) {
const abortController = new AbortController();
onCancel(() => {
abortController.abort();
});
const { message } = yield fetch("https://dog.ceo/api/breeds/image/random", {
signal: abortController.signal
});
onCancel(() => abortController.abort());
const { message } = yield c(
fetch("https://dog.ceo/api/breeds/image/random", {
signal: abortController.signal
}).then(res => res.json())
);
setDoggoImageSrc(message);
}, []);

Expand All @@ -170,16 +195,23 @@ const MyDoggoImage = () => {

[![Edit use-async-effect doggo cancel demo](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/use-async-effect-doggo-cancel-demo-6rxvd?fontsize=14)

#### Cleanup Handler
### Cleanup Handler

Similar to `React.useEffect` you can return a cleanup function from your generator function.
It will be called once the effect dependencies change or the component is unmounted.
Please take note that the whole generator must be executed before the cleanup handler can be invoked.
In case you setup event listeners etc. earlier you will also have to clean them up by specifiying a cancel handler.

```jsx
import React, { useState } from "react";
import useAsyncEffect from "@n1ru4l/use-async-effect";

const MyDoggoImage = () => {
const [doggoImageSrc, setDoggoImageSrc] = useState(null);
useAsyncEffect(function*() {
const { message } = yield fetch("https://dog.ceo/api/breeds/image/random");
useAsyncEffect(function*(_, c) {
const { message } = yield* c(
fetch("https://dog.ceo/api/breeds/image/random").then(res => res.json())
);
setDoggoImageSrc(message);

const listener = () => {
Expand All @@ -195,7 +227,7 @@ const MyDoggoImage = () => {

[![Edit use-async-effect cleanup doggo demo](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/use-async-effect-doggo-demo-w1zlh?fontsize=14)

#### Setup eslint for `eslint-plugin-react-hooks`
### Setup eslint for `eslint-plugin-react-hooks`

You need to configure the `react-hooks/exhaustive-deps` plugin to treat `useAsyncEffect` as a hook with dependencies.

Expand All @@ -214,46 +246,20 @@ Add the following to your eslint config file:
}
```

#### Usage with TypeScript
### TypeScript

Unfortunately, it is currently not possible to [to interfer the type of a yield expression based on the yielded value](https://github.com/microsoft/TypeScript/issues/32523).
However, there is a workaround for typing yielded results.
We expose a helper function for TypeScript that allows interferring the correct Promise resolve type. It uses some type-casting magic under the hood and requires you to use the `yield*` keyword instead of the `yield` keyword.

```tsx
useAsyncEffect(function*() {
// without the type annotation `numericValue` would be of the type `any`
const numericValue: number = yield Promise.resolve(123);
useAsyncEffect(function*(setErrorHandler, c) {
const numericValue = yield* c(Promise.resolve(123));
// type of numericValue is number 🎉
});
```

For complex use cases you can leverage some TypeScript utility types ([based on Conditional Types](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html#conditional-types)):

```tsx
type ThenArg<T> = T extends PromiseLike<infer U> ? U : T;

useAsyncEffect(function*() {
const promise = fetchSomeData();
const result: ThenArg<typeof promise> = yield promise;
});
```

Or the "shorter version" (less variable assignments):

```tsx
type ThenArg<T> = T extends PromiseLike<infer U> ? U : T;

useAsyncEffect(function*() {
const result: ThenArg<ReturnType<
typeof fetchSomeData
>> = yield fetchSomeData();
});
```

This is no ideal solution (and indeed prone to errors, due to typos or wrong type casting). However, it is still a bitter solution than go without types at all. In the future TypeScript might be able to improve the current situation.

## API

### `useAsyncEffect`
### `useAsyncEffect` Hook

Runs a effect that includes async operations. The effect ins cancelled upon dependency change/unmount.

Expand All @@ -263,7 +269,8 @@ function useAsyncEffect(
setCancelHandler: (
onCancel?: null | (() => void),
onCancelError?: null | ((err: Error) => void)
) => void
) => void,
cast: <T>(promise: Promise<T>) => Generator<Promise<T>, T>
) => Iterator<any, any, any>,
deps: React.DependencyList
): void;
Expand All @@ -275,4 +282,4 @@ Please check our contribution guides [Contributing](https://github.com/n1ru4l/us

## LICENSE

MIT
[MIT](https://github.com/n1ru4l/use-async-effect/blob/master/LICENSE).
17 changes: 17 additions & 0 deletions src/use-async-effect.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -391,3 +391,20 @@ it("calls latest generator reference upon dependency change", async done => {
unmount();
done();
});

it("interfers the correct type with the typing helper", async done => {
const TestComponent: React.FC<{}> = () => {
useAsyncEffect(function*(setErrorHandler, cast) {
const a = yield* cast(
Promise.resolve({ type: "FOO" as "FOO", value: 1 })
);
expect(a).toEqual({ type: "FOO", value: 1 });
}, []);
return null;
};

const { unmount } = render(<TestComponent />);
await Promise.resolve();
unmount();
done();
});
23 changes: 17 additions & 6 deletions src/use-async-effect.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
import { useEffect, useRef } from "react";

// eslint-disable-next-line @typescript-eslint/no-empty-function
const noop = () => { };
// eslint-disable-next-line @typescript-eslint/no-empty-function
const noop = () => {};

type GeneratorReturnValueType = void | (() => void);

function* cast<T>(input: Promise<T>): Generator<Promise<T>, T> {
// eslint-disable-next-line
// @ts-ignore
return yield input;
}

type ThenArg<T> = T extends PromiseLike<infer U> ? U : T;

export const useAsyncEffect = (
createGenerator: (
setCancelHandler: (
onCancel?: null | (() => void),
onCancelError?: null | ((err: Error) => void)
) => void
) => // eslint-disable-next-line @typescript-eslint/no-explicit-any
Iterator<any, any, any>,
) => void,
cast: <T>(promise: Promise<T>) => Generator<Promise<T>, T>
) => Iterator<unknown, GeneratorReturnValueType>,
deps: React.DependencyList
) => {
const generatorRef = useRef(createGenerator);
Expand All @@ -27,7 +37,8 @@ export const useAsyncEffect = (
(cancelHandler, cancelErrorHandler) => {
onCancel = cancelHandler || noop;
onCancelError = cancelErrorHandler || noop;
}
},
cast
);
let cleanupHandler = noop;

Expand Down
4 changes: 2 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"esModuleInterop": true,
"declaration": true,
"skipLibCheck": true,
"downlevelIteration": true,
"lib": ["ESNext", "DOM"]
},
"exclude": ["**/*.spec.ts", "**/*.spec.tsx"]
}
}
Loading

0 comments on commit c839053

Please sign in to comment.