Skip to content

ArthurClemens/use-stream

Repository files navigation

use-stream

A React Hook for working with observable action streams inside function components.

Motivation

Hooks and Streams by James Forbes is a great introduction to action streams. The article makes the case why streams are a better approach than hooks: "Streams are a composeable, customizable, time-independent data structure that does everything hooks do and more". Streams are a great way to handle state and to create reactive apps.

The downside: streams do not fit neatly within React's component rendering.

  • Function components are ran each render, so any state is either lost or re-initialized, except when using Redux or Hooks.
  • Stream state is disconnected from component state, so when a stream gets updated no new state is rendered.

This is where useStream comes in:

  • Memoizes streams so that they are initialized only once.
  • Re-renders the component whenever a stream is updated.

Example

import React from "react";
import { useStream } from "use-stream";
import stream from "mithril/stream"; // or another stream library

const App = () => {
  const { count } = useStream({
    model: {
      count: stream(0)
    }
  });
  return (
    <div className="page">
      <h1>{count()}</h1>
      <p>
        <button
          onClick={() => count(count() - 1)}
          disabled={count() === 0}
        >
          Decrement
        </button>
        <button onClick={() => count(count() + 1)}>Increment</button>
      </p>
    </div>
  );
}

Live examples

Usage

npm install use-stream

Stream libraries

You can use any stream library. The only prerequisite is that the stream has a map method.

Mithril Stream

In some examples below we'll use the lightweight stream module from Mithril, which comes separate from Mithril core code.

Mithril Stream documentation

Flyd

More full fledged stream library, but still quite small.

Flyd documentation

API

useStream({ model })
useStream({ model, deps, onMount, onUpdate, onDestroy, debug })

Type definition:

useStream<TModel>({ model, deps, onMount, onUpdate, onDestroy, debug } : {
  model: TModelGen<TModel>,
  defer?: boolean;
  deps?: React.DependencyList;
  onMount?: (model: TModel) => any,
  onUpdate?: (model: TModel) => any,
  onDestroy?: (model: TModel) => any,
  debug?: Debug.Debugger
}): TModel

interface Model {
  [key: string]: TStream<unknown>;
}

type TModelFn<TModel extends Model> = (_?: unknown) => TModel;

type TModelGen<TModel extends Model> = TModel | TModelFn<TModel>;

model

The model is a POJO object with (optionally multiple) streams. useStream returns this model once it is initialized.

Note that the model streams will be called at each render. See Optimizing the model instantiation below.

Example:

const { index, count } = useStream({
  model: {
    index: stream(0),
    count: stream(3)
  }
})

Optimizing the model instantiation

Model function

With a POJO object the model streams will be called at each render. While this does't mean that streams are reset at each call (because their results are memoized), some overhead may occur, and you need to be careful if you are causing side effects.

The optimization is to pass a function that returns the model object. This approach also gives more flexibility, for instance to connect model streams before passing the model.

Example:

const { index, count } = useStream({
  model: () => {
    const index = stream(0)
    const count = stream(3)
    count.map(console.log) // another stream that is subscribed to the count stream

    return {
      index,
      count
    }
  }
})
Deferring instantiation

One further optimization is to defer the initialization to the first render. See defer below for elaboration.

Using TypeScript

import flyd from "flyd";

type TModel = {
  count: flyd.Stream<number>;
}

const { count } = useStream<TModel>({
  model: {
    count: flyd.stream(0)
  }
});

// When using a model function:

const { count } = useStream<TModel>({
  model: () => ({
    count: flyd.stream(0)
  })
});

// count is now:
// const count: flyd.Stream<number>

Type definition:

model: TModelGen<TModel>

type TModelGen<TModel> = TModel | TModelFn<TModel>
type TModelFn<TModel> = (_?: any) => TModel

defer

Postpones the model initialization until after the first render (in React.useEffect). This also prevents that the initialization is called more than once.

The result of postponing to after the first render is that the model will not be available immediately.

The return contains the model plus boolean isDeferred, which can be used for conditional rendering.

Example:

const model = useStream({
  model: () => ({ // first optimization: model function
    index: stream(0),
    count: stream(3)
  }),
  defer: true, // second optimization
})

const { index, count, isDeferred } = model

if (isDeferred) {
  // first render
  return null
}

Type definition:

defer?: boolean

deps

React hooks deps array. Default [].

deps: [props.initCount]

Type definition:

deps?: React.DependencyList

onMount

Callback method to run side effects when the containing component is mounted.

onMount: (model) => {
  // Handle any side effects.
}

Type definition:

onMount?: (model: TModel) => any

onUpdate

When using deps. Callback method to run side effects when the containing component is updated through deps.

deps: [props.initCount],
onUpdate: (model) => {
  // Called when `initCount` is changed. Handle any side effects.
}

Type definition:

onUpdate?: (model: TModel) => any

onDestroy

Callback method to clean up side effects. onDestroy is called when the containing component goes out of scope.

onDestroy: (model) => {
  // Cleanup of any side effects.
}

Type definition:

onDestroy?: (model: TModel) => any

debug

Debugger instance. See: https://www.npmjs.com/package/debug

Provides feedback on the lifecycle of the model instance and stream subscriptions.

Example:

import Debug from "debug";

const debugUseStream = Debug("use-stream");
debugUseStream.enabled = true;
debugUseStream.log = console.log.bind(console);

const model = useStream({
  model: ...,
  debug: debugUseStream,
});

Type definition:

debug?: Debug.Debugger

Sizes

┌────────────────────────────────────────┐
│                                        │
│   Bundle Name:  use-stream.module.js   │
│   Bundle Size:  2.1 KB                 │
│   Minified Size:  852 B                │
│   Gzipped Size:  486 B                 │
│                                        │
└────────────────────────────────────────┘

┌─────────────────────────────────────┐
│                                     │
│   Bundle Name:  use-stream.umd.js   │
│   Bundle Size:  2.72 KB             │
│   Minified Size:  1.09 KB           │
│   Gzipped Size:  615 B              │
│                                     │
└─────────────────────────────────────┘

┌──────────────────────────────────┐
│                                  │
│   Bundle Name:  use-stream.cjs   │
│   Bundle Size:  2.19 KB          │
│   Minified Size:  940 B          │
│   Gzipped Size:  537 B           │
│                                  │
└──────────────────────────────────┘