Skip to content

smikhalevski/react-executor

Repository files navigation

React Executor


Asynchronous task execution and state management for React.


npm install --save-prod react-executor

🔰 Introduction

📢 Events and lifecycle

🔌 Plugins

⚛️ React integration

🚀 Server-side rendering

⚙️ Devtools

🍪 Cookbook

Introduction

An executor runs a task, stores the execution result, and provides access to that result. Tasks are callbacks that return a value or throw an error.

An Executor is created and managed by an ExecutorManager, which controls the executor's lifecycle:

import { ExecutorManager } from 'react-executor';

const manager = new ExecutorManager();

const rookyExecutor = manager.getOrCreate('rooky');
// ⮕ Executor<any>

Each executor has a unique key within the manager's scope. In this example, we created a new executor with the key 'rooky'. The manager creates a new executor when you call getOrCreate with a previously unused key. Subsequent calls with the same key return the same executor instance.

If you want to retrieve an existing executor by its key and avoid creating a new one if it doesn't exist, use get:

manager.get('bobby');
// ⮕ undefined

manager.get('rooky');
// ⮕ Executor<any>

The executor we created is unsettled, meaning it stores neither a value nor a failure reason:

rookyExecutor.isSettled;
// ⮕ false

An executor can be created with an initial value:

const bobbyExecutor = manager.getOrCreate('bobby', 42);

bobbyExecutor.isSettled;
// ⮕ true

// The result stored in the executor is a value
bobbyExecutor.isFulfilled;
// ⮕ true

bobbyExecutor.value;
// ⮕ 42

An initial value may be a task (which will be executed), a promise (which the executor will await), or any other value that immediately fulfills the executor. Read more in the Execute a task and in the Settle an executor sections.

When creating an executor, you can also provide an array of plugins:

import retryRejected from 'react-executor/plugin/retryRejected';

const rookyExecutor = manager.getOrCreate('rooky', 42, [retryRejected()]);

Plugins can subscribe to executor events or modify the executor instance. Read more about plugins in the Plugins section.

Executor keys

Anything can be an executor key: a string, a number, an object, etc. By default, keys are considered identical if their JSON-serialized form is identical:

const manager = new ExecutorManager();

const userExecutor = manager.getOrCreate(['user', 123]);

manager.get(['user', 123]);
// ⮕ userExecutor

Pass keyIdGenerator option to the ExecutorManager constructor to change the way key identity is computed. The returned key ID can be anything, a string, or an object.

If you're using objects as executor keys, then you may want to use stable serialized form of executor keys as key IDs (when object keys are sorted alphabetically during serialization). In this case use any library that supports stable JSON serialization:

import JSONMarshal from 'json-marshal';

const manager = new ExecutorManager({
  keyIdGenerator: key => JSONMarshal.stringify(key, { isStable: true }),
});

const rookyExecutor = manager.getOrCreate({ id: 123, name: 'Rooky' });
// ⮕ Executor<any>

// 🟡 Key properties are listed in a different order
manager.get({ name: 'Rooky', id: 123 });
// ⮕ rookyExecutor

Tip

With additional configuration, json-marshal can stringify and parse any data structure.

If you want to use object references as executor key IDs, provide an identity function:

const manager = new ExecutorManager({
  keyIdGenerator: key => key,
});

const rookyKey = { id: 123 };

const rookyExecutor = manager.getOrCreate(rookyKey);

// 🟡 Executors are different because different objects were used as keys
manager.get(rookyKey) !== manager.getOrCreate({ id: 123 });

Execute a task

Let's execute a new task:

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

const manager = new ExecutorManager();

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

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

const helloPromise = rookyExecutor.execute(task);
// ⮕ AbortablePromise<any>

helloTask receives an AbortSignal and rookyExecutor as arguments. The signal is aborted if the task is aborted or replaced.

While tasks can be synchronous or asynchronous, executors always handle them in an asynchronous fashion. The executor is marked as pending immediately after execute is called:

// The executor is waiting for the task to complete
rookyExecutor.isPending;
// ⮕ true

helloPromise is resolved when the task completes:

await helloPromise;

// The executor doesn't have a pending task anymore
rookyExecutor.isPending;
// ⮕ false

// The result stored in the executor is a value
rookyExecutor.isFulfilled;
// ⮕ true

rookyExecutor.value;
// ⮕ 'Hello'

The executor keeps track of the latest task it has executed:

rookyExecutor.task;
// ⮕ helloTask

If a task throws an error (or returns a promise that rejects with an error), then the promise returned from the execute is rejected:

const ooopsPromise = rookyExecutor.execute(() => {
  throw new Error('Ooops!');
});
// ⮕ Promise{<rejected>}

rookyExecutor.isPending;
// ⮕ true

The executor becomes rejected as well after ooopsPromise is settled:

rookyExecutor.isRejected;
// ⮕ true

// The reason of the task failure
rookyExecutor.reason;
// ⮕ Error('Ooops!')

Executors always preserve the latest value and the latest reason. So even when the executor isPending, you can access the previous value or failure reason. Use isFulfilled and isRejected to detect with what result the executor has settled the last time. An executor cannot be both fulfilled and rejected at the same time.

// Execute a new task
const byePromise = rookyExecutor.execute(() => 'Bye');

// 1️⃣ The executor is waiting for the task to complete
rookyExecutor.isPending;
// ⮕ true

// 2️⃣ The executor is still rejected after the previous task
rookyExecutor.isRejected;
// ⮕ true

rookyExecutor.reason;
// ⮕ Error('Ooops!')

// 3️⃣ The executor still holds the latest value, but it isn't fulfilled
rookyExecutor.isFulfilled;
// ⮕ false

rookyExecutor.value;
// ⮕ 'Hello'

The executor becomes fulfilled after byePromise settles:

await byePromise;

rookyExecutor.isFulfilled;
// ⮕ true

rookyExecutor.value;
// ⮕ 'Bye'

Abort a task

The promise returned by the execute method is abortable so the task can be prematurely aborted. Results of the aborted task are discarded:

const helloPromise = rookyExecutor.execute(async () => 'Hello');

rookyExecutor.isPending;
// ⮕ true

helloPromise.abort();

rookyExecutor.isPending;
// ⮕ false

It isn't always convenient to keep the reference to the task execution promise, and you can abort the pending task by aborting the whole executor:

rookyExecutor.abort();

If there's no pending task, then aborting an executor is a no-op.

When a task is aborted, the signal it received as an argument is aborted as well. Check the signal status to ensure that computation should be concluded.

For example, if you're fetching data from the server inside a task, you can pass signal as a fetch option:

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

  return response.json();
};

Replace a task

If a new task is executed while the pending task isn't completed yet, then pending task is aborted and its results are discarded:

executor.execute(async signal => 'Pluto');

await executor.execute(async signal => 'Mars');

executor.value;
// ⮕ 'Mars'

Wait for a task to complete

In the Execute a task section we used a promise that is returned from Executor.execute to wait for a task execution to complete. While this approach allows to wait for a given task execution to settle, it is usually required to wait for an executor itself become settled. The main point here is that the executor remains pending while multiple tasks replace one another.

Let's consider the scenario where a task is replaced with another task:

const planetExecutor = manager.getOrCreate('planet');

// The promise is resolved only when planetExecutor is settled
const planetPromise = planetExecutor.getOrAwait();

const marsPromise = planetExecutor.execute(async signal => 'Mars');

// 🟡 marsPromise is aborted, because task was replaced
const venusPromise = planetExecutor.execute(async signal => 'Venus');

await planetPromise;
// ⮕ 'Venus'

In this example, marsPromise is aborted, and planetPromise is resolved only after executor itself is settled and not pending anymore.

Here's another example, where the executor waits to be settled:

const printerExecutor = manager.getOrCreate('printer');

printerExecutor.getOrAwait().then(value => {
  console.log(value);
});

// Prints "Hello" to console
printerExecutor.execute(() => 'Hello');

Retry the latest task

To retry the latest task, use retry:

const planets = ['Mars', 'Venus'];

await executor.execute(() => planets.shift());

executor.retry();

await executor.getOrAwait();

executor.value;
// ⮕ 'Mars'

If there's no latest task, or there's a pending task already, then calling retry is a no-op.

If you want to forcefully retry the latest task, then abort the executor first:

executor.abort();
executor.retry();

Settle an executor

While tasks are always handled in an asynchronous fashion, there are cases when an executor should be settled synchronously.

Executor can be synchronously fulfilled via resolve:

executor.resolve('Venus');

executor.isFulfilled;
// ⮕ true

executor.value;
// ⮕ 'Venus'

Or rejected via reject:

executor.reject(new Error('Ooops!'));

executor.isRejected;
// ⮕ true

executor.reason;
// ⮕ Error('Ooops!')

If there is a pending task then invoking resolve or reject will abort it.

If you pass a promise to resolve, then an executor would wait for it to settle and store the result:

const planetPromise = Promise.resolve('Mars');

executor.resolve(planetPromise);

// The executor is waiting for the promise to settle
executor.isPending;
// ⮕ true

await executor.getOrAwait();

executor.value;
// ⮕ 'Mars'

Clear an executor

After the executor becomes settled, it remains settled until it is cleared.

You can reset the executor back to its unsettled state using clear:

executor.clear();

Clearing an executor removes the stored value and reason, but doesn't affect the pending task execution and preserves the latest task that was executed.

Events and lifecycle

Executors publish various events when their state changes. To subscribe to executor events use the subscribe method:

const manager = new ExecutorManager();

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

const unsubscribe = rookyExecutor.subscribe(event => {
  if (event.type === 'fulfilled') {
    // Handle the event here
  }
});

unsubscribe();

You can subscribe to the executor manager to receive events from all executors. For example, you can automatically retry any invalidated executor:

manager.subscribe(event => {
  if (event.type === 'invalidated') {
    event.target.retry();
  }
});

Both executors and managers may have multiple subscribers and each subscriber receives events with following types:

attached

The executor was just created, plugins were applied to it, and it was attached to the manager. Read more about plugins in the Plugins section.

detached

The executor was just detached: it was removed from the manager and all of its subscribers were unsubscribed. Read more in the Detach an executor section.

activated

The executor was inactive and became active. This means that there are consumers that observe the state of the executor. Read more in the Activate an executor section.

deactivated

The executor was active and became inactive. This means that there are no consumers that observe the state of the executor. Read more in the Activate an executor section.

pending

The executor started a task execution. You can find the latest task the executor handled in the Executor.task property.

fulfilled

The executor was fulfilled with a value.

rejected

The executor was rejected with a reason.

aborted

The task was aborted.

If executor is still pending when an 'aborted' event is published then the currently pending task is being replaced with a new task.

Calling Executor.execute when handling an abort event may lead to stack overflow. If you need to do this anyway, execute a new task from async context using queueMicrotask or a similar API.

cleared

The executor was cleared and now isn't settled.

invalidated

Results stored in an executor were invalidated.

annotated

Annotations associated with the executor were patched.

plugin_configured

The configuration of the plugin associated with the executor was updated.

Activate an executor

Executors have an active status that tells whether executor is actively used by a consumer.

const deactivate = executor.activate();

executor.isActive;
// ⮕ true

deactivate();

executor.isActive;
// ⮕ false

If there are multiple consumers and each of them invoke the activate method, then executor would remain active until all of them invoke their deactivate callbacks.

By default, marking an executor as active has no additional effect. Checking the executor active status in a plugin allows to skip or defer excessive updates and keep executor results up-to-date lazily. For example, consider a plugin that retries the latest task if an active executor becomes rejected:

const retryPlugin: ExecutorPlugin = executor => {
  executor.subscribe(event => {
    switch (event.type) {
      case 'rejected':
      case 'activated':
        if (executor.isActive && executor.isRejected) {
          executor.retry();
        }
        break;
    }
  });
};

const executor = manager.getOrCreate('rooky', heavyTask, [retryPlugin]);

executor.activate();

Now an executor would automatically retry the heavyTask if it fails. Read more about plugins in the Plugins section.

Invalidate results

Invalidate results stored in the executor:

executor.invalidate();

executor.isInvalidated;
// ⮕ true

After the executor is fulfilled, rejected, or cleared, it becomes valid:

executor.resolve('Okay');

executor.isInvalidated;
// ⮕ false

By default, invalidating an executor has no effect except marking it as invalidated.

Detach an executor

By default, executors that a manager has created are preserved indefinitely and are always available though get. This isn't always optimal, and you may want to detach an executor when it isn't needed anymore. Use detach in such case:

const executor = manager.getOrCreate('test');

manager.detach(executor.key);
// ⮕ true

All executor subscribers are now unsubscribed, and executor is removed from the manager.

If an executor is still active then it won't be detached.

Note

Pending task isn't aborted if the executor is detached. Use abortDeactivated plugin to abort the task of the deactivated executor.

Plugins

Plugins are callbacks that are invoked only once when the executor is created by the manager. For example, you can create a plugin that aborts the pending task and detaches an executor when it is deactivated:

const detachPlugin: ExecutorPlugin = executor => {
  executor.subscribe(event => {
    if (event.type === 'deactivted') {
      executor.abort();
      executor.manager.detach(executor.key);
    }
  });
};

To apply a plugin, pass it to the ExecutorManager.getOrCreate or to the useExecutor hook:

const executor = manager.getOrCreate('test', undefined, [detachPlugin]);

const deactivate = executor.activate();

// The executor is instantly detached by the plugin
deactivate();

manager.get('test');
// ⮕ undefined

Make the manager apply a plugin to all executors by default:

const manager = new ExecutorManager({
  plugins: [detachPlugin],
});

abortDeactivated

Aborts the pending task after the delay if the executor is deactivated.

import abortDeactivated from 'react-executor/plugin/abortDeactivated';

const executor = useExecutor('test', heavyTask, [
  abortDeactivated({ delay: 2_000 }),
]);

const deactivate = executor.activate();

// Aborts heavyTask in 2 seconds
deactivate();

If an executor is re-activated during this delay, the task won't be aborted. The executor must be activated at least once for this plugin to have an effect.

abortPendingAfter

Aborts the pending task with TimeoutError if the task execution took longer then the given delay.

import abortPendingAfter from 'react-executor/plugin/abortPendingAfter';

const executor = useExecutor('test', heavyTask, [
  abortPendingAfter(10_000),
]);

abortWhen

Aborts the pending task if the observable emits true.

For example, abort the current task if the device is disconnected from the network for more then 5 seconds:

import abortWhen from 'react-executor/plugin/abortWhen';
import navigatorOffline from 'react-executor/observable/navigatorOffline';

const executor = useExecutor('test', heavyTask, [
  abortWhen(navigatorOffline, { delay: 5_000 }),
]);

By default, abortWhen only aborts the currently pending task. Here's how every newly executed task can be instantly aborted if the last value emitted by the observable was true and a delay has ran out:

const executor = useExecutor('test', heavyTask, [
  abortWhen(navigatorOffline, {
    delay: 5_000,

    // 🟡 Abort every newly executed task
    isSustained: true,
  }),
]);

Read more about observables in the InvalidateWhen section.

detachDeactivated

Detaches the executor after the timeout if the executor is deactivated.

import detachDeactivated from 'react-executor/plugin/detachDeactivated';

const executor = useExecutor('test', heavyTask, [
  detachDeactivated({ delay: 2_000 }),
]);

const deactivate = executor.activate();

// Executor is detached in 2 seconds
deactivate();

If an executor is re-activated during this delay, the executor won't be detached.

This plugin doesn't abort the pending task when an executor is detached. Use abortDeactivated to do the job:

import abortDeactivated from 'react-executor/plugin/abortDeactivated';
import detachDeactivated from 'react-executor/plugin/detachDeactivated';

const executor = useExecutor('test', heavyTask, [
  abortDeactivated({ delay: 2_000 }),
  detachDeactivated({ delay: 2_000 }),
]);

const deactivate = executor.activate();

// The heavyTask is aborted and the executor is detached in 2 seconds
deactivate();

detachInactive

Detach an executor if it wasn't activated during first 5 seconds after being created:

import detachInactive from 'react-executor/plugin/detachInactive';

const executor = useExecutor('test', 42, [
  detachInactive({ delayBeforeActivated: 5_000 }),
]);

Detach an executor if it was inactive for 5 seconds:

const executor = useExecutor('test', 42, [
  detachInactive({ delayAfterActivation: 5_000 }),
]);

const deactivate = executor.activate();

// The executor is detached in 5 seconds
deactivate();

invalidateAfter

Invalidates the executor result after a delay.

import invalidateAfter from 'react-executor/plugin/invalidateAfter';

const executor = useExecutor('test', 42, [
  invalidateAfter(2_000),
]);

// The executor is invalidated in 2 seconds
executor.activate();

If the executor is settled then the timeout is restarted.

invalidateByPeers

Invalidates the executor result if another executor with a matching key is fulfilled or invalidated.

import invalidateByPeers from 'react-executor/plugin/invalidateByPeers';

const cheeseExecutor = useExecutor('cheese', 'Burrata', [
  invalidateByPeers(executor => executor.key === 'bread'),
]);

const breadExecutor = useExecutor('bread');

// cheeseExecutor is invalidated
breadExecutor.resolve('Ciabatta');

Provide an array of executors as peers:

const breadExecutor = useExecutor('bread');

const cheeseExecutor = useExecutor('cheese', 'Burrata', [
  invalidateByPeers([breadExecutor]),
]);

// cheeseExecutor is invalidated
breadExecutor.resolve('Ciabatta');

invalidatePeers

Invalidates peer executors with matching keys if the executor is fulfilled or invalidated.

import invalidatePeers from 'react-executor/plugin/invalidatePeers';

const cheeseExecutor = useExecutor('cheese', 'Burrata', [
  invalidatePeers(executor => executor.key === 'bread'),
]);

const breadExecutor = useExecutor('bread', 'Focaccia');

// breadExecutor is invalidated
cheeseExecutor.resolve('Mozzarella');

Provide an array of executors as peers:

const breadExecutor = useExecutor('bread', 'Focaccia');

const cheeseExecutor = useExecutor('cheese', 'Burrata', [
  invalidatePeers([breadExecutor]),
]);

// breadExecutor is invalidated
cheeseExecutor.resolve('Mozzarella');

invalidateWhen

Invalidates the settled executor result when the observable emits true.

For example, if the window was offline for more than 5 seconds, then the executor would be invalidated when the window goes is back online:

import invalidateWhen from 'react-executor/plugin/invalidateWhen';
import navigatorOnline from 'react-executor/observable/navigatorOnline';

const executor = useExecutor('test', heavyTask, [
  invalidateWhen(navigatorOnline, { delay: 5_000 }),
]);

Combining multiple plugins, you can set up a complex executor behaviour. For example, let's create an executor that follows these requirements:

  1. Executes the task every 5 seconds.
  2. Aborts the pending task if the window loses focus for more than 10 seconds.
  3. Aborts instantly if the window goes offline.
  4. Resumes the periodic task execution if window gains focus or goes back online.
import { useExecutor } from 'react-executor';
import abortWhen from 'react-executor/plugin/abortWhen';
import invalidateWhen from 'react-executor/observable/invalidateWhen';
import navigatorOffline from 'react-executor/observable/navigatorOffline';
import navigatorOnline from 'react-executor/observable/navigatorOnline';
import retryInvalidated from 'react-executor/plugin/retryInvalidated';
import retryFulfilled from 'react-executor/plugin/retryFulfilled';
import windowBlurred from 'react-executor/observable/windowBlurred';
import windowFocused from 'react-executor/observable/windowFocused';

useExecutor('test', heavyTask, [
  retryInvalidated(),

  // Retry the task every 5 seconds if if succeeds
  retryFulfilled({ delay: 5_000 }),

  // Abort the task if the window looses focus for at least 10 seconds
  abortWhen(windowBlurred, { delay: 10_000 }),

  // Abort the pending task if the device is disconnected from the network
  abortWhen(navigatorOffline),

  // Invalidate results when the window was blurred and then gains focus
  invalidateWhen(windowFocused),

  // Invalidate results if the device was disconnected from the network
  // and then connects again
  invalidateWhen(navigatorOnline),
]);

lazyTask

Sets an executor task but doesn't execute it.

This plugin is useful when you have an static initial value and a task that can update this value later:

import lazyTask from 'react-executor/plugin/lazyTask';
import retryInvalidated from 'react-executor/plugin/retryInvalidated';

const executor = useExecutor('meaningOfLife', 42, [
  lazyTask(async () => await getTheMeaningOfLife()),
  retryInvalidated(),
]);

executor is created with the value set to 42. getTheMeaningOfLife task isn't executed and would be called only if executor is invalidated (tanks to retryInvalidated plugin):

executor.invalidate();

rejectPendingAfter

Aborts the pending task and rejects the executor with TimeoutError if the task execution took longer then the given timeout.

import rejectPendingAfter from 'react-executor/plugin/rejectPendingAfter';

const executor = useExecutor('test', heavyTask, [
  rejectPendingAfter(10_000),
]);

resolveBy

Resolves the executor with values pushed by an Observable.

import { Observable } from 'react-executor';
import resolveBy from 'react-executor/plugin/resolveBy';

const observable: Observable<string> = {
  subscribe(listener) {
    // Call the listener when value is changed
    const timer = setTimeout(listener, 1_000, 'Venus');

    return () => {
      // Unsubscribe the listener
      clearTimeout(timer);
    };
  },
};

const executor = useExecutor('planet', 'Mars', [
  resolveBy(observable),
]);

PubSub can be used do decouple the lazy data source from the executor:

import { PubSub } from 'parallel-universe';

const pubSub = new PubSub<string>();

const executor = useExecutor('planet', 'Mars', [
  resolveBy(pubSub),
]);

pubSub.publish('Venus');

executor.value; // ⮕ 'Venus'

retryActivated

Retries the latest task if the executor is activated.

import retryActivated from 'react-executor/plugin/retryActivated';

const executor = useExecutor('test', heavyTask, [
  retryActivated(),
]);

// Retries the task
executor.activate();

Set the minimum delay in milliseconds that should pass between the activation and the moment the executor was last settled:

const executor = useExecutor('test', heavyTask, [
  retryActivated({ delay: 5_000 }),
]);

// Doesn't retry the task if 5 seconds didn't pass
executor.activate();

retryFulfilled

Retries the latest task after the execution was fulfilled.

import retryFulfilled from 'react-executor/plugin/retryFulfilled';

const executor = useExecutor('test', heavyTask, [
  retryFulfilled(),
]);

If the task fails, is aborted, or if an executor is deactivated then the plugin stops the retry process.

With the default configuration, the plugin would infinitely retry the task of an active executor with a 5-second delay between retries. This is effectively a decent polling strategy that kicks in only if someone is actually using an executor.

Specify the number of times the task should be re-executed if it succeeds:

retryFulfilled({ count: 3 });

Specify the delay in milliseconds between retries:

retryFulfilled({ count: 3, delay: 5_000 });

Provide a function that returns the delay depending on the number of retries:

retryFulfilled({
  count: 5,
  delay: (index, executor) => 1000 * index,
});

By default, retryFulfilled doesn't retry inactive executors. The executor is retried only after it becomes active.

To retry the latest task regardless of the executor activation status:

retryFulfilled({ isEager: true });

Use retryFulfilled in conjunction with retryRejected to create a polling sequence. For example, this configuration would infinitely poll the heavyTask and would retry failed iterations with an exponential backoff and no more then 3 times in a row:

const executor = useExecutor('test', heavyTask, [
  retryFulfilled(),
  retryRejected({ count: 5 }),
]);

You can also use a combination of invalidateAfter and retryInvalidated to create an infinite polling regardless of the result of the previous iteration:

const executor = useExecutor('test', heavyTask, [
  invalidateAfter(5_000),
  retryInvalidated(),
]);

retryInvalidated

Retries the latest task of the active executor if it was invalidated.

import retryInvalidated from 'react-executor/plugin/retryInvalidated';

const executor = useExecutor('test', 42, [
  retryInvalidated(),
]);

executor.activate();

Combine this plugin with invalidateByPeers to automatically retry this executor if another executor on which it depends becomes invalid:

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

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

  // Wait for the breadExecutor to be settled
  const bread = await breadExecutor.getOrAwait();

  // Choose the best cheese for this bread
  return bread === 'Ciabatta' ? 'Mozzarella' : 'Burrata';
};

const cheeseExecutor = useExecutor('cheese', fetchCheese, [
  invalidateByPeers(executor => executor.key === 'bread'),
  retryInvalidated(),
]);

const breadExecutor = useExecutor('bread');

// 🟡 cheeseExecutor is invalidated and re-fetches cheese
breadExecutor.resolve('Ciabatta');

Read more about dependent tasks.

By default, retryInvalidated doesn't retry inactive executors. The executor is retried only after it becomes active.

To retry the latest task regardless of the executor activation status:

retryInvalidated({ isEager: true });

retryRejected

Retries the last task after the execution has failed.

import retryRejected from 'react-executor/plugin/retryRejected';

const executor = useExecutor('test', heavyTask, [
  retryRejected(),
]);

executor.activate();

If the task succeeds, is aborted, or if an executor is deactivated then the plugin stops the retry process.

With the default configuration, the plugin would retry the task 3 times with an exponential delay between retries.

Specify the number of times the task should be re-executed if it fails:

retryRejected({ count: 3 });

Specify the delay in milliseconds between retries:

retryRejected({ count: 3, delay: 5_000 });

Provide a function that returns the delay depending on the number of retries:

retryRejected({
  count: 5,
  delay: (index, executor) => 1000 * 1.8 ** index,
});

By default, retryRejected doesn't retry inactive executors. The executor is retried only after it becomes active.

To retry the latest task regardless of the executor activation status:

retryRejected({ isEager: true });

retryWhen

Retries the latest task if the observable emits true.

For example, if the window was offline for more than 5 seconds, the executor would retry the heavyTask after the window is back online:

import retryWhen from 'react-executor/plugin/retryWhen';
import navigatorOnline from 'react-executor/observable/navigatorOnline';

const executor = useExecutor('test', heavyTask, [
  retryWhen(navigatorOnline, { delay: 5_000 }),
]);

syncBrowserStorage

Synchronizes the executor state with localStorage or sessionStorage item. No-op in a non-browser environment.

import syncBrowserStorage from 'react-executor/plugin/syncBrowserStorage';

const executor = useExecutor('test', 42, [syncBrowserStorage()]);

With this plugin, you can synchronize the executor state across multiple browser tabs in just one line.

Important

If executor is detached, then the corresponding item is removed from the storage.

By default, an executor state is serialized using JSON. If your executor stores a value that may contain circular references, or non-serializable data like BigInt, use a custom serializer.

Here's how you can enable serialization of objects with circular references:

import JSONMarshal from 'json-marshal';

const executor = useExecutor('test', 42, [
  syncBrowserStorage({
    serializer: JSONMarshal,
  }),
]);

Tip

With additional configuration, json-marshal can stringify and parse any data structure.

By default, syncBrowserStorage plugin uses a serialized executor key as a storage key. You can provide a custom key via storageKey option:

useExecutor('test', 42, [syncBrowserStorage({ storageKey: 'hello' })]);

syncExternalStore

Persists the executor state in the observable external store.

Here's the minimal external store example:

import { useExecutor, ExecutorState } from 'react-executor';
import syncExternalStore, { ExternalStore } from 'react-executor/plugin/syncExternalStore';

let myStoredState: ExecutorState | null = null;

const myStore: ExternalStore<ExecutorState> = {
  get() {
    return myStoredState;
  },

  subscribe(listener) {
    // Place subscription login here
    return () => {};
  },
};

const myExecutor = useExecutor('test', 42, [syncExternalStore(myStore)]);

When executor is settled, cleared, invalidated or annotated then the plugin calls the ExternalStore.set method on the store.

When executor is detached then the plugin calls the ExternalStore.delete method on the store.

Prefer syncBrowserStorage if you want to persist the executor state in a localStorage or sessionStorage.

React integration

In the basic scenario, to use executors in your React app, you don't need any additional configuration, just use the useExecutor hook right away:

import { useExecutor } from 'react-executor';

function User(props: { userId: string }) {
  const executor = useExecutor(['user', props.userId], async signal => {
    // Fetch the user from the server
  });

  if (executor.isPending) {
    return 'Loading';
  }

  // Render the user from the executor.value
}

Every time the executor's state is changed, the component is re-rendered. The executor returned from the hook is activated after mount and deactivated on unmount.

The hook has the exact same signature as the ExecutorManager.getOrCreate method, described in the Introduction section.

Tip

Check out the live example of the TODO app that employs React Executor.

You can use executors both inside and outside the rendering process. To do this, provide a custom ExecutorManager through the context:

import { ExecutorManager, ExecutorManagerProvider } from 'react-executor';

const manager = new ExecutorManager();

const App = () => (
  <ExecutorManagerProvider value={manager}>
    <User userId={'28'} />
  </ExecutorManagerProvider>
);

Now you can use manager to access all the same executors that are available through the useExecutor hook:

const executor = manager.get(['user', '28']);

If you want to have access to an executor in a component, but don't want to re-render the component when the executor's state is changed, use useExecutorManager hook:

const accountExecutor = useExecutorManager().getOrCreate('account');

You can execute a task in response to a user action, for example when user clicks a button:

const executor = useExecutor('test');

const handleClick = () => {
  executor.execute(async signal => {
    // Handle the task
  });
};

If you want executor to run on the client only, then execute a task from the effect:

const executor = useExecutor('test');

useEffect(() => {
  executor.execute(async signal => {
    // Handle the task
  });
}, []);

Suspense

Executors support fetch-as-you-render approach and can be integrated with React Suspense. To facilitate the rendering suspension, use the useExecutorSuspense hook:

import { useExecutor, useExecutorSuspense } from 'react-executor';

function Account() {
  const accountExecutor = useExecutor('account', signal => {
    // Fetch an account from a server
  });

  // Suspend rendering if accountExecutor is pending and isn't fulfilled
  const account = useExecutorSuspense(accountExecutor).get();
}

Now when the Account component is rendered, it would be suspended until the accountExecutor is settled:

import { Suspense } from 'react';

const App = () => (
  <Suspense fallback={'Loading'}>
    <Account />
  </Suspense>
);

Executors can run tasks in parallel and rendering is suspended until both of them are settled:

const cheeseExecutor = useExecutor('cheese', buyCheeseTask);
const beadExecutor = useExecutor('bread', bakeBreadTask);

const cheese = useExecutorSuspense(cheeseExecutor).get();
const bread = useExecutorSuspense(breadExecutor).get();

External executors

You can use executors created outside the rendering process in your components, rerender and suspend your components when such executors get updated:

const manager = new ExecutorManager();

// 1️⃣ Create an executor
const accountExecutor = manager.getOrCreate('account', signal => {
  // Fetch an account from a server
});

function Account() {
  // 2️⃣ Re-render a component when accountExecutor is updated
  useExecutorSubscription(accountExecutor);

  // 3️⃣ Suspend rendering if accountExecutor is pending and isn't fulfilled
  const account = useExecutorSuspense(accountExecutor).get();
}

Server-side rendering

Tip

Check out the live example of streaming SSR with React Executor.

Executors can be hydrated on the client after being settled on the server.

To enable hydration on the client, create the executor manager and provide it through a context:

import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import { hydrateExecutorManager, ExecutorManager, ExecutorManagerProvider } from 'react-executor';

const manager = new ExecutorManager();

// 🟡 Hydrates executors on the client with the server data
hydrateExecutorManager(manager);

hydrateRoot(
  document,
  <ExecutorManagerProvider value={manager}>
    <App />
  </ExecutorManagerProvider>
);

Here, App is the component that renders your application. Inside the App you can use useExecutor and useExecutorSuspence to load your data.

hydrateExecutorManager must be called only once on the client-side with the manager that would receive the dehydrated state from the server.

On the server-side, you can either render your app contents as a string and send it to the client in one go, or stream the contents.

Render to string

Use SSRExecutorManager to render your app as an HTML string:

import { createServer } from 'node:http';
import { renderToString } from 'react-dom/server';
import { ExecutorManagerProvider } from 'react-executor';
import { SSRExecutorManager } from 'react-executor/ssr';

const server = createServer(async (request, response) => {
  // 1️⃣ Create a new manager for each request
  const manager = new SSRExecutorManager();

  // 2️⃣ Re-render until there are no more changes
  let html;
  do {
    html = renderToString(
      <ExecutorManagerProvider value={manager}>
        <App />
      </ExecutorManagerProvider>
    );
  } while (await manager.hasChanges());

  // 3️⃣ Inject the hydration script
  html = html.replace('</body>', manager.nextHydrationChunk() + '</body>');

  response.setHeader('Content-Type', 'text/html');
  response.end(html);
});

server.listen(8080);

A new executor manager must be created for each request, so the results that are stored in executors are served in response to a particular request.

hasChanges would resolve with true if state of some executors have changed during rendering.

The hydration chunk returned by nextHydrationChunk contains the <script> tag that hydrates the manager for which hydrateExecutorManager was invoked.

Streaming SSR

React can stream parts of your app while it is being rendered. You can inject React Executor hydration chunks into the React stream.

import { createServer } from 'node:http';
import { Writable } from 'node:stream';
import { renderToReadableStream } from 'react-dom/server';
import { ExecutorManagerProvider } from 'react-executor';
import { SSRExecutorManager } from 'react-executor/ssr';

const server = createServer(async (request, response) => {
  // 1️⃣ Create a new manager for each request
  const manager = new SSRExecutorManager();

  const stream = await renderToReadableStream(
    <ExecutorManagerProvider value={manager}>
      <App />
    </ExecutorManagerProvider>
  );

  const hydrator = new TransformStream({
    transform(chunk, controller) {
      controller.enqueue(chunk);
      controller.enqueue(manager.nextHydrationChunk());
    },
  });

  response.setHeader('Content-Type', 'text/html');

  // 2️⃣ Inject the hydration chunks into the react stream
  await stream.pipeThrough(hydrator).pipeTo(Writable.toWeb(response));

  response.end();
});

server.listen(8080);

hydrator injects React Executor hydration chunks into the React stream.

In the App component, use the combination of <Suspense>, useExecutor and useExecutorSuspence to suspend rendering while executors process their tasks:

function App() {
  return (
    <html>
      <head />
      <body>
        <Suspense fallback={'Loading'}>
          <Hello />
        </Suspense>
      </body>
    </html>
  );
}

function Hello() {
  const helloExecutor = useExecutor('hello', async () => {
    // Asynchronously return the result
    return 'Hello, Paul!';
  });

  // 🟡 Suspend rendering until helloExecutor is settled
  useExecutorSuspense(helloExecutor);

  return helloExecutor.get();
}

If the App is rendered in streaming mode, it would first show "Loading" and after the executor is settled, it would update to "Hello, Paul!". In the meantime helloExecutor on the client would be hydrated with the data from the server.

State serialization

By default, an executor state is serialized using JSON that has quite a few limitations. If your executor stores a value that may contain circular references, or non-serializable data like BigInt, then a custom state serializer must be provided.

On the server, pass a serializer option to SSRExecutorManager:

import { SSRExecutorManager } from 'react-executor/ssr';
import JSONMarshal from 'json-marshal';

const manager = new SSRExecutorManager({ serializer: JSONMarshal });

On the client, pass the same serializer to hydrateExecutorManager:

import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import { hydrateExecutorManager, ExecutorManager, ExecutorManagerProvider } from 'react-executor';
import JSONMarshal from 'json-marshal';

const manager = new ExecutorManager();

hydrateExecutorManager(manager, { serializer: JSONMarshal });

hydrateRoot(
  document,
  <ExecutorManagerProvider value={manager}>
    <App />
  </ExecutorManagerProvider>
);

Tip

With additional configuration, json-marshal can stringify and parse any data structure.

Content-Security-Policy support

By default, nextHydrationChunk renders an inline <script> tag without any attributes. To enable the support of the script-src directive of the Content-Security-Policy header, provide the nonce option to SSRExecutorManager or any of its subclasses:

const manager = new SSRExecutorManager(response, { nonce: '2726c7f26c' });

Send the header with this nonce in the server response:

Content-Security-Policy: script-src 'nonce-2726c7f26c'

Next.js integration

Tip

Check out the live example of the Next.js app that showcases streaming SSR with React Executor.

To enable client hydration in Next.js, use @react-executor/next package.

First, provide an ExecutorManager:

// providers.tsx
'use client';

import { ReactNode } from 'react';
import { hydrateExecutorManager, ExecutorManager, ExecutorManagerProvider } from 'react-executor';
import { SSRExecutorManager } from 'react-executor/ssr';
import { ExecutorHydrator } from '@react-executor/next';

const manager = typeof window !== 'undefined' ? hydrateExecutorManager(new ExecutorManager()) : undefined;

export function Providers(props: { children: ReactNode }) {
  return (
    <ExecutorManagerProvider value={manager || new SSRExecutorManager()}>
      <ExecutorHydrator>{props.children}</ExecutorHydrator>
    </ExecutorManagerProvider>
  );
}

ExecutorHydrator propagates server-rendered executor state to the client. You can configure how dehydrated state is serialized on the server and deserialized on the client, by default JSON is used.

Enable providers in the root layout:

// layout.tsx
import { ReactNode } from 'react';
import { Providers } from './providers';

export default function (props: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers>{props.children}</Providers>
      </body>
    </html>
  );
}

Devtools

To inspect the current state of executors in your app, install the React Executor Devtools browser extension and open its panel in the Chrome Developer Tools:


React Executor Devtools Screenshot


Devtools extension doesn't require any additional configuration and provides introspection to all executors on the page, regardless if they were rendered through React or created outside of the rendering process.

To disable devtools, create a custom ExecutorManager:

import { ExecutorManager } from 'react-executor';

const opaqueExecutorManager = new ExecutorManager({
  devtools: false,
});

Executors created by the opaqueExecutorManager won't be visible in the React Executor Devtools extension. It is recommended to use this setting in production.

The extension source can be found in the react-executor-devtools repo.

Cookbook

Optimistic updates

To implement optimistic updates, resolve the 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:

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:

const accountExecutor = useExecutor('account', async signal => {
  // Fetch account here
});

const shoppingCartExecutor = useExecutor('shoppingCart', async signal => {
  const account = await accountExecutor.getOrAwait();

  // Fetch shopping cart for an account
});

In this example, the component is subscribed to both account and a shopping cart executors, and re-rendered if their state is changed. To avoid unnecessary re-renders, you can acquire an executor through the manager:

const shoppingCartExecutor = useExecutor('shoppingCart', async (signal, executor) => {
  // 1️⃣ Wait for the account executor to be created
  const accountExecutor = await executor.manager.getOrAwait('account');

  // 2️⃣ Wait for the account executor to be settled
  const account = await accountExecutor.getOrAwait();

  // Fetch shopping cart for an account
});

Pagination

Create an executor that would store the current page contents:

const fetchPage = async (pageIndex: number, signal: AbortSignal) => {
  // Request the data from the server here
};

const pageExecutor = useExecutor('page', signal => fetchPage(0, signal));

const handleGoToPageClick = (pageIndex: number) => {
  pageExecutor.execute(signal => fetchPage(pageIndex, signal));
};

The executor preserves the latest value it was resolved with, so you can render page contents using executor.value, and render a spinner when executor.isPending.

Infinite scroll

Create a task that uses the current executor value to combine it with the data loaded from the server:

const itemsExecutor = useExecutor<Item[]>('items', async (signal, executor) => {
  const items = executor.value || [];

  return items.concat(await fetchItems({ offset: items.length, signal }));
});

Now if a user clicks on a button to load more items, itemsExecutor must retry the latest task:

const handleLoadMoreClick = () => {
  itemsExecutor.retry();
};

Invalidate all executors

ExecutorManager is iterable and provides access to all executors that it has created. You can perform bach operations with all executors in for-loop:

const manager = useExecutorManager();

for (const executor of manager) {
  executor.invalidate();
}

By default, invalidating an executor has no additional effect. If you want to retry the latest task that each executor has executed, use retry:

for (const executor of manager) {
  executor.retry();
}

It isn't optimal to retry all executors even if they aren't actively used. Use the retryInvalidated to retry active executors when they are invalidated.

Prefetching

In some cases, you can initialize an executor before its data is required for the first time:

const User = () => {
  useExecutorManager().getOrCreate('shoppingCart', fetchShoppingCart);
};

In this example, the executor with the 'shoppingCart' key is initialized once the component is rendered for the first time. The User component won't be re-rendered if the state of this executor is changed.

To do prefetching before the application is even rendered, create an executor manager beforehand:

const manager = new ExecutorManager();

// Prefetch the shopping cart
manager.getOrCreate('shoppingCart', fetchShoppingCart);

const App = () => <ExecutorManagerProvider value={manager}>{/* Render you app here */}</ExecutorManagerProvider>;

Storage state versioning

You can store an executor state in a localStorage using the syncBrowserStorage plugin:

import { useExecutor } from 'react-executor';
import syncBrowserStorage from 'react-executor/plugin/syncBrowserStorage';

const playerExecutor = useExecutor('player', { health: '50%' }, [syncBrowserStorage()]);
// ⮕ Executor<{ health: string }>

But what if over time you'd like to change the structure of the value stored in the playerExecutor? For example, make health property a number:

const playerExecutor = useExecutor('player', { health: 0.5 }, [syncBrowserStorage()]);

After users have used the previous version of the app where health was a string, they would still receive a string value since the playerExecutor state is read from the localStorage:

playerExecutor.value.health;
// ⮕ '50%'

This may lead to an unexpected behavior of your app. To mitigate this issue, let's write a plugin that would annotate the executor with a version:

import { type ExecutorPlugin } from 'react-executor';

export function requireVersion(version: number): ExecutorPlugin {
  return executor => {
    if (executor.annotations.version === version) {
      // ✅ Executor is annotated with a correct version
      return;
    }

    // ❌ Clear the executor state and annotate it with a proper version
    executor.clear();
    executor.annotate({ version });
  };
}

Add the plugin to the executor:

const playerExecutor = useExecutor('player', { health: 0.5 }, [syncBrowserStorage(), requireVersion(1)]);

After the syncBrowserStorage plugin reads the data from the localStorage, the requireVersion plugin ensures that the version annotation read from the localStorage matches the required version. On mismatch the executor is cleared and the initial value { health: 0.5 } is written to the storage.

playerExecutor.value.health;
// ⮕ 0.5

Bump the version provided to requireVersion plugin every time the structure of the executor value is changed.

We can enhance the requireVersion plugin by making it migrate the data instead of just clearing it:

export function requireVersion<T>(version: number, migrate: (executor: Executor<T>) => T): ExecutorPlugin<T> {
  return executor => {
    if (executor.annotations.version === version) {
      return;
    }

    // 🟡 Migrate only if executor has a value
    if (executor.isSettled) {
      migrate(executor);
    }

    executor.annotate({ version });
  };
}

Now requireVersion would apply the migration on the state version mismatch:

const playerExecutor = useExecutor('player', { health: 0.5 }, [
  syncBrowserStorage(),

  requireVersion(1, executor => {
    executor.resolve({
      health: parseInt(executor.get().health) / 100,
    });
  }),
]);

Global loading indicator

To detect a global pending state we can rely on events published by an ExecutorManager:

function useIsPending(predicate = (executor: Executor) => true): boolean {
  const manager = useExecutorManager();
  const [isPending, setPending] = useState(false);

  useEffect(() => {
    const syncIsPending = (event: ExecutorEvent) => {
      setPending(Array.from(manager).some(executor => predicate(executor) && executor.isPending));
    };

    // 1️⃣ Ensure isPending is up-to-date after mount
    syncIsPending();

    // 2️⃣ Sync isPending when any event is published
    return manager.subscribe(syncIsPending);
  }, [manager]);

  return isPending;
}

Now a global pending indicator can be shown when any executor is pending:

const isPending = useIsPending();

isPending && <LoadingIndicator />;

You can use a predicate to filter only executors that are actually fetching data. To do this, fetching executors should be marked as such, for example with an annotation:

const accountExecutor = useExecutor(
  'account',

  () => fetch('/account'),

  // 1️⃣ Annotate an executor once via a plugin
  [executor => executor.annotate({ isFetching: true })]
);

// 2️⃣ Get global pending status for executors that are fetching data
const isPending = useIsPending(executor => executor.annotations.isFetching);

:octocat: ❤️

About

🦖 Asynchronous task execution and state management for React.

Topics

Resources

License

Stars

Watchers

Forks