Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 53 additions & 44 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,18 @@

<!--OVERVIEW-->

Fetch data, run async tasks, and keep your UI in sync — without the boilerplate. React Executor replaces ad-hoc
`useState`/`useEffect` data-fetching patterns with a composable, plugin-driven model that works with Suspense and SSR
out of the box.
Asynchronous task execution and state management for React.

- TypeScript first.
- Expressive and concise API with strict typings.
- Works great with SSR and Suspense.
- [Extensible with plugins.](#plugins)
- [First class devtools.](#devtools)
- [Just 5&#8239;kB gzipped.](https://pkg-size.dev/react-executor)
- [Just 3&#8239;kB gzipped.](https://bundlephobia.com/package/react-executor)
- Check out the [Cookbook](#cookbook) for real-life examples!

<!--/OVERVIEW-->

> [!TIP]
> New here? Skip straight to the [Cookbook](#cookbook) for real-world patterns including polling, optimistic updates,
> pagination, and dependent tasks.

<br>

```sh
Expand All @@ -42,11 +36,6 @@ npm install --save-prod react-executor

<!--TOC-->

- [API docs](https://smikhalevski.github.io/react-executor/)
- [TODO app example](https://stackblitz.com/edit/react-executor-todo-app?file=README.md)
- [Streaming SSR example](https://codesandbox.io/p/devbox/react-executor-ssr-streaming-example-mwrmrs)
- [Next.js integration example](https://codesandbox.io/p/devbox/react-executor-next-example-whsj4v)

<span class="toc-icon">🔰&ensp;</span>[**Introduction**](#introduction)

- [Executor keys](#executor-keys)
Expand All @@ -57,6 +46,7 @@ npm install --save-prod react-executor
- [Retry the latest task](#retry-the-latest-task)
- [Settle an executor](#settle-an-executor)
- [Clear an executor](#clear-an-executor)
- [Optimistic updates](#optimistic-updates)

<span class="toc-icon">📢&ensp;</span>[**Events and lifecycle**](#events-and-lifecycle)

Expand Down Expand Up @@ -110,7 +100,6 @@ npm install --save-prod react-executor

<span class="toc-icon">🍪&ensp;</span>**Cookbook**

- [Optimistic updates](#optimistic-updates)
- [Dependent tasks](#dependent-tasks)
- [Derived executors](#derived-executors)
- [Pagination](#pagination)
Expand All @@ -120,6 +109,13 @@ npm install --save-prod react-executor
- [Storage state versioning](#storage-state-versioning)
- [Global loading indicator](#global-loading-indicator)

<span class="toc-icon">🔎&ensp;</span>**Resources**

- [API docs](https://smikhalevski.github.io/react-executor/)
- [TODO app example](https://stackblitz.com/edit/react-executor-todo-app?file=README.md)
- [Streaming SSR example](https://codesandbox.io/p/devbox/react-executor-ssr-streaming-example-mwrmrs)
- [Next.js integration example](https://codesandbox.io/p/devbox/react-executor-next-example-whsj4v)

<!--/TOC-->

<!--ARTICLE-->
Expand Down Expand Up @@ -260,13 +256,13 @@ manager.get(rookyKey) !== manager.getOrCreate({ id: 123 });
Let's execute a new task:

```ts
import { ExecutorManager, ExecutorTask } from 'react-executor';
import { ExecutorManager, ExecutorTaskCallback } from 'react-executor';

const manager = new ExecutorManager();

const rookyExecutor = manager.getOrCreate('rooky');

const helloTask: ExecutorTask = async (signal, executor) => 'Hello';
const helloTask: ExecutorTaskCallback = async (signal, executor) => 'Hello';

const helloPromise = rookyExecutor.execute(task);
// ⮕ AbortablePromise<any>
Expand Down Expand Up @@ -418,7 +414,7 @@ For example, if you're fetching data from the server inside a task, you can pass
a [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/fetch#signal) option:

```ts
const byeTask: ExecutorTask = async (signal, executor) => {
const byeTask: ExecutorTaskCallback = async (signal, executor) => {
const response = await fetch('/bye', { signal });

return response.json();
Expand Down Expand Up @@ -575,6 +571,44 @@ Clearing an executor removes the stored value and reason, but _doesn't_ affect t
the [latest task](https://smikhalevski.github.io/react-executor/interfaces/react-executor.Executor.html#task)
that was executed.

## Optimistic updates

Optimistic updates are a UX technique where the UI reflects the result of an action instantly, before the server has
confirmed it. If the server succeeds, nothing changes visually. If the server fails, the UI rolls back to the state it
was in before the action.

In React Executor, optimistic updates are first-class citizens and can be achieved by providing
[`pendingValue`](https://smikhalevski.github.io/react-executor/interfaces/react-executor.ExecutorTask.html#pendingxalue)
in executor task. When a task is submitted with a `pendingValue`, the executor resolves to that value instantly
(before the task callback runs) and stores a checkpoint of the previous state. If the task fails, the checkpoint is
restored automatically.

```ts
const executor = useExecutor('meaningOfLife');

const handleClick = () => {
executor.execute({
callback: async signal => await getTheMeaningOfLife(signal),

// 🟡 Executor is resolved with this value before running callback
pendingValue: 42,
});
};
```

The UI reflects `42` the moment the button is clicked. If `getTheMeaningOfLife` rejects, the executor rolls back
to the value it held before execute was called — no manual snapshot or error handling required.

The rollback covers three cases depending on what state the executor was in before the optimistic update was applied:

- If previously _fulfilled_, then the executor is resolved back to the previous value.

- If previously _rejected_, then the executor is rejected with the previous reason.

- If previously _unsettled_, then the executor is cleared back to its initial state.

This means `pendingValue` is safe to use regardless of whether the executor has ever successfully fetched data.

# Events and lifecycle

Executors publish various events when their state changes. To subscribe to executor events use the
Expand Down Expand Up @@ -1329,10 +1363,10 @@ Combine this plugin with [`invalidateByPeers`](#invalidatebypeers) to automatica
executor on which it depends becomes invalid:

```ts
import { ExecutorTask, useExecutor } from 'react-executor';
import { ExecutorTaskCallback, useExecutor } from 'react-executor';
import invalidateByPeers from 'react-executor/plugin/invalidateByPeers';

const fetchCheese: ExecutorTask = async (signal, executor) => {
const fetchCheese: ExecutorTaskCallback = async (signal, executor) => {
// Wait for the breadExecutor to be created
const breadExecutor = await executor.manager.getOrAwait('bread');

Expand Down Expand Up @@ -2042,31 +2076,6 @@ the [react-executor-devtools](https://github.com/smikhalevski/react-executor-dev

# Cookbook

## Optimistic updates

To implement optimistic updates, [resolve the executor](#settle-an-executor) with the expected value and then
execute a server request.

For example, if you want to instantly show to a user that a flag was enabled:

```ts
const executor = useExecutor('flag', false);

const handleEnableClick = () => {
// 1️⃣ Optimistically resolve an executor
executor.resolve(true);

// 2️⃣ Synchronize state with the server
executor.execute(async signal => {
const response = await fetch('/flag', { signal });

const data = await response.json();

return data.isEnabled;
});
};
```

## Dependent tasks

Pause a task until another executor is settled:
Expand Down
62 changes: 55 additions & 7 deletions src/main/ExecutorImpl.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { AbortablePromise, PubSub } from 'parallel-universe';
import type { ExecutorManager } from './ExecutorManager.js';
import type { Executor, ExecutorEvent, ExecutorState, ExecutorTask, PartialExecutorEvent } from './types.js';
import {
Executor,
ExecutorEvent,
ExecutorState,
ExecutorTask,
ExecutorTaskCallback,
PartialExecutorEvent,
} from './types.js';
import { AbortError, isPromiseLike, preventUnhandledRejection } from './utils.js';

/**
Expand Down Expand Up @@ -29,6 +36,11 @@ export class ExecutorImpl<Value = any> implements Executor {
*/
_pubSub = new PubSub<ExecutorEvent>();

/**
* Snapshot captured before task execution, if the execution can be rolled back.
*/
_rollbackSnapshot: ExecutorState<Value> | null = null;

get isRejected(): boolean {
return this.isSettled && !this.isFulfilled;
}
Expand Down Expand Up @@ -98,20 +110,31 @@ export class ExecutorImpl<Value = any> implements Executor {
});
};

execute = (task: ExecutorTask<Value>): AbortablePromise<Value> => {
const handleAbort = (): void => {
execute = (task: ExecutorTask<Value> | ExecutorTaskCallback<Value>): AbortablePromise<Value> => {
if (typeof task === 'function') {
task = { callback: task };
}

const { callback, pendingValue, preserveLatestTask } = task;

const handleAbort = () => {
if (this.promise === promise) {
this._rollback();
this.promise = null;
this.version++;
}

this.publish({ type: 'aborted' });
};

// Rollback pending execution if any
this._rollback();

const promise = new AbortablePromise<Value>((resolve, reject, signal) => {
signal.addEventListener('abort', handleAbort);

new Promise<Value>(resolve => {
const value = task(signal, this);
const value = callback(signal, this);
resolve(value instanceof AbortablePromise ? value.withSignal(signal) : value);
}).then(
value => {
Expand Down Expand Up @@ -149,11 +172,25 @@ export class ExecutorImpl<Value = any> implements Executor {
this.version++;
}

if (this.promise === promise) {
if (this.promise !== promise) {
// Task was replaced midflight
return promise;
}

if (!preserveLatestTask) {
this.task = task;
this.publish({ type: 'pending' });
}

if (pendingValue !== undefined) {
this._rollbackSnapshot = this.getStateSnapshot();
this.isFulfilled = true;
this.value = pendingValue;
this.settledAt = Date.now();
this.invalidatedAt = 0;
}

this.publish({ type: 'pending', payload: { task } });

return promise;
};

Expand Down Expand Up @@ -187,10 +224,12 @@ export class ExecutorImpl<Value = any> implements Executor {

resolve = (value: PromiseLike<Value> | Value, settledAt = Date.now()): void => {
if (isPromiseLike(value)) {
this.execute(() => value);
this.execute({ callback: () => value, preserveLatestTask: true });
return;
}

this._rollback();

const prevPromise = this.promise;
this.promise = null;

Expand All @@ -206,6 +245,8 @@ export class ExecutorImpl<Value = any> implements Executor {
};

reject = (reason: any, settledAt = Date.now()): void => {
this._rollback();

const prevPromise = this.promise;
this.promise = null;

Expand Down Expand Up @@ -263,4 +304,11 @@ export class ExecutorImpl<Value = any> implements Executor {
invalidatedAt: this.invalidatedAt,
};
};

_rollback(): void {
if (this._rollbackSnapshot !== null) {
Object.assign(this, this._rollbackSnapshot);
this._rollbackSnapshot = null;
}
}
}
8 changes: 4 additions & 4 deletions src/main/ExecutorManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
ExecutorEvent,
ExecutorPlugin,
ExecutorState,
ExecutorTask,
ExecutorTaskCallback,
NoInfer,
Observable,
} from './types.js';
Expand Down Expand Up @@ -146,7 +146,7 @@ export class ExecutorManager implements Iterable<Executor>, Observable<ExecutorE
*/
getOrCreate<Value = any>(
key: unknown,
initialValue?: ExecutorTask<Value> | PromiseLike<Value> | Value,
initialValue?: ExecutorTaskCallback<Value> | PromiseLike<Value> | Value,
plugins?: Array<ExecutorPlugin<NoInfer<Value>> | null | undefined>
): Executor<Value>;

Expand All @@ -163,7 +163,7 @@ export class ExecutorManager implements Iterable<Executor>, Observable<ExecutorE
executor = Object.assign(new ExecutorImpl(key, this), this._initialState.get(keyId));

if (typeof initialValue === 'function') {
executor.task = initialValue as ExecutorTask;
executor.task = { callback: initialValue as ExecutorTaskCallback };
}

const unsubscribe = executor.subscribe(event => {
Expand Down Expand Up @@ -194,7 +194,7 @@ export class ExecutorManager implements Iterable<Executor>, Observable<ExecutorE
return executor;
}
if (typeof initialValue === 'function') {
executor.execute(initialValue as ExecutorTask);
executor.execute(initialValue as ExecutorTaskCallback);
} else {
executor.resolve(initialValue);
}
Expand Down
1 change: 1 addition & 0 deletions src/main/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export {
type ExecutorState,
type ExecutorPlugin,
type ExecutorTask,
type ExecutorTaskCallback,
type Observable,
type PartialExecutorEvent,
type ReadonlyExecutor,
Expand Down
10 changes: 8 additions & 2 deletions src/main/plugin/lazyTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,21 @@
* @module plugin/lazyTask
*/

import type { ExecutorPlugin, ExecutorTask, PluginConfiguredPayload } from '../types.js';
import type { ExecutorPlugin, ExecutorTask, ExecutorTaskCallback, PluginConfiguredPayload } from '../types.js';

/**
* Sets an executor task but doesn't execute it.
*
* @param task The task that is set to an executor.
*/
export default function lazyTask<Value>(task: ExecutorTask<Value>): ExecutorPlugin<Value> {
export default function lazyTask<Value>(
task: ExecutorTask<Value> | ExecutorTaskCallback<Value>
): ExecutorPlugin<Value> {
return executor => {
if (typeof task === 'function') {
task = { callback: task };
}

executor.task = task;

executor.publish({
Expand Down
2 changes: 1 addition & 1 deletion src/main/ssr/SSRExecutorManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export class SSRExecutorManager extends ExecutorManager {
* Increase this if your SSR data-fetching involves many sequential async steps.
*
* @throws {Error} If pending executors are still found after `maxRetries` attempts. This usually indicates
* a plugin that continuously re-executes tasks during SSR, such as {@link retryFulfilled} with a short delay.
* a plugin that continuously re-executes tasks during SSR.
*/
hasChanges(maxRetries = 10): Promise<boolean> {
const getVersion = () => Array.from(this).reduce((version, executor) => version + executor.version, 0);
Expand Down
Loading