Skip to content

Commit f87bb48

Browse files
authored
feat: add new useAsyncIterState hook (#16)
* first code for new `useAsyncIterState` hook * some JSDocs for `useAsyncIterState`
1 parent 2062e89 commit f87bb48

File tree

10 files changed

+307
-5
lines changed

10 files changed

+307
-5
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"@eslint/js": "^9.17.0",
5757
"@testing-library/jest-dom": "^6.6.3",
5858
"@testing-library/react": "^16.1.0",
59+
"@types/lodash-es": "^4.17.12",
5960
"@types/node": "^22.10.2",
6061
"@types/react": "^18.2.47",
6162
"@types/react-dom": "^18.0.11",
@@ -66,6 +67,7 @@
6667
"eslint-config-prettier": "^9.1.0",
6768
"globals": "^15.13.0",
6869
"jsdom": "^25.0.1",
70+
"lodash-es": "^4.17.21",
6971
"prettier": "^3.4.2",
7072
"typescript": "^5.7.2",
7173
"typescript-eslint": "^8.18.0",

pnpm-lock.yaml

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

spec/tests/useAsyncIterState.spec.tsx

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { it, describe, expect, afterEach } from 'vitest';
2+
import { gray } from 'colorette';
3+
import { range } from 'lodash-es';
4+
import { renderHook, cleanup as cleanupMountedReactTrees, act } from '@testing-library/react';
5+
import { useAsyncIterState } from '../../src/index.js';
6+
import { asyncIterToArray } from '../utils/asyncIterToArray.js';
7+
import { asyncIterTake } from '../utils/asyncIterTake.js';
8+
import { pipe } from '../utils/pipe.js';
9+
10+
afterEach(() => {
11+
cleanupMountedReactTrees();
12+
});
13+
14+
describe('`useAsyncIterState` hook', () => {
15+
it(gray('The returned iterable can be async-iterated upon successfully'), async () => {
16+
const [values, setValue] = renderHook(() => useAsyncIterState<string>()).result.current;
17+
18+
const valuesToSet = ['a', 'b', 'c'];
19+
20+
const collectPromise = pipe(values, asyncIterTake(valuesToSet.length), asyncIterToArray);
21+
22+
for (const value of valuesToSet) {
23+
await act(() => setValue(value));
24+
}
25+
26+
expect(await collectPromise).toStrictEqual(['a', 'b', 'c']);
27+
});
28+
29+
it(
30+
gray(
31+
'When hook is unmounted, all outstanding yieldings of the returned iterable resolve to "done"'
32+
),
33+
async () => {
34+
const renderedHook = renderHook(() => useAsyncIterState<string>());
35+
const [values] = renderedHook.result.current;
36+
37+
const [collectPromise1, collectPromise2] = range(2).map(() => asyncIterToArray(values));
38+
39+
renderedHook.unmount();
40+
41+
const collections = await Promise.all([collectPromise1, collectPromise2]);
42+
expect(collections).toStrictEqual([[], []]);
43+
}
44+
);
45+
46+
it(
47+
gray(
48+
'After setting some values followed by unmounting the hook, the pre-unmounting values go through while further values pulled from the returned iterable are always "done"'
49+
),
50+
async () => {
51+
const renderedHook = renderHook(() => useAsyncIterState<string>());
52+
const [values, setValue] = renderedHook.result.current;
53+
54+
const [collectPromise1, collectPromise2] = range(2).map(() => asyncIterToArray(values));
55+
56+
await act(() => setValue('a'));
57+
58+
renderedHook.unmount();
59+
60+
const collections = await Promise.all([collectPromise1, collectPromise2]);
61+
expect(collections).toStrictEqual([['a'], ['a']]);
62+
}
63+
);
64+
65+
it(
66+
gray(
67+
'After the hook is unmounted, any further values pulled from the returned iterable are always "done"'
68+
),
69+
async () => {
70+
const renderedHook = renderHook(() => useAsyncIterState<string>());
71+
const [values] = renderedHook.result.current;
72+
73+
renderedHook.unmount();
74+
75+
const collections = await Promise.all(range(2).map(() => asyncIterToArray(values)));
76+
expect(collections).toStrictEqual([[], []]);
77+
}
78+
);
79+
80+
it(
81+
gray(
82+
"The returned iterable's values are each shared between all its parallel consumers so that each receives all the values from the start of consumption and onwards"
83+
),
84+
async () => {
85+
const [values, setValue] = renderHook(() => useAsyncIterState<string>()).result.current;
86+
87+
const consumeStacks: string[][] = [];
88+
89+
for (const [i, value] of ['a', 'b', 'c'].entries()) {
90+
consumeStacks[i] = [];
91+
(async () => {
92+
for await (const v of values) consumeStacks[i].push(v);
93+
})();
94+
await act(() => setValue(value));
95+
}
96+
97+
expect(consumeStacks).toStrictEqual([['a', 'b', 'c'], ['b', 'c'], ['c']]);
98+
}
99+
);
100+
});

spec/utils/asyncIterTake.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
export { asyncIterTake };
2+
3+
function asyncIterTake<T>(count: number): (src: AsyncIterable<T>) => AsyncIterable<T> {
4+
return sourceIter => {
5+
let iterator: AsyncIterator<T>;
6+
let remainingCount = count;
7+
let closed = false;
8+
9+
return {
10+
[Symbol.asyncIterator]: () => ({
11+
async next() {
12+
if (closed) {
13+
return { done: true, value: undefined };
14+
}
15+
16+
iterator ??= sourceIter[Symbol.asyncIterator]();
17+
18+
if (remainingCount === 0) {
19+
closed = true;
20+
await iterator.return?.();
21+
return { done: true, value: undefined };
22+
}
23+
24+
remainingCount--;
25+
const next = await iterator.next();
26+
27+
if (next.done) {
28+
closed = true;
29+
return { done: true, value: undefined };
30+
}
31+
32+
return next;
33+
},
34+
35+
async return() {
36+
if (!closed) {
37+
closed = true;
38+
await iterator?.return?.();
39+
}
40+
return { done: true, value: undefined };
41+
},
42+
}),
43+
};
44+
};
45+
}

spec/utils/asyncIterToArray.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
export { asyncIterToArray };
22

33
async function asyncIterToArray<T>(source: AsyncIterable<T>): Promise<T[]> {
4-
const values: T[] = [];
4+
const collected: T[] = [];
55
for await (const value of source) {
6-
values.push(value);
6+
collected.push(value);
77
}
8-
return values;
8+
return collected;
99
}

src/Iterate/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ export { Iterate, type IterateProps };
5959
*
6060
* @param props Props for `<Iterate>`. See {@link IterateProps `IterateProps`}.
6161
*
62-
* @returns A renderable output that's re-rendered as consequent values become available and
63-
* formatted by the function passed as `children` (or otherwise the resolved values as-are).
62+
* @returns A React node that updates its contents in response to each next yielded value automatically as
63+
* formatted by the function passed as `children` (or in the absent of, just the yielded values as-are).
6464
*
6565
* @see {@link IterationResult}
6666
*

src/common/promiseWithResolvers.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export { promiseWithResolvers, type PromiseWithResolvers };
2+
3+
const promiseWithResolvers: <T>() => PromiseWithResolvers<T> =
4+
'withResolvers' in Promise
5+
? () => Promise.withResolvers()
6+
: /**
7+
* A ponyfill for the [`Promise.withResolvers`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers) helper
8+
* @returns A pending {@link PromiseWithResolvers} instance for use
9+
*/
10+
function promiseWithResolversPonyfill<T>(): PromiseWithResolvers<T> {
11+
let resolve!: PromiseWithResolvers<T>['resolve'];
12+
let reject!: PromiseWithResolvers<T>['reject'];
13+
const promise = new Promise<T>((res, rej) => {
14+
resolve = res;
15+
reject = rej;
16+
});
17+
return { promise, resolve, reject };
18+
};
19+
20+
type PromiseWithResolvers<T> = {
21+
promise: Promise<T>;
22+
resolve(value: T): void;
23+
reject(reason?: unknown): void;
24+
};

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useAsyncIter, type IterationResult } from './useAsyncIter/index.js';
22
import { Iterate, type IterateProps } from './Iterate/index.js';
33
import { iterateFormatted, type FixedRefFormattedIterable } from './iterateFormatted//index.js';
4+
import { useAsyncIterState, type AsyncIterStateResult } from './useAsyncIterState/index.js';
45

56
export {
67
useAsyncIter,
@@ -9,4 +10,6 @@ export {
910
type IterateProps,
1011
iterateFormatted,
1112
type FixedRefFormattedIterable,
13+
useAsyncIterState,
14+
type AsyncIterStateResult,
1215
};
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { promiseWithResolvers } from '../common/promiseWithResolvers.js';
2+
3+
export { IterableChannel };
4+
5+
class IterableChannel<TVal> {
6+
#isClosed = false;
7+
#nextIteration = promiseWithResolvers<IteratorResult<TVal, void>>();
8+
iterable = {
9+
[Symbol.asyncIterator]: () => ({
10+
next: () => this.#nextIteration.promise,
11+
}),
12+
};
13+
14+
put(value: TVal): void {
15+
if (!this.#isClosed) {
16+
this.#nextIteration.resolve({ done: false, value });
17+
this.#nextIteration = promiseWithResolvers();
18+
}
19+
}
20+
21+
close(): void {
22+
this.#isClosed = true;
23+
this.#nextIteration.resolve({ done: true, value: undefined });
24+
}
25+
}

src/useAsyncIterState/index.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { useEffect, useRef } from 'react';
2+
import { IterableChannel } from './IterableChannel.js';
3+
import { type Iterate } from '../Iterate/index.js'; // eslint-disable-line @typescript-eslint/no-unused-vars
4+
5+
export { useAsyncIterState, type AsyncIterStateResult };
6+
7+
/**
8+
* Basically like {@link https://react.dev/reference/react/useState `React.useState`}, only that the value
9+
* is provided back __wrapped as an async iterable__.
10+
*
11+
* This hook allows a component to declare and manage a piece of state while easily letting it control
12+
* what area(s) specifically within the UI would be bound to it (will re-render in reaction to changes in it) -
13+
* combined for example with one or more {@link Iterate `<Iterate>`}s.
14+
*
15+
* ```tsx
16+
* import { useAsyncIterState, Iterate } from 'async-react-iterators';
17+
*
18+
* function MyForm() {
19+
* const [firstNameIter, setFirstName] = useAsyncIterState<string>();
20+
* const [lastNameIter, setLastName] = useAsyncIterState<string>();
21+
* return (
22+
* <div>
23+
* <form>
24+
* <FirstNameInput valueIter={firstNameIter} onChange={setFirstName} />
25+
* <LastNameInput valueIter={lastNameIter} onChange={setLastName} />
26+
* </form>
27+
*
28+
* Greetings, <Iterate>{firstNameIter}</Iterate> <Iterate>{lastNameIter}</Iterate>
29+
* </div>
30+
* )
31+
* }
32+
* ```
33+
*
34+
* This is unlike vanila `React.useState` which simply re-renders the entire component. Instead,
35+
* `useAsyncIterState` helps confine UI updates as well as facilitate layers of sub-components that pass
36+
* actual async iterables across one another as props, skipping typical cascading re-renderings down to
37+
* __only the inner-most leafs__ of the UI tree.
38+
*
39+
* The returned async iterable is sharable; it can be iterated by multiple consumers concurrently
40+
* (e.g multiple {@link Iterate `<Iterate>`}s) all see the same yields at the same time.
41+
*
42+
* The returned async iterable is automatically closed on host component unmount.
43+
*
44+
* @template TVal the type of state to be set and yielded by returned iterable.
45+
*
46+
* @returns a stateful async iterable and a function with which to yield an update, both maintain stable
47+
* references across re-renders.
48+
*
49+
* @see {@link Iterate `<Iterate>`}
50+
*/
51+
function useAsyncIterState<TVal>(): AsyncIterStateResult<TVal> {
52+
const ref = useRef<{
53+
channel: IterableChannel<TVal>;
54+
result: AsyncIterStateResult<TVal>;
55+
}>();
56+
57+
if (!ref.current) {
58+
const channel = new IterableChannel<TVal>();
59+
ref.current = {
60+
channel,
61+
result: [channel.iterable, newVal => channel.put(newVal)],
62+
};
63+
}
64+
65+
const { channel, result } = ref.current;
66+
67+
useEffect(() => {
68+
return () => channel.close();
69+
}, []);
70+
71+
return result;
72+
}
73+
74+
/**
75+
* The pair of stateful async iterable and a function with which to yield an update.
76+
* Returned from the {@link useAsyncIterState `useAsyncIterState`} hook.
77+
*
78+
* @see {@link useAsyncIterState `useAsyncIterState`}
79+
*/
80+
type AsyncIterStateResult<TVal> = [AsyncIterable<TVal, void, void>, (newValue: TVal) => void];

0 commit comments

Comments
 (0)