A React Hook for working with observable action streams inside function components.
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.
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>
);
}
- All examples
- Listed examples:
npm install use-stream
You can use any stream library. The only prerequisite is that the stream has a map
method.
In some examples below we'll use the lightweight stream module from Mithril, which comes separate from Mithril core code.
More full fledged stream library, but still quite small.
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>;
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)
}
})
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
}
}
})
One further optimization is to defer the initialization to the first render. See defer below for elaboration.
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
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
React hooks deps array. Default []
.
deps: [props.initCount]
Type definition:
deps?: React.DependencyList
Callback method to run side effects when the containing component is mounted.
onMount: (model) => {
// Handle any side effects.
}
Type definition:
onMount?: (model: TModel) => any
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
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
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
┌────────────────────────────────────────┐
│ │
│ 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 │
│ │
└──────────────────────────────────┘