Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/malte-wessel/bassdrum
reactive, type safe components with preact and rxjs.
https://github.com/malte-wessel/bassdrum
dom preact reactive rxjs
Last synced: 1 day ago
JSON representation
reactive, type safe components with preact and rxjs.
- Host: GitHub
- URL: https://github.com/malte-wessel/bassdrum
- Owner: malte-wessel
- Created: 2019-11-14T23:31:55.000Z (almost 5 years ago)
- Default Branch: master
- Last Pushed: 2023-01-05T03:16:00.000Z (almost 2 years ago)
- Last Synced: 2024-10-27T22:39:10.055Z (17 days ago)
- Topics: dom, preact, reactive, rxjs
- Language: TypeScript
- Homepage: https://bassdrum.js.org
- Size: 3.52 MB
- Stars: 43
- Watchers: 3
- Forks: 2
- Open Issues: 82
-
Metadata Files:
- Readme: README.MD
Awesome Lists containing this project
README
# 🥁 bassdrum
bassdrum let's you create **reactive, type safe components** with `preact` and `rxjs`.
- Handle data transformation, events and side effects with `rxjs`
- Let `preact` render your JSX templates
- bassdrum is tiny 2KB (1KB gzip)
- bassdrum components are _fully compatible_ with `preact`[![Actions Status](https://github.com/malte-wessel/bassdrum/workflows/Node%20CI/badge.svg)](https://github.com/malte-wessel/bassdrum/actions)
## The gist
With bassdrum you create components by transforming `props` to `state` with `rxjs`. The transformed `state` will be passed to your JSX template and gets rendered with `preact`.
```tsx
interface Props {
items: Item[];
}interface State {
items: Item[];
count: number;
itemsPerPage: number;
}const AppFn: ComponentFunction = ({ props }) => {
const items = props.pipe(pluck('items'));
const count = items.pipe(map(items => items.length));
return combine(props, { count, itemsPerPage: 20 });
};const AppTemplate: ComponentTemplate = ({
items,
count,
itemsPerPage,
}) => (
We got {count} items for you!
);const App = createComponent(AppFn, AppTemplate);
```## Installation
```bash
# bassdrum has preact and rxjs as peer dependencies
yarn add bassdrum preact rxjs
```Check out the `/examples` directory on how to set up typescript properly
## Guide
bassdrum has a minimal api surface. It consists of four functions: `createComponent`, `combine`, `createHandler` & `createRef`.
To create a new bassdrum component use `createComponent`. It expects a `ComponentFunction` and a `ComponentTemplate`.
```tsx
import { h, render } from 'preact';
import {
createComponent,
ComponentFunction,
ComponentTemplate,
} from 'bassdrum';interface Props {
name: string;
}interface State {
name: string;
}// The component function is the heart of your component. The function maps the
// received `props` stream to a `state` stream.
const ComponentFn: ComponentFunction = ({ props }) => props;// Use the state returned from your component function stream in your template
const Template: ComponentTemplate = state => (
Hello, {state.name}
);// Create the component
const Component = createComponent(ComponentFn, Template);// Render the component as you would do with preact
const root = document.getElementById('root');
if (root) {
render(, root);
}
```### The component function
Let's have a closer look at the component function. This function is called once your component is about to be created. It receives a `props` stream that emits whenever the props change (`componentWillReceiveProps`), an `updates` stream that emits whenever all changes have been flushed to the DOM (`componentDidMount` & `componentDidUpdate`) and a `subscribe` function that you can use to subscribe to streams. The component takes care of unsubscribing from those streams when the component is unmounted.
#### Transforming data
To transform the data that you receive from the `props` stream, you can use the transformation operators from `rxjs`. In the following example we use the `pluck` operator to get a stream of `items` from the `props` and the `map` operator to map the `items` to its length.
```tsx
interface Props {
items: Item[];
}interface State {
items: Item[];
count: number;
itemsPerPage: number;
}const ComponentFn: ComponentFunction = ({ props }) => {
const items = props.pipe(pluck('items'));
const count = items.pipe(map(items => items.length));
return combine(props, { count, itemsPerPage: 20 });
};
```To combine the `props` stream and your transformed streams use the bassdrum `combine` method. This method can merge data from streams and plain objects that hold primitive values or streams. In the example above `combine` will emit an object that looks like this:
```js
{
items: [{...}],
count: 43,
itemsPerpage: 20
}
````combine` emits whenever one of its streams emits. We do a `shallowEqual` compare and only rerender the component if at least one of the values has changed. The component automatically subscribes and unsubscibes from the stream that you return from the component function.
If you derive complex data make sure to use the `rxjs` operator `distinctUntilChanged` to avoid unnecessary rerenders, like in the following example:
```tsx
const ComponentFn: ComponentFunction = ({ props }) => {
const items = props.pipe(
// `items` will emit whenever `props` emit,
// even though `items` haven't changed
pluck('items'),
// This operator will do a strict equal check
// and only emits if the items have really changed
distinctUntilChanged(),
);
const groupedByRating = items.pipe(map(groupBy(item => item.rating)));
return combine(props, { groupedByRating });
};
```#### Lifecycle events
To deal with lifecycle events in bassdrum all you need is the `updates` stream. Look at the following example
```tsx
const log = value => tap(() => console.log(value));const ComponentFn: ComponentFunction = ({
props,
updates,
subscribe,
}) => {
// the first value emitted by `updates` signals the did mount event
const mounted = updates.pipe(first());// skip the mounted event if you only want updates
const updated = updates.pipe(skip(1));// when the component will unmount, the `updates` stream completes
const unmounted = updates.pipe(last());subscribe(mounted.pipe(log('component did mount')));
subscribe(updated.pipe(log('component did update')));
subscribe(unmounted.pipe(log('component will unmount')));return props;
};
```Alright, let's have a look what's happening in this example. We use the `updates` stream to get notified about certain lifecycle events. The `updates` stream emits the current `props` whenever `componenDidMount` or `componentDidUpdate` is called. When the component is about to get unmounted, the `updates` stream completes. We can use the `rxjs` filtering operators to only react to certain events.
The first time `updates` emits the component is mounted. By using the `first` operator we will get a stream that only emits once the component is mounted and attached to the DOM. If you want a stream that only emits when the component has been updated, skip the first emit by using the `skip` operator. To deal with cleaning up use the `last` operator that returns a stream that only emits once the `updates` stream completes.
Let's say you want to notify a parent component about updates of your component. The `updates` stream emits the current value of `props`. Use the `tap` operator to hook into update events and use the data and callbacks passed to your component right there:
```tsx
const ComponentFn: ComponentFunction = ({
props,
updates,
subscribe,
}) => {
const updated = updates.pipe(
tap(props => {
const { handleUpdated, id } = props;
handleUpdated(id);
}),
);
subscribe(updated);
return props;
};
```#### Subscriptions
In the examples above we were using the `subscribe` function that is passed to your component function. Why is that? The streams that we are creating and not returning or passing to `combine` basically do nothing until you subscribe to them. In `rxjs` you would subscribe to a stream by calling it's `subscribe` method. This method returns an `unsubscribe` function that you need to call once you don't need your stream anymore. bassdrum provides you this util function that takes care of managing those subscriptions, so you don't need to deal with it. You can use this function e.g. when you are dealing with side effects that do not result in data that you use in your template.
### Working with handlers
Handlers in bassdrum work just like in `preact`. You create a function that you pass to the DOM or a child component. The way how you create them is different though. Since in bassdrum everything is a `rxjs` stream you want a stream of events when your handler is called. For this purpose we use `createHandler`.
The API of `createHandler` is inspired by react hooks. `createHandler` returns a handler function and an `rxjs` stream that emits whenever the handler is called. This way you can easily transform your event stream to data like in the following example.
```tsx
import {
combine,
createHandler,
ComponentFunction,
ComponentTemplate,
Handler,
MouseEvent,
} from 'bassdrum';interface Props {}
interface State {
handleClick: Handler;
counter: number;
}const ComponentFn: ComponentFunction = ({ updates }) => {
const [handleClick, clicks] = createHandler();const counter = clicks.pipe(
scan(value => value + 1, 0),
startWith(0),
);return combine({
handleClick,
counter,
});
};const Template: ComponentTemplate = ({ handleClick, counter }) => (
Your pressed this button {counter} times.
Increase counter
);
```When the user presses the button the `clicks` stream emits a click `event`. We use this information to increase the counter. The `counter` stream is derived from those clicks. It starts with a `0` and increases everytime it receives an event.
Why do we need to use the `startsWith` operator here? bassdrum expects the stream you return from the component function to immediately emit once your component is created. `combine` won't emit until every input stream has emitted. At the time the component is created `clicks` has never emitted. Thus we need `startWith` to immediately emit on component creation time.
### Working with refs
Similar to `createHandler`, `createRef` returns a `Ref` function that you pass to your template and a stream that emits once the respective element is created.
```tsx
const ComponentFn: ComponentFunction = ({ props, updates }) => {
const [ref, el] = createRef();subscribe(
el.pipe(tap(el => console.log('Look ma, this is an element', el))),
);return combine(props, {
ref,
});
};
```When using `refs` there are a few things to keep in mind. The `el` stream returned from `createRef` emits `null` on component creation, since at that point we do not have an element. Once your element is created by `preact` it will emit the element instance. In the example above you would see the following logs:
```
Look ma, this is an element null
Look ma, this is an element [Element]
```When the component gets unmounted you will see another `Look ma, this is an element null` log message. This is because the element is removed from the DOM and preact calls the handler with `null`. Now it's up to you to do some clean up logic.
Also keep in mind that `el` emits at the time the DOM element is created. This happens even before the component is considered to be mounted. That's why most of the time you want to combine it with the `updates` stream like in the following example.
```tsx
const ComponentFn: ComponentFunction = ({ props, updates }) => {
const [ref, el] = createRef();const width = combineLatest(el, updates).pipe(
map(([el]) => (el ? el.offsetWidth : 0)),
startWith(0),
);return combine(props, {
ref,
width,
});
};
````combineLatest(el, updates)` will emit once both `el` and `updates` have emitted. At that time your component is mounted and you can interact with your `el`, e.g. gathering data like the `offsetWith`. Keep in mind to use `startWith(0)` for your inital emit.
## Advanced example
```tsx
import { h, Ref, render } from 'preact';
import { combineLatest } from 'rxjs';
import { map, startWith, scan, distinctUntilChanged } from 'rxjs/operators';
import {
createComponent,
ComponentFunction,
ComponentTemplate,
combine,
createRef,
createHandler,
Handler,
MouseEvent,
} from 'bassdrum';interface Props {
name: string;
}interface State {
name: string;
ref: Ref;
width: number;
handleClick: Handler>;
toggle: boolean;
}const ComponentFn: ComponentFunction = ({ props, updates }) => {
const [ref, el] = createRef();
const [handleClick, clicks] = createHandler<
MouseEvent
>();const toggle = clicks.pipe(
scan(value => !value, true),
startWith(true),
);const width = combineLatest(el, updates).pipe(
map(([el]) => (el ? el.offsetWidth : 0)),
startWith(0),
distinctUntilChanged(),
);return combine(props, {
width,
ref,
handleClick,
toggle,
});
};const Template: ComponentTemplate = ({
name,
width,
ref,
toggle,
handleClick,
}) => (
Hello {name}, welcome to bassdrum!
This element is {width}px wide.
Toggle width
);const Component = createComponent(ComponentFn, Template);
const root = document.getElementById('root');
if (root) {
render(, root);
}
```## Prior art
The idea of transforming `props` to `state` with `rxjs` originates from
[melody-streams](https://github.com/trivago/melody/tree/master/packages/melody-streams)