Skip to content

Introduction

usePureSource enables React components to safely and efficiently use a mutable Pure source, and derive state from it. A source is considered Pure if its initialization is side-effects free and doesn't require access to the DOM or to a Ref.

tsx
import { usePureSource } from 'use-mutable-source';

Define the source

You can define an init function that generates a Pure source. usePureSource will take care of generating it when the component renders.

tsx
const [useSnapshot, source] = usePureSource(
  // Defines the source.
  () => new Source()
);

If the source lifecycle depends on some variables, you may pass the dependency list as a second parameter. The source will be recreated every time a dependency changes.

tsx
const [useSnapshot, source] = usePureSource(
  // Defines the source.
  () => new Source(dep1, dep2),
  // Defines the dependency list.
  [dep1, dep2]
);

Derive a snapshot

React strictly forbid to read a mutable object on render, but you can safely derive some state from it using useSnapshot. You can provide a pure function that generates the (Immutable) snapshot from the source as a first parameter.

tsx
const snapshot = useSnapshot(
  // Derives a snapshot.
  (source) => getSnapshot(source),
  // Subscribes to all the events that may change the snapshot.
  // We'll dig into it in the next chapter.
  subscribeToChanges
);

After each render, useSnapshot will compare the new snapshot with the current one, and if they are not the same, it will force a re-render so that the the components always see the latest snapshot.

INFO

use-mutable-source use use-sync-external-storage under the hood, so that your can safely use the snapshot.

getSnapshot is considered stable by default. If you need it to be dynamic, you may pass the dependency list right after.

tsx
const snapshot = useSnapshot(
  // Derives the snapshot using some variables.
  (source) => getSnapshot(source, dep1, dep2),
  // Defines the "getSnapshot" dependency list.
  [dep1, dep2],
  // Subscribes to changes.
  subscribe
);

Subscribe to changes

To make the snapshot always up to date, you have to provide a subscribe function as a second parameter. The function has to subscribe to all the events that may change the snapshot, using the onChange callback, and has to return an unsubscribe function.

tsx
const snapshot = useSnapshot(
  getSnapshot,
  // Defines the subscription.
  (source, onChange) => {
    // Subscribes to the events that will change the snapshot.
    subscribe(source, onChange);
    // Returns a callback to unsubscribe.
    return () => unsubscribe(source, onChange);
  }
);

subscribe is considered stable by default. If you need it to be dynamic, you may pass the dependency list right after. useSnapshot will resubscribe each time a dependency changes.

tsx
const snapshot = useSnapshot(
  getSnapshot,
  // Defines the subscription.
  (source, onChange) => {
    // Subscribes to the events based on some dependencies.
    subscribe(source, onChange, dep1, dep2);
    // Returns a callback to unsubscribe.
    return () => unsubscribe(source, onChange, dep1, dpe2);
  },
  // Defines the "subscription" dependency list.
  [dep1, dep2]
);

Comparing snapshots

When the immutable snapshot is an object, since it is derived from a mutable source, useSnapshot cannot rely on reference equality to determine if it has changed.

In these cases, it is necessary to manually compare the current snapshot with the generated snapshot, and return the current one if they are semantically equal.

A classic example is using a shallow comparison. use-mutable-source expose the comparer for your convenience.

tsx
import { shallowEqual } from 'use-mutable-source';
tsx
useSnapshot(
  // Derives a snapshot.
  (source, currentSnapshot) => {
    const snapshot = getSnapshot(source);
    // If the two snapshots are semantically equals, we can return the current
    // one to bailout from the update.
    return shallowEqual(currentSnapshot, snapshot) ? currentSnapshot : snapshot;
  },
  subscribe
);

Since useSnapshot will try to re-render each time the snapshot changes, if it is unable to determine that two snapshot are equal it may cause an infinite render loop. Check the next chapter to see how to avoid equality comparisons.

INFO

There is currently a limitation with typescript when you access currentSnapshot, and it is unable to infer the snapshot type. In those cases you should manually provide the snapshot type useSnapshot<SnapshotType>().

Exploit concurrent mode

By default, useSnapshot will use use-sync-external-storage to manage the subscription. Because there is no guarantee on when and where the snapshots are generated, each update will trigger a synchronous render.

This behavior is safe, but cannot benefit from concurrent mode. For this reason, we provide a compact API that we call atomic.

tsx
import { usePureSource } from 'use-mutable-source/atomic';

By constraining how and where you can derive a snapshot, we are able to exploit concurrent mode. We expect this to fit many use cases and we recommend using it whenever possible.

tsx
// Instead of useSnapshot we directly have the unique snapshot.
const [snapshot, source] = usePureSource(
  // Defines the source.
  () => new Source(),
  // Derives a snapshot.
  (source) => getSnapshot(source),
  // Subscribes to changes and returns the unsubscribe function.
  (source, onChange) => subscribe(source, onChange)
);

Again, all functions are considered stable by default. Unlike before, getSnapshot and init Cannot be dynamic (and their dependency lists cannot be defined). This is the tradeoff to achieve better performance.

TIP

If you are not sure that a function can be dynamic, just put the dependency list right after it and typescript will show an error if it cannot be done.

Contracts (avoid comparing snapshots)

You may be wondering why we need an equality comparison to determine if a snapshot has changed, when useSnapshot actually subscribes to change events.

This is necessary due to the order in which side effects are performed. A child component may make changes to the source before useSnapshot can actually subscribe.

To solve this we introduce a slightly different concept from subscriptions, that we call contracts.

tsx
import { usePureSource } from 'use-mutable-source/with-contract';

A contract is a function that register a callback to change events and doesn't perform any (other) side effect (it is Pure in a sense). Since it is side-effect free, it doesn't accept any unsubscribe callback in return.

By passing the contract directly to useSource, we are able to subscribe right after the source is generated, and remove the timing problem.

tsx
const [useSnapshot, source] = usePureSource(
  // Defines the source.
  () => new Source(),
  // Defines the contract.
  (source, onChange) => void subscribe(source, onChange)
);

useSnapshot will rely on that contract to listen for change events, no subscription has to be provided.

tsx
// Derives a snapshot.
const snapshot = useSnapshot((source) => getSnapshot(source));

Again, all functions are considered stable by default. Unlike before, the contract Cannot be dynamic (and its dependency lists cannot be defined) and is shared by all snapshots.

Also note that since there is no clean-up phase, you Must ensure that the source is garbage collectable even after the contract has registered the callback.

INFO

Those are the tradeoffs to achieve the best performance and dx that use-mutable-source can provide. The constraints are quite restrictive, but they should cover most use cases. In future we may extend the contracts concept to be more flexible.

Contracts and concurrent mode

Lastly, we also provide the atomic equivalent.

tsx
import { usePureSource } from 'use-mutable-source/with-contract/atomic';

Note that the contract comes before getSnapshot.

tsx
// Instead of useSnapshot we directly have the unique snapshot.
const [snapshot, source] = usePureSource(
  // Defines the source.
  () => new Source(),
  // Defines the contract.
  (source, onChange) => void subscribe(source, onChange),
  // Derives a snapshot.
  (source) => getSnapshot(source)
);

Again, all functions are considered stable by default and none can be dynamic (their dependency lists cannot be defined).

Examples

Check some of the examples.

Released under the MIT License.