Stream updates to React components from ES6 generators
YSU is an experimental* library to manage asynchronous state in React. It stands for yield sequential updates, which describes the process of streaming updates to components from generators.
*minimal test coverage and not yet used in production, please use with caution.
import { update, sequence } from "ysu";
// sequence --
async function* randomQuoteSequence() {
yield update("LOADING");
try {
const res = await fetch("https://programming-quotes-api.herokuapp.com/quotes/random");
const data = await res.json();
yield update("READY", data);
} catch (error) {
yield update("FAILED", error);
}
}
// component --
const RandomQuote = props => {
const [quote, getQuote] = props.randomQuote;
useEffect(() => {
getQuote();
}, []);
return (
<>
{quote.status === "LOADING" && <p>Loading...</p>}
{quote.status === "READY" && <p>{quote.payload.en}</p>}
{quote.status === "FAILED" && <p>{quote.payload.message}</p>}
<button onClick={getQuote}>Get another quote</button>
</>
);
};
export default sequence({
randomQuote: randomQuoteSequence
})(RandomQuote);
The component is connected to the generator using the sequence
higher-order component. When(ever) the generator yields an update
the component (re)renders.
Each field passed to sequence
is mapped to a prop which contains a pair. In this example the prop randomQuote
holds the pair:
[quote,
reflects the current status of the sequence, along with any data associated with that status.getQuote]
a function that when called initiates the sequence.
The randomQuote
sequence starts when the component mounts, or when the user clicks the Get another quote button. This is a fairly straightforward example, however other examples with live demos are linked below.
The examples can be viewed online, or alternatively can be run locally by cloning the repository and running npm install
and then npm start
.
- Remote Data Fetching Similar to the basic example above (demo | code).
- Polling Endpoint is polled every N seconds, where the user can change the frequency of, or pause and resume polling (demo | code).
- Retry Request Retries an XHR request until a certain condition is met, or number of retries exceeds 5 attempts (demo | code).
- Aggregation Render data from multiple endpoints only when all datasets are ready (demo | code).
- Race Time constraign a request to 2 seconds so that the UI isn't blocked on slow internet connections (demo | code).
- User Journey Form in which the user has 5 seconds to either confirm or cancel submission (demo | code).
- Undo After submitting a form the user can change their mind by clicking undo (demo | code).
- Debounce Run a remote search query once the user has stopped typing for 2 seconds (demo | code).
- Composition Compose a sequence from multiple generators (demo | code).
DevTools with time travel π
Baked-in logger
Middleware support
Subscribe to a stream of updates for third-party interactions such as logging and error reporting.
Much of what makes UI programming difficult is managing values that change over time. If we take the sterotypical async example of making an API request, the UI reflects a sequence of state changes.
The state starts off idle β then goes to loading* β then finishes with success or failed**.
*usually triggered on component mount / user interaction. **depending on the API response.
Our API request has three sequential stages with four possible statuses.
Async generators (async function*
) are very good at coordinating sequences since they can be paused, exited and resumed.
This means we can do something asynchronous β yield an update to the UI β re-enter to resume where we left off β do something else β yield another update to the UI β and so forth.
The API request can be represented using a sequence diagram:
Time | Component | Generator |
---|---|---|
β | initiate sequence ------> | |
β | <------ yield "loading" | |
β | loading... | call API |
β | β success | <------ yield "success" on resolve |
β | βerror | <------ yield "failed" on reject |
You may have noticed this sequence diagram depicts exactly what is happening in the basic example shown earlier.
Since yielding from a generator triggers a (re)render in the UI, and generators can generate values forever, implementing infinite and finite sequences such as polling or retries is trivial.
Polling is as simple as calling an endpoint and yielding an update to the UI within an infinite loop. Whilst retrying an XHR request does the same but from within a finite loop.
Note: on component unmount any running sequences are stopped and scheduled updates cancelled automatically. This ensures that infinite sequences (such as polling) only run when the component is mounted.
- Keep asynchronous state out of components
- Don't block rendering (always be ready to render)
- Component state over global state
- Same API for function and class components
- Predictable rendering (1 yield equals 1 render)
- Simple mental model (components just recieve props)
- Baked-in status enums
- Decorator approach popularised by Redux
- Pair approach popularised by hooks
YSU exports the following functions:
import { sequence, update, pause } from "ysu";
Higher-order component that connects a component to one or more generators:
sequence(generatorMap, middleware?)(Component);
Object that maps generator functions to component props:
sequence(
{
foo: fooSequence,
bar: barSequence
}
)(Component);
In this example fooSequence
will be available in the component via the prop foo
. Each prop (such as foo and bar) is an array containing 3 items:
const [value, initiator, goodies] = props.foo;
value
Object containing:status
String: the status of the sequence (i.e:"LOADING"
)payload?
Any: data associated with the current status (i.e:{ userName: "@chart" }
)
initiator
Function: that starts the sequencegoodies
Object containing:devTools
Component: that renders the history of the sequence. Note: the history is transient and destroyed when the component unmounts.suspend
Function: that stops the sequence and cancels any scheduled updates (i.e: click button to stop polling)
One or more functions that are subscribed to the sequence. They are called whenever a generator yields an update:
sequence(
{
foo: fooSequence,
bar: barSequence
},
trackingMiddleware,
errorMiddleware,
// as many middlewares you desire
)(Component);
function trackingMiddleware({ status, payload, meta }) {
if (status === "SUCCESS") {
// track conversion
}
}
function errorMiddleware({ status, payload, meta }) {
if (status === "FAILED") {
// log error
}
}
Each middleware function is passed an object containing:
status
String: the status of the sequencepayload?
Any: data associated with the current statusmeta
Object containing:sequenceId
String: corresponds to the name of the prop (i.e:foo
orbar
)
Note: middleware is colocated with components since sequences could have different tracking or error logging requirements depending on where theyβre used.
Pure function that describes a step in a sequence:
yield update(status, payload?);
status
String: the status of the sequencepayload?
Any: data associated with the current status
Returns an object containing { status, payload? }
.
Helper function that pauses a sequence for a given amount of time:
await pause(delay);
delay
Number: how long in milliseconds the sequence should be paused
Returns a Promise.
Only include devTools and logger in development bundles.
Expose back/forward functions for UIs with undo/redo:
const [quote, getQuote, { goBack, goForward }] = props.quoteSequence;
Persist context between yields. This would be useful when composing multiple sequences:
// sequence --
yield update("READY", { rates }, { persist: true });
// ...yields from other generators...
// component --
const [{ status, payload }, transition, { context }] = props.currencyConverter;
<>{context.rates}</> // rates available in context for any statuses that follow READY
Automatically suspend a running sequence when new sequence is initiated.
Investigate how YSU would integrate with React suspense and concurrent mode.
In-built cache and sequence deduplication.
Hooks alternative to higher-order component API:
const [quote, getQuote, goodies] = useYSU(randomQuoteSequence);
// with unique key for in-built cache: `useYSU('key', randomQuoteSequence)`
Move devTools and logger out into separate package @ysu/devtools
.
- Test coverage π§ͺ
- Visual cues in devTools when sequence:
- is running ππ»ββοΈ
- has been suspended βΈ
- Include errors in devTools
β οΈ - Ability to suspend sequence from devTools π
- Consistent colours in logger and devTools π
- Only publish library code to npm π’
- Rename history to devTools π€
- Fix unmount memory leak π¦
MIT