Skip to content

React.js hooked up with the magic of JavaScript async iterators: hooks · components · utilities ⚛️ ⛓️ 🧬

License

Notifications You must be signed in to change notification settings

shtaif/react-async-iterators

Repository files navigation

React Async Iterators

Hooks, components and utilities for working with JavaScript async iterator values in React.js.


npm published version semantic-release

Async iterables/iterators are a native language construct in JS that can be viewed as a counterpart to Promise, in the sense that while a promise asynchronously resolves one value - an async iterable is a stream that asynchronously yields any number of values.

Somewhat obvious to say, the React ecosystem features many methods and tools that have to do with integrating promise-based data into your React components; from higher level SDK libraries, state managers - to generic async utilities, which make the different promise states available to the rendering. And just like that - react-async-iterators packs hooks, components and utilities written in TypeScript with the aim to make async iterables into first-class citizens to React as they become gradually more prevalent across the JavaScript platform.

What can react-async-iterators be used for?

  • easily consuming async iterables obtained from any library, web API or composed manually - in a React-friendly declarative fashion.
  • unlock new ways of expressing data flow in or between components efficiently, constricting redundant re-rendering.

Illustration:

import { It } from 'react-async-iterators';

const randoms = (async function* () {
  while (true) {
    await new Promise(r => setTimeout(r, 1000));
    const x = Math.random();
    yield Math.round(x * 10);
  }
})();

// and then:

<It>{randoms}</It>

// renders: '2'... '1'... '3'... etc.

// OR:

<It value={randoms}>
  {next => (
    next.pendingFirst
      ? 'Loading first...'
      : <p>{next.value.toExponential()}</p>
  )}
</It>

// renders:
//   'Loading first...'
//   <p>2e+0</p>...
//   <p>1e+0</p>...
//   <p>3e+0</p>...
//   etc.

Highlights

✔️ Fully written in TypeScript with comprehensive inferring typings
✔️ Fully tree-shakeable exports
✔️ Light weight, zero run-time dependencies
✔️ ESM build
✔️ Semver compliant

Table of Contents

Installation

# With npm:
npm i react-async-iterators

# With pnpm:
pnpm i react-async-iterators

# With Yarn:
yarn add react-async-iterators

Can then be imported as follows (TypeScript/ESM style):

import { It, type IterationResult } from 'react-async-iterators';

Walkthrough

Consuming async iterables

Async iterables can be hooked into your components and consumed using <It> and <ItMulti>, or their hook version counterparts useAsyncIter and useAsyncIterMulti respectively.

The iteration values and states are expressed via a consistent structure (more exaustive list in this breakdown).
They may be accessed as follows:

const myIter = getSomeIter(); // given some `myIter` async iterable

With <It>:

import { It } from 'react-async-iterators';

<It value={myIter} initialValue="first_value">
  {next => {
    next.pendingFirst; /* -> whether we're still waiting for the first value yielded
                             from `myIter`, analogous to a promise's pending state. */

    next.value; /* -> the most recent value yielded. If `pendingFirst` is `true`,
                      we should see the last value carried over from the previous
                      iterable before `myIter` (otherwise fall back to "first_value"
                      if we've just been mounted) */

    next.done; /* -> whether the iteration of `myIter` has finished (will yield no
                     further values) */
    
    next.error; /* -> if the iterated async iterable threw an error, this will be set
                      to it along with `done` showing `true` */
  }}
</It>

With useAsyncIter:

import { useAsyncIter } from 'react-async-iterators';

const next = useAsyncIter(myIter, 'first_value');

// (Properties are identical to the above...)
next.pendingFirst;
next.value;
next.done;
next.error;

Using the component form may be typically preferrable over the hook form (e.g <It> over useAsyncIter) - Why? because using it, when changes in data occure - the re-rendered UI area within a component tree can be declaratively narrowed to the necessary minimum, saving other React elements that do not depend on its values from re-evaluation. On the the other hand - useAsyncIter, being a hook, must re-render the entirety of the host component's output for every new value.

When segregating data flows and relationships across the components' render code like this, using the component forms - it makes for a more managable code, and might get rid of having to akwardly split components down to smaller parts just to render-optimize them when it otherwise wouldn't "feel right" to do so.

Plain values

All of the consuming hooks and components also accept plain ("non-iterable") values safely, rendering them as-are with very low extra overhead - their inputs may alternate between async iterable and plain values at any time.

When rendering a plain value, the iteration state properties behave alternatively like so:

  • .value reflects the plain value as-is
  • .pendingFirst and .done are ALWAYS false
  • .error is ALWAYS empty


ℹ️ When providing a plain value right upon mounting - the initial value, if given, is ignored.


Showing <It> being used with either plain or async iterable values, wrapped as a custom component:

import { It, type MaybeAsyncIterable } from 'react-async-iterators';

function Foo(props: {
  value: MaybeAsyncIterable<string>;
}) {
  return (
    <It value={props.value}>
      {next => (
        /* ... */
      )}
    </It>
  );
}

// Later:

<Foo value="my_value" />
// or:
<Foo value={myStringIter} />


⬆️ Note use of the MaybeAsyncIterable convenience type


One use for this among others is an ability for a certain async-iterable-fed UI piece to be pushed some alternative "placeholder" value at times there isn't an actual async iterable available to feed it with.

Another implication of this conveniency is the possibility to design apps and component libraries that can receive data expressable in both "static" and "changing" fashions - seamlessly. If a certain component has to be given a string value prop, but you happen to (or wish to) only have an async iterable of strings at hand - why shouldn't you be able to pass just that onto the same prop and it would just work as expected - self updating whenever the next string is yielded? Async iterables are standard JavaScript after all.

Iteration lifecycle

When rendering an async iterable with any of the consuming component/hooks, they immediately begin iterating through it value-by-value.

The current active iteration is always associated to the particular value that was given into the consumer component or hook, such that re-rendering the consumer again and again with a reference to the same object will keep the same active iteration running persistingly in a React-like fashion (similar to React.useEffect not re-running until its dependencies are changed).

Whenever the consumer receives a new value to iterate, it will immediately dispose of the current running iteration (calling .return() on its held iterator) and proceed iterating the new value in the same manner as before.

Finally, when the consumer is unmounted, the current running iteration is disposed of as well.

Iteration lifecycle phases

The following phases and state properties are reflected via all consumer utilities (with hooks - returned, with components - injected to their given render functions):

Phase Description

1. Initial phase;

a single occurrence of:

{
  pendingFirst: true,
  value: INITIAL_VALUE,
  done: false,
  error: undefined
}

...if input is an async iterable with no current value -
Otherwise, phase is skipped.

⬇️

2. Yields phase;

...zero or more rounds of:

{
  pendingFirst: false,
  value: VALUE,
  done: false,
  error: undefined
},
// ...
// ...
⬇️

3. Ending phase;

{
  pendingFirst: false,
  value: PREVIOUS_RECENT_VALUE,
  done: true,
  error: POSSIBLE_ERROR
}

with error property being non-empty - ending due to source throwing an error
or
with error property being undefined - ending due to completion - source is done.

🔃 Repeat when changing to new source value 🔃

Async iterables with current values

Throughout the library there is a specially recognized case (or convention) for expressing async iterables with a notion of a "current value". These are simply defined as any regular async iterable object coupled with a readable .value.current property.

When any consumer hook/component from the library detects the presence of a current value (.value.current), it can render it immediately and skip the isPending: true phase, since this effectively signals there is no need to wait for a first yield - the value is available already.

This rule bridges the gap between async iterables which always yield asynchronously (as their yields are wrapped in promises) and React's component model in which render outputs are strictly synchronous. Normally, if for example the first value for an async iterable is known in advance and yielded as soon as possible - React could only grab the yielded value from it via an immediate subsequent run/render of the consumer hook/component (since the promise can resolve only after such initial sync run/render). This issue is therefore solved by async iterables that expose a current value.

For example, the stateful iterable created from the useAsyncIterState hook (see Component state as an async iterable) applies this convention from its design, acting like a "topic" with an always-available current value that's able to signal out future changes, skipping pending phases so there is no need to manage setting any initial starting states.

Formatting values

When building your app with components accepting async iterable data as props, as you render these and have to provide such props - you may commonly see a need to re-format held async iterables' value shapes before they're passed in those props, in order for them to match the expected shape. iterateFormatted is an easy-to-use approach to many cases like this.

For instance, let's say we're trying to use an existing <Select> generic component, which supports being provided its option list in async iterable form, so it could update its rendered dropdown in real-time as new sets of options are yielded. It is used like so;

<Select
  options={
    // EXPECTING HERE AN ASYNC ITER YIELDING:
    // {
    //   value: string;
    //   label: string;
    // }
  }
/>

Now, we would like to populate <Select>'s dropdown with some currency options from an async iterable like this one:

const currenciesIter = getAvailableCurrenciesIter();
// THIS YIELDS OBJECTS OF:
// {
//   isoCode: string;
//   name: string;
// }

As apparent, the value types between these two are not compatible (properties are not matching).

By using iterateFormatted, our source iterable can be formatted/transformed to fit like so:

const currenciesIter = getAvailableCurrenciesIter();

function MyComponent() {
  return (
    <div>
      <Select
        options={iterateFormatted(currenciesIter, ({ isoCode, name }) => ({
          value: isoCode,
          label: `${name} (${isoCode})`
        }))}
      />
    </div>
  );
}

Alternatively, such transformation can be also achieved (entirely legitimately) with help from React.useMemo and some generic mapping operator like iter-tools's asyncMap, among the multitude of available operators from such libraries:

import { useMemo } from 'react';
import { execPipe as pipe, asyncMap } from 'iter-tools';

function MyComponent() {
  const formattedCurrenciesIter = useMemo(
    () =>
      pipe(
        getAvailableCurrenciesIter(),
        asyncMap(({ isoCode, name }) => ({
          value: isoCode,
          label: `${name} (${isoCode})`
        }))
      ),
    []
  );

  return (
    <div>
      <Select options={formattedCurrenciesIter} />
    </div>
  );
}


ℹ️ Every calls to iterateFormatted returns a formatted versions of currenciesIter with some transparent metadata used by library's consumers (like <It>) to associate every transformed iterable with its original source iterable so existing iteration states can be maintained. It's therefore safe to recreate and pass on formatted iterables from repeated calls to iterateFormatted across re-renders (as long the same source is used with it consistently).

So unless you require some more elaborate transformation than simply formatting values - it might be more ergonomic to use iterateFormatted vs manual compositions within React.useMemo, especially if dealing with multiple iterables to transform.

Component state as an async iterable

As illustrated throughout this library and docs - when dealing with data in your app that's presented as an async iterable, an interesting pattern emerges; instead of a transition in app state traditionally sending down a cascading re-render through the entire tree of components underneath it to propagate the new state - your async iterable objects can be distributed once when the whole tree is first mounted, and when new data is then communicated through them it directly gets right to the edges of the UI tree that are concerned with it, re-rendering them exclusively and thus skipping all intermediaries.

The packaged useAsyncIterState hook can lend this paradigm to your component state. It's like a React.useState version that returns you an async iterable of the state value instead of the state value, paired with a setter function that causes the stateful iterable to yield the next states.

The stateful iterable may be distributed via props down through any number of component levels the same way you would with classic React state, and used in conjunction with <It> or useAsyncIter, etc. wherever it has to be rendered.

In a glance, it's usage looks like this:

import { useAsyncIterState, It } from 'react-async-iterators';

function MyCounter() {
  const [countIter, setCount] = useAsyncIterState(0);

  function handleIncrement() {
    setCount(count => count + 1);
  }

  return (
    <>
      Current count: <It>{countIter}</It> {/* <- this is the ONLY thing re-rendering here! */}
      <button onClick={handleIncrement}>Increment</button>
    </>
  );
}

The stateful iterable let's you directly access the current state any time via its .value.current property (see Async iterables with current values) so you may read it when you need to get only the current state alone, for example - as part of a certain side effect logic;

// Using the state iterable's `.value.current` property to read the immediate current state:

import { useAsyncIterState, It } from 'react-async-iterators';

function MyForm() {
  const [firstNameIter, setFirstName] = useAsyncIterState('');
  const [lastNameIter, setLastName] = useAsyncIterState('');

  return (
    <form
      onSubmit={() => {
        const firstName = firstNameIter.value.current;
        const lastName = lastNameIter.value.current;
        // submit `firstName` and `lastName`...
      }}
    >
      Greetings, <It>{firstNameIter}</It> <It>{lastNameIter}</It>

      {/* More content... */}
    </form>
  );
}

Iteration state properties breakdown

The following iteration state properties are common for all consumer utilities, with hooks - returned, with components - injected to their given render functions:

Property Description
.pendingFirst Boolean indicating whether we're still waiting for the first value to yield.
Can be considered analogous to the promise pending state.

** Is always false if source is a plain value instead of an async iterable.
.value The most recent value yielded.
If we've just started consuming the current iterable (while pendingFirst is true), the last value from a prior iterable would be carried over. If there is no prior iterable (the hook/component had just been mounted) - this will be set to the provided initial value (undefined by default).

** If source is otherwise a plain value and not an async iterable - this will be itself.
.done Boolean indicating whether the async iterable's iteration has ended, having no further values to yield.
This means either of:
  1. it has completed (by resolving a { done: true } object, per async iteration protocol)
  2. it had thrown an error (in which case the escorting error property will be set to that error).
When true, the adjacent value property will __still be set__ to the last value seen before the moment of completing/erroring.

** Is always false if source is a plain value instead of an async iterable.
.error Indicates whether the iterated async iterable threw an error, capturing a reference to it.
If error is non-empty, the escorting done property will always be true because the iteration process has effectively ended.

** Is always undefined if source is a plain value instead of an async iterable.

License

Free and licensed under the MIT License (c) 2024