react-seq
v0.9.1
Published
React hooks for working with async generators and promises
Downloads
788
Readme
React-seq
React-seq is a light-weight library that helps you take full advantage of async functions and generators while developing React apps. It provides a set of hooks for managing processes that require time to complete. It's designed for React 18 and above.
Installation
npm install --save-dev react-seq
Basic usage
React only:
import { useState, useEffect } from 'react';
export function Counter() {
const [ count, setCount ] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1);
}, 200);
return () => {
clearInterval(timer);
};
}, []);
return <div>{count} tests passed</div>;
}
React + React-seq:
import { useSequentialState, delay } from 'react-seq';
export function Counter() {
const count = useSequentialState(async function*({ initial }) {
let count = 0;
initial(count++);
do {
await delay(200);
yield count++;
} while (true);
}, []);
return <div>{count} tests passed</div>;
}
The generator version is not only easier to understand, it also allows you to utilize the debugger much more effectively. You can step through the code line by line and easily set conditional breakpoints.
The convinence of React-seq comes at the cost of higher memory usage. It's expected that you would only use it in higher-level components.
Hooks
useSequential
- Return the last element outputted by an async generator functionuseProgressive
- Return an element filled with data from multiple async sourcesuseSequentialState
- Return the last value outputted by an async generator functionuseProgressiveState
- Return an object whose properties are drawn from async sources
Usage scenarios
- Loading of remote data
- Handling page navigation
- Adding transition effects
- Handling authentication
- Managing complex states
Other topics
- Error handling
- Server-side rendering
- Logging
- Unit testing
- ESLint configuration
- Jest configuration
- Browser compatibility
API reference
List of examples
- Payment form
useSequential
- Star Wars API
useProgressive
- WordPress
useProgressive
- Nobel Prize API
useSequentialState
- Star Wars API (alternate implementation)
useSequentialState
useProgressiveState
- WordPress (React Native)
useProgressive
- Star Wars API (server-side rendering)
useProgressive
- NPM Search
useSequentialState
useProgressiveState
- Media capture
useSequentialState
- Transition
useSequential
- Ink CLI
useSequential
Loading of remote data
Retrieval of data from a remote server is probably the most common async operation in web applications. React-seq
lets you accomplish this task through different approaches. You can use useSequential
to construct each part of
a page as data arrives:
import { useSequential } from 'react-seq';
function ProductPage({ productId }) {
return useSequential(async function*({ fallback, defer }) {
fallback(<div className="spinner"/>);
defer(200);
const product = await fetchProduct(productId);
const { default: ProductDescription } = await import('./ProductDescription.js');
yield (
<div className="stage-1">
<ProductDescription product={product} />
</div>
);
const related = await fetchRelatedProducts(product);
const { default: ProductCarousel } = await import('./ProductCarousel.js');
yield (
<div className="stage-2">
<ProductDescription product={product} />
<ProductCarousel products={related} />
</div>
);
const promoted = await fetchPromotedProducts();
yield (
<div className="stage-3">
<ProductDescription product={product} />
<ProductCarousel products={related} />
<ProductCarousel products={promoted} />
</div>
);
/* ... */
}, [ productId ]);
}
You can periodically update the page with the help of an endless loop:
function ProductPage({ productId }) {
return useSequential(async function*({ fallback, defer, manageEvents, flush }) {
fallback(<div className="spinner"/>);
const [ on, eventual ] = manageEvents();
for (let i = 0;; i++) {
defer(i === 0 ? 200 : Infinity);
try {
const product = await fetchProduct(productId);
const { default: ProductDescription } = await import('./ProductDescription.js');
yield (
<div className="stage-1">
<ProductDescription product={product} onUpdate={on.updateRequest} />
</div>
);
const related = await fetchRelatedProducts(product);
const { default: ProductCarousel } = await import('./ProductCarousel.js');
yield (
<div className="stage-2">
<ProductDescription product={product} onUpdate={on.updateRequest} />
<ProductCarousel products={related} />
</div>
);
const promoted = await fetchPromotedProducts();
yield (
<div className="stage-3">
<ProductDescription product={product} onUpdate={on.updateRequest} />
<ProductCarousel products={related} />
<ProductCarousel products={promoted} />
</div>
);
} catch (err) {
if (i === 0) {
throw err;
} else {
// abandon partially rendered page
flush(false);
}
} finally {
await eventual.updateRequest.for(5).minutes;
}
}
/* ... */
}, [ productId ]);
}
The example above demonstrates the use of React-seq's event manager. It's a key component
of the library. Its automatically generated promises and handlers connect your user interface with
your async code. Here, it allows the user to manually trigger an update: Calling on.updateRequest
causes the
fulfillment of the eventual.updateRequest
promise. That releases the generator function from the await
operation
inside the finally block.
Since data loading happens so frequently in web applications, React-seq provides a specialized hook:
useProgressive
. It works as a sort of async-to-sync translator, turning promises
and generators into ordinary objects and arrays.
Consider the following component taken from the Star Wars API example:
export default function Character({ id }) {
return useProgressive(async ({ type, defer, suspend, signal }) => {
type(CharacterUI);
defer(200);
suspend(`character-${id}`);
const person = await fetchOne(`people/${id}`, { signal });
return {
person,
films: fetchMultiple(person.films, { signal }),
species: fetchMultiple(person.species, { signal }),
homeworld: fetchOne(person.homeworld, { signal }),
vehicles: fetchMultiple(person.vehicles, { signal }),
starships: fetchMultiple(person.starships, { signal }),
};
}, [ id ]);
}
(Note the lack of await
in front of many of the fetch calls.)
The object returned by the callback would contain this:
{
person: [object],
films: [async generator],
species: [async generator],
homeworld: [promise],
vehicles: [async generator],
starships: [async generator]
}
The component CharacterUI
would receive this as props:
{
person: [object],
films: [array],
species: [array],
homeworld: [object],
vehicles: [array],
starships: [array]
}
The various arrays would grow over time as data is retrieved from the remote server.
It's possible to create a generator that pauses and resumes on user action. The WordPress example shows how this can be used to implement an infinite-scrolling article list. The React Native version of the example is also worth checking out.
Besides using useSequential
and useProgressive
, you can
also load data with the help of React-seq's state hooks: useSequentialState
and useProgressiveState
. Instead of React elements, these hooks return simple values
(usually objects). They are useful for components that handle user input. For example:
function SearchBar() {
const categories = useSequentialState(async function*({ signal }) {
initial([]);
const res = await fetch('/api/categories/', { signal });
yield res.json();
}, []);
const [ query, setQuery ] = useState('');
const [ category, setCategory ] = useState('');
return (
<div className="SearchBar">
<input type="text" value={query} onChange={evt => setQuery(evt.target.value)} />
<select value={category} onChange={evt => setCategory(evt.target.value)} >
{categories.map(c => (
<option value={c.id}>{c.name}</option>
))}
</select>
</div>
);
}
State hooks are also the right ones to use generally when you are creating custom hooks. Consult the alternate implementation of the Star Wars API example to learn more about the advantages and drawbacks.
Handling page navigation
You can use a useSequential
hook to handle page navigation for you app in the following manner:
export default function App() {
const [ parts, query, { trap, throw404, isDetour } ] = useSequentialRouter();
return useSequential(async function* (methods) {
const { reject, manageEvents } = methods;
trap('detour', (err) => {
reject(err);
return true;
});
const [ on, eventual ] = manageEvents();
for (;;) {
try {
if (parts[0] === 'products') {
if (!parts[1]) {
const { default: ProductList } = await import('./ProductList.js');
yield <ProductList onSelect={on.selection} />;
const { selection } = await eventual.selection;
parts[1] = selection;
} else {
const { default: ProductDetails } = await import('./ProductDetails.js');
yield <ProductDetails id={parts[1]} onReturn={on.return} />;
await eventual.return;
parts.splice(1);
}
} else if (parts[0] === 'news') {
/* ... */
} else if (parts[0] === 'notifications') {
/* ... */
} else {
throw404();
}
} catch (err) {
if (isDetour(err)) {
err.proceed();
} else {
yield <ErrorPage error={err} onRetry={on.retry} />;
await eventual.retry;
}
}
}
}, [ parts, query, trap, isDetour ]);
}
The example uses Array-router, a companion solution designed to work well with React-seq. It's a minimalist "router" that turns the browser location into an array of path parts and an object containing query variables. Actual routing is done using JavaScript control structures.
The code above follows the Yield-Await-Promise model. We output some user-interface elements then wait for a user response. We then act upon that response, causing the displaying of a different page.
Our code is in full control of navigation. It alters the route components and interprets them. Clicks on hyperlinks and use of the browser's back/forward buttons are treated as exceptions. If a detour request manages to reach the top-level try-catch block, it gets approved.
React-seq can handle nested generators. useSequential
will in effect flatten the generator
provided by its callback. That allows us to break a long generator functions into more manageable subroutines:
export default function App() {
const [ parts, query, { trap, throw404, isDetour } ] = useSequentialRouter();
return useSequential(async function* (methods) {
const { reject, manageEvents } = methods;
trap('detour', (err) => {
reject(err);
return true;
});
const [ on, eventual ] = manageEvents();
for (;;) {
try {
if (parts[0] === 'products') {
yield handleProductSection(parts, query, methods);
} else if (parts[0] === 'news') {
yield handleNewsSection(parts, query, methods);
} else if (parts[0] === 'notifications') {
yield handleNotificationSection(parts, query, methods);
} else {
throw404();
}
} catch (err) {
if (isDetour(err)) {
err.proceed();
} else {
yield <ErrorPage error={err} onRetry={on.retry} />;
await eventual.retry;
}
}
}
}, [ parts, query, trap, isDetour ]);
}
async function *handleProductSection(parts, query, methods) {
const { manageEvents } = methods;
const [ on, eventual ] = manageEvents();
try {
for (;;) {
if (!parts[1]) {
const { default: ProductList } = await import('./ProductList.js');
yield <ProductList onSelect={on.selection} />;
const { selection } = await eventual.selection;
parts[1] = selection;
} else {
const { default: ProductDetails } = await import('./ProductDetails.js');
yield <ProductDetails id={parts[1]} onReturn={on.return} />;
await eventual.return;
delete parts[1];
}
}
} finally {
// do clean-up here
}
}
Since there is no break
or return
inside the loop, the only way to exit the product section is via an exception
(probably a detour request). When that happens, code in the finally block will run. If the function had made changes
specific to this section, it can roll them back here.
Please note that certain implementation details are left out of the code snippets above for brevity sake. Consult the transition example for something that better represent a fully-developed, real-world solution. You'll also find further discussion on the Yield-Await-Promise model.
Adding transition effects
React-seq's ability to handle nested async generator lends itself nicely to performing page transition. All we would need is a function that takes an element and returns a generator producing the right sequence. It can output anything. The only requirement is that the last item coming from the generator is the next page.
Suppose you want to fill the screen with a fire ball when the user navigates to a different page (because it's a web site for fans of professional wrestling?). The first item of the transition sequence would be the current page overlaid with a clip of an explosion. Once the clip has played to some midway point where the screen is fully engulfed in flame, the generator outputs the next page overlaid with the same clip. Finally, when the clip has reached its end, the generator outputs only the next page.
Thanks to the syntactic sugar provided by React-seq's event manager, orchestrating such a sequence is quite easy.
To see the concept described here in action, check out the transition example.
Handling authentication
The ability to insert whole sequence of pages anywhere also makes it rather easy to handle authentication. If a particular section of our app is restricted to logged-in users only, all we would have to do is place a line atop that section:
yield handleLogin(methods);
handleLogin
might look something like this:
async function* handleLogin({ manageEvents }) {
if (isAuthenticated()) {
return;
}
const [ on, eventual ] = manageEvents();
const { default: ScreenLogin } = await import('./screens/ScreenLogin.js');
let error;
for (;;) {
yield <ScreenLogin onSubmit={on.credentials} lastError={error} />;
const { credentials } = await eventual.credentials;
try {
await authenticateUser(credentials);
return;
} catch (err) {
error = err;
}
}
}
A call to this function might also happen in the app's catch block, to deal with expired user session:
} catch (err) {
if (err instanceof HTTPError && err.status === 401) {
yield handleLogin(methods);
} else {
yield <ScreenError error={err} onRetry={on.retryRequest} />
await eventual.retryRequest;
}
}
If the user manages to log in, that means our catch block has successfully resolved the error. The loop sends the user back to where he was before (since the route hasn't changed) and whatever being done there would no longer cause an error. Everything works as it should.
Managing complex states
State management using an async generator function is generally much easier, even when no inherently async operations like data retrieval are involved. First of all, you have a variable scope that persists over time. When you need to remember something, just set a local variable. At the same time the number of states that need be tracked are sharply reduced thanks to async functions' ability to halt mid-execution. Consider the following example. It's a hook that listens for a sequence of keystrokes matching the well-known Konami code:
function useKonamiCode() {
return useSequentialState(async function*({ initial, mount, manageEvents, signal }) {
initial(false);
await mount();
const [ on, eventual ] = manageEvents();
window.addEventListener('keydown', on.key.filter(e => e.key), { signal });
while (!(
await eventual.key.value() === 'ArrowUp'
&& await eventual.key.value() === 'ArrowUp'
&& await eventual.key.value() === 'ArrowDown'
&& await eventual.key.value() === 'ArrowDown'
&& await eventual.key.value() === 'ArrowLeft'
&& await eventual.key.value() === 'ArrowRight'
&& await eventual.key.value() === 'ArrowLeft'
&& await eventual.key.value() === 'ArrowRight'
&& await eventual.key.value() === 'b'
&& await eventual.key.value() === 'a'
));
yield true;
}, []);
}
Notice how there isn't an array holding the keys that the user has pressed. No number indicating which portion of the sequence has matched thus far either. There are no progress-tracking variables. We don't need them because the JavaScript engine is tracking progress for us. It knows where in the code it has stopped.
Note also the use of signal
in the call to
addEventListener
. That eliminates
the need to call removeEventListener
.
For further demonstration of how React-seq can help you manage state, please consult the media capture example.
Error handling
Errors encountered by React-seq hooks will trigger component updates and get rethrown during React's rendering cycle, allowing them to be handled by an error boundary further up the component tree.
When you employ the Yield-Await-Promise model, you can funnel errors through the generator tree with the help of
reject
. Example:
function App() {
return useSequential(async function*(methods) {
const { manageEvents, wrap, reject } = methods;
const [ on, eventual ] = manageEvents();
// create error boundary around contents
wrap(children => <ErrorBoundary onError={reject}>{children}</ErrorBoundary>);
wrap(children => <AppFrame>{children}</AppFrame>);
let section = 'news';
for (;;) {
try {
if (section === 'news') {
// handle news section in a separate function
yield handleNewsSection(methods);
} else if (page === 'products') {
yield handleProductSection(methods);
}
} catch (err) {
yield <ErrorPage error={err} onRetry={on.retryRequest} />;
await eventual.retryRequest;
}
}
}, []);
}
async function *handleNewsSection({ wrap, manageEvents }) {
const [ on, eventual ] = manageEvents();
const unwrap = wrap(children => <NewsSection>{children}</NewsSection>);
let articleId;
try {
for (;;) {
try {
if (!articleId) {
try {
yield <ArticleList onSelect={on.selection} />;
// wait for an article to be selected
articleId = await eventual.selection.value();
} catch (err) {
// handle specific errors from ArticleList
}
} else {
/* ... */
}
} catch (err) {
if (err instanceof CMSError) {
// handle error specific to section
} else {
throw err;
}
}
}
} finally {
unwrap();
}
}
function ArticleList() {
return useProgressive(async ({ type, usable, manageEvents, signal }) => {
type(ArticleListUI);
usable({ articles: 1 });
const [ on, eventual ] = manageEvents();
const options = { signal };
const articles = fetchAll(() => eventual.needForMore, options);
const authors = fetchAuthors(articles, options);
const categories = fetchCategories(articles, options);
const tags = fetchTags(articles, options);
const media = fetchFeaturedMedia(articles, options);
return { articles, authors, categories, tags, media, onBottomReached: on.needForMore };
}, []);
}
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { error: null, fresh: false };
}
static getDerivedStateFromProps(props, state) {
const { error, fresh } = state;
if (fresh) {
// render() needs to see this--clear it next time
return { error, fresh: false };
} else {
// clear stale error
return { error: null };
}
}
static getDerivedStateFromError(error) {
return { error, fresh: true };
}
render() {
let { error } = this.state;
if (error) {
// keep rendering if the error was patched up somehow
if (this.props.onError(error) === true) {
error = null;
}
}
return !error ? this.props.children : null;
}
}
Suppose the call to fetchTags
in ArticleList
causes a 404 Not Found error to be thrown. This error will first
bubble through the React component tree as shown in this diagram (stage 1):
When it reaches the error boundary, it teleports to the spot of the current await operation. From there it bubbles
through three try blocks (stage 2). As none of these would stop it, it pops through handleNewsSection
and
finally gets caught by the catch block in App
(stage 3).
Basically, errors will resurface at (or close to) the decision points that led eventually to their occurrence.
From the perspective of handleNewsSection
, yielding ArticleList
is what caused the error. It pops up on the very
next line. Action followed by consequence. Intuitively, this is how we expect exception handling would work. And in
general, the code responsible for triggering an error is in the best position to make decisions on how it should be
handled.
Of course, not much can be done about a fatal error like a 404 aside from putting up an error message. In the transition example, you'll find a scenario where catching an error in a local try-catch block actual makes sense.
Server-side rendering
React-seq has built-in support for a simple kind of server-side rendering (SSR), where server-generated HTML is essentially used as an app's fallback screen. Only a single function call is needed:
fastify.get('/*', async (req, reply) => {
reply.type('text/html');
const location = `${req.protocol}://${req.hostname}/${req.params['*']}`;
return renderInChildProc(location, buildPath);
});
renderInChildProc
will generate the page using the app's production build.
No changes to your project's build configuration required. You only need to enable
hydration and render-to-server in your app's
boot-strap code:
import App from './App.js';
import { hydrateRoot, renderToServer } from 'react-seq/client';
if (typeof(window) === 'object') {
hydrateRoot(document.getElementById('root'), <App />);
} else {
renderToServer(<App />);
}
To see SSR in action, clone the repository and run the Star Wars API SSR example.
Logging
React-seq provides a mean for you to examine what happens inside its hooks. When a hook detects the presence of
an InspectorContext
, it will start reporting events to the given inspector
instance.
The library comes with two built-in inspectors: ConsoleLogger
and
PromiseLogger
. You can create you own by extending Inspector
.
The Payment form example makes use of ConsoleLogger
:
export default function App() {
const logger = useMemo(() => new ConsoleLogger(), []);
return (
<div className="App">
<header className="App-header">
<p>Payment Page Example</p>
</header>
<InspectorContext.Provider value={logger}>
<PaymentPage />
</InspectorContext.Provider>
</div>
);
}
Unit testing
For the purpose of unit testing React-seq provides two functions:
withTestRenderer
and withRestDOM
.
One utilizes React Test Renderer while the other relies on the DOM.
They have the same interface.
The following is a test case from the Payment form example:
import { withTestRenderer } from 'react-seq/test-utils';
import { PaymentPage } from './PaymentPage.js';
import { PaymentSelectionScreen } from './PaymentSelectionScreen.js';
import { PaymentMethodBLIK } from './PaymentMethodBLIK.js';
import { PaymentProcessingScreen } from './PaymentProcessingScreen.js';
import { PaymentCompleteScreen } from './PaymentCompleteScreen.js';
test('payment with BLIK', async () => {
await withTestRenderer(<PaymentPage />, async ({ awaiting, showing, shown, resolve }) => {
expect(showing()).toBe(PaymentSelectionScreen);
expect(awaiting()).toBe('selection');
await resolve({ selection: { name: 'BLIK', description: 'Payment using BLIK' } });
expect(showing()).toBe(PaymentMethodBLIK);
expect(awaiting()).toBe('submission.or.cancellation');
await resolve({ submission: { number: '123 456' } });
expect(shown()).toContain(PaymentProcessingScreen);
expect(showing()).toBe(PaymentCompleteScreen);
expect(awaiting()).toBe(undefined);
});
});
withTestRenderer
renders the component and awaits the first stoppage point. A stoppage point is either the
termination of a hook's generator or an await
on a promise of the event manager. When one of the two occurs,
the callback is invoked. The test code can then check whether the expected outcome has been achieved then force
the component to move to the next stoppage point by manually settling the awaited promise.
ESLint configuration
Add "react-seq" to your ESLint settings to enable the linting of React-seq hooks:
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest",
"react-seq"
]
},
You will find the eslintConfig
section in your project's package.json
if it was created using Create React App.
Jest configuration
Add the following to your project's package.json
so Jest would transpile the library:
"jest": {
"transformIgnorePatterns": [
"!node_modules/react-seq/"
]
},
Browser compatibility
React-seq makes use of JavaScript proxy. According to Mozilla, it is available in the following environment:
Since the functionality in question cannot be polyfilled, React-seq does not work in any version of Internet Explorer or Opera Mini.